mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-07 08:14:42 +00:00
Compare commits
22 Commits
feat/websi
...
prompt-tab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deadcb376b | ||
|
|
8a5a6d0f11 | ||
|
|
a1e6fb36d2 | ||
|
|
394e36984f | ||
|
|
19fff29204 | ||
|
|
b3b895a2a9 | ||
|
|
e5c81488e4 | ||
|
|
5c07198acb | ||
|
|
6fb90b224d | ||
|
|
a8e1fa8bef | ||
|
|
83ceef8cb3 | ||
|
|
4885ef856c | ||
|
|
873a75d607 | ||
|
|
f500e0dde7 | ||
|
|
8a26ac632d | ||
|
|
324ef4fab1 | ||
|
|
de6eb5a7e7 | ||
|
|
fb96e64d82 | ||
|
|
4730a53b3d | ||
|
|
ef6030da0f | ||
|
|
6b11e5aea6 | ||
|
|
4f505dc80b |
27
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
27
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -54,6 +54,33 @@ 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
|
||||
|
||||
197
browser_tests/assets/subgraphs/subgraph-with-collapsed-node.json
Normal file
197
browser_tests/assets/subgraphs/subgraph-with-collapsed-node.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"size": [400, 550],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
@@ -27,14 +27,7 @@
|
||||
"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": [],
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 },
|
||||
@@ -39,6 +40,7 @@ export const DefaultGraphPositions = {
|
||||
textEncodeNode2: Position
|
||||
textEncodeNodeToggler: Position
|
||||
emptySpaceClick: Position
|
||||
emptyCanvasClick: Position
|
||||
clipTextEncodeNode1InputSlot: Position
|
||||
clipTextEncodeNode2InputSlot: Position
|
||||
clipTextEncodeNode2InputLinkPath: Position
|
||||
|
||||
@@ -35,6 +35,13 @@ 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) {
|
||||
@@ -50,17 +57,22 @@ export async function triggerSerialization(page: Page): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||
if (!widget) {
|
||||
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]
|
||||
if (!widget) {
|
||||
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
|
||||
}
|
||||
|
||||
if (typeof widget.serializeValue !== 'function') {
|
||||
throw new Error(
|
||||
'mask widget on node 1 does not have a serializeValue function.'
|
||||
)
|
||||
}
|
||||
|
||||
await widget.serializeValue(node, 0)
|
||||
await widget.serializeValue(node, widgetIndex)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
|
||||
;(
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker.checkState()
|
||||
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
|
||||
}, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.checkState()
|
||||
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
|
||||
@@ -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 checkState() is still gated.
|
||||
// start the next transaction while captureCanvasState() is still gated.
|
||||
await expect
|
||||
.poll(() => getChangeTrackerDebugState(comfyPage))
|
||||
.toMatchObject({
|
||||
@@ -272,4 +272,42 @@ 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)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe(
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
test('Prevents captureCanvasState 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 checkState during graph loading.
|
||||
// Register an extension that forces captureCanvasState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
// which triggers a captureCanvasState 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.checkState()
|
||||
workflow.changeTracker.captureCanvasState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,3 +64,29 @@ 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'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -146,7 +146,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyCanvasClick
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clipboard.paste()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
@@ -174,7 +176,9 @@ 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: { x: 50, y: 500 } })
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyCanvasClick
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise2 = comfyPage.page.waitForResponse(
|
||||
|
||||
@@ -55,4 +55,30 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
122
browser_tests/tests/imageCrop.spec.ts
Normal file
122
browser_tests/tests/imageCrop.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -27,6 +28,85 @@ 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'] },
|
||||
@@ -52,7 +132,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -79,9 +159,245 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
await comfyPage.expectScreenshot(
|
||||
dialog,
|
||||
'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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
@@ -16,21 +17,24 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
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]
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
@@ -42,23 +46,13 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(container).toBeVisible()
|
||||
await expect(canvas).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
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')
|
||||
await expect(container).toHaveCSS('position', 'relative')
|
||||
|
||||
// position and z-index validation moved to the parent container of the minimap
|
||||
const minimapMainContainer = comfyPage.page.locator(
|
||||
@@ -69,59 +63,53 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
const { container, toggleButton } = getMinimapLocators(comfyPage)
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(container).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
const { container, toggleButton } = getMinimapLocators(comfyPage)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
await toggleButton.click()
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeHidden()
|
||||
|
||||
await toggleButton.click()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
await expect(minimap).toBeVisible()
|
||||
const { container, toggleButton, closeButton } =
|
||||
getMinimapLocators(comfyPage)
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
await expect(minimap).toBeHidden()
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
await closeButton.click()
|
||||
await expect(container).toBeHidden()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(toggleButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -129,12 +117,10 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
@@ -143,155 +129,192 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
|
||||
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Minimap canvas is non-empty for a workflow with nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap canvas is empty after all nodes are deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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)
|
||||
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
|
||||
})
|
||||
|
||||
test('Clicking minimap corner pans the main canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
|
||||
|
||||
await expect
|
||||
.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('Clicking minimap center after FitView causes minimal canvas movement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] -= 1000
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
|
||||
timeout: 2000
|
||||
})
|
||||
.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)
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
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 })
|
||||
const minimapBox = await container.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
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 ({
|
||||
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
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
test('Minimap re-renders after loading a different workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { canvas } = getMinimapLocators(comfyPage)
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Default workflow has content
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
// Load a very different workflow
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Minimap should still have content (different workflow, still has nodes)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap viewport position reflects canvas pan state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).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.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The viewport indicator should have moved within the minimap
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
if (!box || !positionBefore) return false
|
||||
return box.x !== positionBefore.x || box.y !== positionBefore.y
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -370,4 +370,64 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,9 +121,9 @@ test.describe('Workflow Persistence', () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
})
|
||||
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
@@ -388,7 +388,7 @@ test.describe('Workflow Persistence', () => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
'PR #10745 — saveWorkflow called captureCanvasState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -419,13 +419,13 @@ test.describe('Workflow Persistence', () => {
|
||||
.toBe(nodeCountA + 1)
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
// Trigger captureCanvasState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
@@ -464,7 +464,7 @@ test.describe('Workflow Persistence', () => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
'PR #10745 — saveWorkflowAs called captureCanvasState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -488,13 +488,13 @@ test.describe('Workflow Persistence', () => {
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
// Trigger captureCanvasState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
|
||||
|
||||
@@ -5,14 +5,18 @@ history by comparing serialized graph snapshots.
|
||||
|
||||
## How It Works
|
||||
|
||||
`checkState()` is the core method. It:
|
||||
`captureCanvasState()` 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 `checkState()` is explicitly triggered.
|
||||
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.
|
||||
|
||||
## Automatic Triggers
|
||||
|
||||
@@ -31,7 +35,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 `checkState()` Manually
|
||||
## When You Must Call `captureCanvasState()` Manually
|
||||
|
||||
The automatic triggers above are designed around LiteGraph's native DOM
|
||||
rendering. They **do not cover**:
|
||||
@@ -50,24 +54,42 @@ rendering. They **do not cover**:
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
// After mutating the graph:
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
```
|
||||
|
||||
### Existing Manual Call Sites
|
||||
|
||||
These locations already call `checkState()` explicitly:
|
||||
These locations call `captureCanvasState()` directly:
|
||||
|
||||
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
|
||||
- `ColorPickerButton.vue` — After changing node colors
|
||||
- `NodeSearchBoxPopover.vue` — After adding a node from search
|
||||
- `useAppSetDefaultView.ts` — After setting default view
|
||||
- `builderViewOptions.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
|
||||
- `workflowService.ts` — After workflow service operations
|
||||
- `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"). |
|
||||
|
||||
## Transaction Guards
|
||||
|
||||
@@ -76,7 +98,7 @@ For operations that make multiple changes that should be a single undo entry:
|
||||
```typescript
|
||||
changeTracker.beforeChange()
|
||||
// ... multiple graph mutations ...
|
||||
changeTracker.afterChange() // calls checkState() when nesting count hits 0
|
||||
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
|
||||
```
|
||||
|
||||
The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
@@ -84,8 +106,12 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- `checkState()` is a no-op during `loadGraphData` (guarded by
|
||||
- `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
|
||||
`isLoadingGraph`) to prevent cross-workflow corruption
|
||||
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `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)
|
||||
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
|
||||
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing
|
||||
|
||||
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -171,14 +171,10 @@ const sidebarPanelVisible = computed(
|
||||
)
|
||||
|
||||
const firstPanelVisible = computed(
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
|
||||
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
|
||||
)
|
||||
const lastPanelVisible = computed(
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
|
||||
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -268,6 +264,7 @@ const splitterRefreshKey = computed(() => {
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -275,6 +272,7 @@ const firstPanelStyle = computed(() => {
|
||||
})
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -293,9 +291,13 @@ const lastPanelStyle = computed(() => {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* 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']) {
|
||||
/* 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']
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const mockActiveWorkflow = ref<{
|
||||
isTemporary: boolean
|
||||
initialMode?: string
|
||||
isModified?: boolean
|
||||
changeTracker?: { checkState: () => void }
|
||||
changeTracker?: { captureCanvasState: () => void }
|
||||
} | null>({
|
||||
isTemporary: true,
|
||||
initialMode: 'app'
|
||||
|
||||
@@ -49,10 +49,10 @@ describe('setWorkflowDefaultView', () => {
|
||||
expect(app.rootGraph.extra.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls changeTracker.checkState', () => {
|
||||
it('calls changeTracker.captureCanvasState', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
|
||||
expect(workflow.changeTracker.captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks telemetry with correct default_view', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export function setWorkflowDefaultView(
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
workflow.changeTracker?.captureCanvasState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ function createMockWorkflow(
|
||||
const changeTracker = Object.assign(
|
||||
new ChangeTracker(workflow, structuredClone(defaultGraph)),
|
||||
{
|
||||
checkState: vi.fn() as Mock
|
||||
captureCanvasState: vi.fn() as Mock
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ const applyColor = (colorOption: ColorOption | null) => {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
currentColorOption.value = canvasColorOption
|
||||
showColorPicker.value = false
|
||||
workflowStore.activeWorkflow?.changeTracker.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const currentColorOption = ref<CanvasColorOption | null>(null)
|
||||
|
||||
@@ -143,7 +143,7 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useCanvasRefresh() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
canvasStore.canvas?.graph?.afterChange()
|
||||
canvasStore.canvas?.emitAfterChange()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useGroupMenuOptions() {
|
||||
groupContext.resizeTo(groupContext.children, padding)
|
||||
groupContext.graph?.change()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ export function useGroupMenuOptions() {
|
||||
})
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
groupContext.graph?.change()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const toggleNodeCollapse = () => {
|
||||
@@ -33,7 +33,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const toggleNodePin = () => {
|
||||
@@ -43,7 +43,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const toggleNodeBypass = () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useSelectionOperations() {
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const duplicateSelection = () => {
|
||||
@@ -73,7 +73,7 @@ export function useSelectionOperations() {
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
@@ -92,7 +92,7 @@ export function useSelectionOperations() {
|
||||
canvas.setDirty(true, true)
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
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?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -145,7 +145,7 @@ export function useSelectionOperations() {
|
||||
}
|
||||
})
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useSubgraphOperations() {
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const doUnpack = (
|
||||
@@ -46,7 +46,7 @@ export function useSubgraphOperations() {
|
||||
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
|
||||
graph.unpackSubgraph(subgraphNode, { skipMissingNodes })
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
const unpackSubgraph = () => {
|
||||
|
||||
446
src/composables/painter/usePainter.test.ts
Normal file
446
src/composables/painter/usePainter.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
checkState: vi.fn()
|
||||
captureCanvasState: 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.checkState).toHaveBeenCalled()
|
||||
expect(mockChangeTracker.captureCanvasState).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.checkState).not.toHaveBeenCalled()
|
||||
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,7 +432,7 @@ describe('useCoreCommands', () => {
|
||||
'alias2',
|
||||
'alias3'
|
||||
])
|
||||
expect(mockChangeTracker.checkState).toHaveBeenCalled()
|
||||
expect(mockChangeTracker.captureCanvasState).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.checkState).not.toHaveBeenCalled()
|
||||
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1164,7 +1164,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
if (description === null) return
|
||||
|
||||
extra.BlueprintDescription = description.trim() || undefined
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1201,7 +1201,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
|
||||
extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -430,6 +430,17 @@ 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', () => {
|
||||
|
||||
@@ -86,6 +86,7 @@ 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',
|
||||
@@ -304,6 +305,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isViewerMode: hasTargetDimensions
|
||||
})
|
||||
|
||||
if (mouseOnViewer) {
|
||||
load3d.updateStatusMouseOnViewer(true)
|
||||
}
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, load3d)
|
||||
|
||||
const sourceCameraState = source.getCameraState()
|
||||
@@ -416,6 +421,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isViewerMode: true
|
||||
})
|
||||
|
||||
if (mouseOnViewer) {
|
||||
load3d.updateStatusMouseOnViewer(true)
|
||||
}
|
||||
|
||||
await load3d.loadModel(modelUrl)
|
||||
currentModelUrl = modelUrl
|
||||
restoreStandaloneConfig(modelUrl)
|
||||
@@ -522,6 +531,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
* Notifies the viewer that the mouse has entered the viewer area.
|
||||
*/
|
||||
const handleMouseEnter = () => {
|
||||
mouseOnViewer = true
|
||||
load3d?.updateStatusMouseOnViewer(true)
|
||||
}
|
||||
|
||||
@@ -529,6 +539,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
* Notifies the viewer that the mouse has left the viewer area.
|
||||
*/
|
||||
const handleMouseLeave = () => {
|
||||
mouseOnViewer = false
|
||||
load3d?.updateStatusMouseOnViewer(false)
|
||||
}
|
||||
|
||||
@@ -727,6 +738,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (isStandaloneMode.value) {
|
||||
saveStandaloneConfig()
|
||||
}
|
||||
mouseOnViewer = false
|
||||
load3d?.remove()
|
||||
load3d = null
|
||||
sourceLoad3d = null
|
||||
|
||||
@@ -140,7 +140,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
workflow.changeTracker?.prepareForSave()
|
||||
await saveWorkflow(workflow)
|
||||
} else {
|
||||
let target: ComfyWorkflow
|
||||
@@ -157,8 +157,7 @@ export const useWorkflowService = () => {
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
|
||||
target.changeTracker?.prepareForSave()
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
|
||||
@@ -174,8 +173,7 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
|
||||
workflow.changeTracker?.prepareForSave()
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
workflow.directory +
|
||||
@@ -370,7 +368,7 @@ export const useWorkflowService = () => {
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker.store()
|
||||
activeWorkflow.changeTracker?.deactivate()
|
||||
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
|
||||
const activeState = activeWorkflow.activeState
|
||||
if (activeState) {
|
||||
|
||||
@@ -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 mockCheckState = vi.hoisted(() => vi.fn())
|
||||
const mockCaptureCanvasState = 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: {
|
||||
checkState: mockCheckState
|
||||
captureCanvasState: mockCaptureCanvasState
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -48,7 +48,7 @@ function createItems(...names: string[]): FormDropdownItem[] {
|
||||
describe('useWidgetSelectActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockCheckState.mockClear()
|
||||
mockCaptureCanvasState.mockClear()
|
||||
})
|
||||
|
||||
describe('updateSelectedItems', () => {
|
||||
@@ -71,7 +71,7 @@ describe('useWidgetSelectActions', () => {
|
||||
updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(modelValue.value).toBe('photo_abc.jpg')
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears modelValue when empty set', () => {
|
||||
@@ -93,7 +93,7 @@ describe('useWidgetSelectActions', () => {
|
||||
updateSelectedItems(new Set())
|
||||
|
||||
expect(modelValue.value).toBeUndefined()
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('useWidgetSelectActions', () => {
|
||||
await handleFilesUpdate([file])
|
||||
|
||||
expect(modelValue.value).toBe('uploaded.png')
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adds uploaded path to widget values array', async () => {
|
||||
|
||||
@@ -23,8 +23,8 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
function checkWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
function captureWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
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
|
||||
checkWorkflowState()
|
||||
captureWorkflowState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
@@ -109,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
checkWorkflowState()
|
||||
captureWorkflowState()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -732,6 +732,8 @@ export class ComfyApp {
|
||||
})
|
||||
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
if (!useExecutionStore().isJobForActiveWorkflow(detail.prompt_id)) return
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const executionId = String(detail.display_node || detail.node)
|
||||
|
||||
@@ -774,6 +776,8 @@ export class ComfyApp {
|
||||
})
|
||||
|
||||
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
||||
if (!useExecutionStore().isJobForActiveWorkflow(detail.jobId)) return
|
||||
|
||||
// Enhanced preview with explicit node context
|
||||
const { blob, displayNodeId, jobId } = detail
|
||||
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
||||
|
||||
302
src/scripts/changeTracker.test.ts
Normal file
302
src/scripts/changeTracker.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,8 @@ import log from 'loglevel'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { 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'
|
||||
@@ -26,14 +24,18 @@ 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 checkState from running during loadGraphData.
|
||||
* Guard flag to prevent captureCanvasState 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 checkState call in that window would serialize
|
||||
* the wrong graph into the old workflow's activeState, corrupting it.
|
||||
* the OLD workflow. Any captureCanvasState call in that window would
|
||||
* serialize the wrong graph into the old workflow's activeState, corrupting it.
|
||||
*/
|
||||
static isLoadingGraph = false
|
||||
/**
|
||||
@@ -91,6 +93,41 @@ 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
|
||||
@@ -138,8 +175,28 @@ export class ChangeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
checkState() {
|
||||
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
|
||||
if (!this.activeState) {
|
||||
this.activeState = currentState
|
||||
@@ -158,6 +215,19 @@ 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) {
|
||||
@@ -216,14 +286,14 @@ export class ChangeTracker {
|
||||
|
||||
afterChange() {
|
||||
if (!--this.changeCount) {
|
||||
this.checkState()
|
||||
this.captureCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
static init() {
|
||||
const getCurrentChangeTracker = () =>
|
||||
useWorkflowStore().activeWorkflow?.changeTracker
|
||||
const checkState = () => getCurrentChangeTracker()?.checkState()
|
||||
const captureState = () => getCurrentChangeTracker()?.captureCanvasState()
|
||||
|
||||
let keyIgnored = false
|
||||
window.addEventListener(
|
||||
@@ -267,8 +337,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('checkState on keydown')
|
||||
changeTracker.checkState()
|
||||
logger.debug('captureCanvasState on keydown')
|
||||
changeTracker.captureCanvasState()
|
||||
})
|
||||
},
|
||||
true
|
||||
@@ -277,34 +347,34 @@ export class ChangeTracker {
|
||||
window.addEventListener('keyup', () => {
|
||||
if (keyIgnored) {
|
||||
keyIgnored = false
|
||||
logger.debug('checkState on keyup')
|
||||
checkState()
|
||||
logger.debug('captureCanvasState on keyup')
|
||||
captureState()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clicking DOM elements (e.g. widgets)
|
||||
window.addEventListener('mouseup', () => {
|
||||
logger.debug('checkState on mouseup')
|
||||
checkState()
|
||||
logger.debug('captureCanvasState on mouseup')
|
||||
captureState()
|
||||
})
|
||||
|
||||
// Handle prompt queue event for dynamic widget changes
|
||||
api.addEventListener('promptQueued', () => {
|
||||
logger.debug('checkState on promptQueued')
|
||||
checkState()
|
||||
logger.debug('captureCanvasState on promptQueued')
|
||||
captureState()
|
||||
})
|
||||
|
||||
api.addEventListener('graphCleared', () => {
|
||||
logger.debug('checkState on graphCleared')
|
||||
checkState()
|
||||
logger.debug('captureCanvasState on graphCleared')
|
||||
captureState()
|
||||
})
|
||||
|
||||
// Handle litegraph clicks
|
||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||
const v = processMouseUp.apply(this, [e])
|
||||
logger.debug('checkState on processMouseUp')
|
||||
checkState()
|
||||
logger.debug('captureCanvasState on processMouseUp')
|
||||
captureState()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -318,9 +388,9 @@ export class ChangeTracker {
|
||||
) {
|
||||
const extendedCallback = (v: string) => {
|
||||
callback(v)
|
||||
checkState()
|
||||
captureState()
|
||||
}
|
||||
logger.debug('checkState on prompt')
|
||||
logger.debug('captureCanvasState on prompt')
|
||||
return prompt.apply(this, [title, value, extendedCallback, event])
|
||||
}
|
||||
|
||||
@@ -328,8 +398,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('checkState on contextMenuClose')
|
||||
checkState()
|
||||
logger.debug('captureCanvasState on contextMenuClose')
|
||||
captureState()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -381,7 +451,7 @@ export class ChangeTracker {
|
||||
const htmlElement = activeEl as HTMLElement
|
||||
if (`on${evt}` in htmlElement) {
|
||||
const listener = () => {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.()
|
||||
htmlElement.removeEventListener(evt, listener)
|
||||
}
|
||||
htmlElement.addEventListener(evt, listener)
|
||||
|
||||
67
src/scripts/pnginfo.test.ts
Normal file
67
src/scripts/pnginfo.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -364,29 +364,29 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('calls checkState when input is selected', async () => {
|
||||
it('calls captureCanvasState when input is selected', async () => {
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflowStore.activeWorkflow = workflow
|
||||
await nextTick()
|
||||
vi.mocked(workflow.changeTracker!.checkState).mockClear()
|
||||
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
|
||||
|
||||
store.selectedInputs.push([42, 'prompt'])
|
||||
await nextTick()
|
||||
|
||||
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
|
||||
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls checkState when input is deselected', async () => {
|
||||
it('calls captureCanvasState when input is deselected', async () => {
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflowStore.activeWorkflow = workflow
|
||||
store.selectedInputs.push([42, 'prompt'])
|
||||
await nextTick()
|
||||
vi.mocked(workflow.changeTracker!.checkState).mockClear()
|
||||
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
|
||||
|
||||
store.selectedInputs.splice(0, 1)
|
||||
await nextTick()
|
||||
|
||||
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
|
||||
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reflects input changes in linearData', async () => {
|
||||
|
||||
@@ -93,7 +93,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
inputs: [...data.inputs],
|
||||
outputs: [...data.outputs]
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
@@ -25,6 +26,9 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
// Reactive ref so the watcher on activeWorkflow?.path fires in tests
|
||||
const mockActiveWorkflow = ref<{ path: string } | null>(null)
|
||||
|
||||
// Mock the workflowStore
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
|
||||
@@ -35,7 +39,10 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
|
||||
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -754,3 +761,391 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
|
||||
expect(store.missingNodesError?.nodeTypes).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - isJobForActiveWorkflow', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = null
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
it('should return true when promptId is null (legacy message)', () => {
|
||||
expect(store.isJobForActiveWorkflow(null)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when promptId is undefined', () => {
|
||||
expect(store.isJobForActiveWorkflow(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when job is not in the session map (unknown job)', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
expect(store.isJobForActiveWorkflow('unknown-job')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when no active workflow is open', () => {
|
||||
mockActiveWorkflow.value = null
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
expect(store.isJobForActiveWorkflow('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when job path matches active workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
expect(store.isJobForActiveWorkflow('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when job path differs from active workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
expect(store.isJobForActiveWorkflow('job-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - WS message filtering by workflow tab', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function fireEvent<T>(name: string, detail: T) {
|
||||
const handler = apiEventHandlers.get(name)
|
||||
if (!handler) throw new Error(`${name} handler not bound`)
|
||||
handler(new CustomEvent(name, { detail }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = null
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
describe('handleExecuted filtering', () => {
|
||||
it('should update nodes when job matches active workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
// Start execution to set activeJobId
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
|
||||
// Fire executed for a node
|
||||
fireEvent('executed', {
|
||||
node: 'node-1',
|
||||
display_node: 'node-1',
|
||||
prompt_id: 'job-1',
|
||||
output: { images: [] }
|
||||
})
|
||||
|
||||
expect(store.activeJob?.nodes['node-1']).toBe(true)
|
||||
})
|
||||
|
||||
it('should ignore executed events from a different workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
fireEvent('executed', {
|
||||
node: 'node-1',
|
||||
display_node: 'node-1',
|
||||
prompt_id: 'job-1',
|
||||
output: { images: [] }
|
||||
})
|
||||
|
||||
// Node should not be marked as executed since we're on workflow-b
|
||||
expect(store.activeJob?.nodes['node-1']).not.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleExecutionCached filtering', () => {
|
||||
it('should ignore cached events from a different workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
fireEvent('execution_cached', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: Date.now(),
|
||||
nodes: ['node-1', 'node-2']
|
||||
})
|
||||
|
||||
expect(store.activeJob?.nodes['node-1']).not.toBe(true)
|
||||
expect(store.activeJob?.nodes['node-2']).not.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleProgress filtering', () => {
|
||||
it('should ignore progress from a different workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
fireEvent('progress', {
|
||||
value: 5,
|
||||
max: 10,
|
||||
prompt_id: 'job-1',
|
||||
node: 'node-1'
|
||||
})
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('should update progress when job matches active workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
fireEvent('progress', {
|
||||
value: 5,
|
||||
max: 10,
|
||||
prompt_id: 'job-1',
|
||||
node: 'node-1'
|
||||
})
|
||||
|
||||
expect(store._executingNodeProgress).toEqual({
|
||||
value: 5,
|
||||
max: 10,
|
||||
prompt_id: 'job-1',
|
||||
node: 'node-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleProgressState filtering', () => {
|
||||
it('should always update nodeProgressStatesByJob regardless of active workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
const nodes = {
|
||||
'node-1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent('progress_state', { prompt_id: 'job-1', nodes })
|
||||
|
||||
// Per-job map should always be updated
|
||||
expect(store.nodeProgressStatesByJob['job-1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('should NOT update nodeProgressStates when job is for a different workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
const nodes = {
|
||||
'node-1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent('progress_state', { prompt_id: 'job-1', nodes })
|
||||
|
||||
// nodeProgressStates (the "current view") should NOT be updated
|
||||
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should update nodeProgressStates when job matches active workflow', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflow-a')
|
||||
|
||||
const nodes = {
|
||||
'node-1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent('progress_state', { prompt_id: 'job-1', nodes })
|
||||
|
||||
expect(store.nodeProgressStates['node-1']).toBeDefined()
|
||||
expect(store.nodeProgressStates['node-1'].state).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-tab scenario', () => {
|
||||
it('should isolate progress between two workflows', () => {
|
||||
// Queue jobs from two different workflow tabs
|
||||
store.ensureSessionWorkflowPath('job-a', '/workflow-a')
|
||||
store.ensureSessionWorkflowPath('job-b', '/workflow-b')
|
||||
|
||||
// User is viewing workflow A
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
|
||||
// Start job-a
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-a',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Progress from job-a should show
|
||||
fireEvent('progress', {
|
||||
value: 3,
|
||||
max: 10,
|
||||
prompt_id: 'job-a',
|
||||
node: 'node-1'
|
||||
})
|
||||
expect(store._executingNodeProgress?.value).toBe(3)
|
||||
|
||||
// Progress from job-b should NOT show (different workflow)
|
||||
fireEvent('progress', {
|
||||
value: 7,
|
||||
max: 10,
|
||||
prompt_id: 'job-b',
|
||||
node: 'node-1'
|
||||
})
|
||||
// Should still be 3 from job-a
|
||||
expect(store._executingNodeProgress?.value).toBe(3)
|
||||
})
|
||||
|
||||
it('should show correct progress after switching tabs', () => {
|
||||
store.ensureSessionWorkflowPath('job-a', '/workflow-a')
|
||||
store.ensureSessionWorkflowPath('job-b', '/workflow-b')
|
||||
|
||||
// Start job-a
|
||||
fireEvent('execution_start', {
|
||||
prompt_id: 'job-a',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// User is on workflow A — progress from job-a appears
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
const nodesA = {
|
||||
'node-1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-a',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
fireEvent('progress_state', { prompt_id: 'job-a', nodes: nodesA })
|
||||
expect(store.nodeProgressStates['node-1']?.value).toBe(5)
|
||||
|
||||
// Switch to workflow B — progress from job-a should no longer update nodeProgressStates
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
const nodesA2 = {
|
||||
'node-1': {
|
||||
value: 8,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-a',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
fireEvent('progress_state', { prompt_id: 'job-a', nodes: nodesA2 })
|
||||
// nodeProgressStates should NOT be updated (still old value from last render)
|
||||
expect(store.nodeProgressStates['node-1']?.value).toBe(5)
|
||||
|
||||
// But nodeProgressStatesByJob should be updated
|
||||
expect(store.nodeProgressStatesByJob['job-a']['node-1'].value).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switch rehydration', () => {
|
||||
it('should rehydrate nodeProgressStates from the new workflow on tab switch', async () => {
|
||||
store.ensureSessionWorkflowPath('job-a', '/workflow-a')
|
||||
store.ensureSessionWorkflowPath('job-b', '/workflow-b')
|
||||
|
||||
// Populate per-job maps with progress data
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
const nodesA = {
|
||||
'node-1': {
|
||||
value: 3,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-a',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
fireEvent('progress_state', { prompt_id: 'job-a', nodes: nodesA })
|
||||
expect(store.nodeProgressStates['node-1']?.value).toBe(3)
|
||||
|
||||
mockActiveWorkflow.value = { path: '/workflow-b' }
|
||||
const nodesB = {
|
||||
'node-2': {
|
||||
value: 7,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-2',
|
||||
prompt_id: 'job-b',
|
||||
display_node_id: 'node-2'
|
||||
}
|
||||
}
|
||||
fireEvent('progress_state', { prompt_id: 'job-b', nodes: nodesB })
|
||||
expect(store.nodeProgressStates['node-2']?.value).toBe(7)
|
||||
|
||||
// Switch back to workflow A — watcher should rehydrate from job-a
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
await vi.dynamicImportSettled()
|
||||
// Wait for watcher to fire
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(store.nodeProgressStates['node-1']?.value).toBe(3)
|
||||
expect(store.nodeProgressStates['node-2']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear nodeProgressStates when switching to a workflow with no jobs', async () => {
|
||||
store.ensureSessionWorkflowPath('job-a', '/workflow-a')
|
||||
|
||||
mockActiveWorkflow.value = { path: '/workflow-a' }
|
||||
const nodesA = {
|
||||
'node-1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running' as const,
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-a',
|
||||
display_node_id: 'node-1'
|
||||
}
|
||||
}
|
||||
fireEvent('progress_state', { prompt_id: 'job-a', nodes: nodesA })
|
||||
expect(store.nodeProgressStates['node-1']?.value).toBe(5)
|
||||
|
||||
// Switch to a workflow with no queued jobs
|
||||
mockActiveWorkflow.value = { path: '/workflow-c' }
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -33,6 +33,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { createSessionTabMap } from '@/utils/sessionTabMap'
|
||||
|
||||
interface QueuedJob {
|
||||
/**
|
||||
@@ -72,11 +73,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
*/
|
||||
const jobIdToWorkflowId = ref<Map<string, string>>(new Map())
|
||||
|
||||
/**
|
||||
* Map of job ID to workflow file path in the current session.
|
||||
* Only populated for jobs that are queued in this browser tab.
|
||||
*/
|
||||
const jobIdToSessionWorkflowPath = shallowRef<Map<string, string>>(new Map())
|
||||
const sessionJobPaths = createSessionTabMap('Comfy.Execution.JobPaths')
|
||||
const jobIdToSessionWorkflowPath = sessionJobPaths.map
|
||||
|
||||
const initializingJobIds = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -255,11 +253,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// before the HTTP response from queuePrompt triggers storeJob.
|
||||
if (!jobIdToSessionWorkflowPath.value.has(activeJobId.value)) {
|
||||
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
|
||||
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
|
||||
if (path) sessionJobPaths.set(activeJobId.value, path)
|
||||
}
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
if (!isJobForActiveWorkflow(e.detail.prompt_id)) return
|
||||
if (!activeJob.value) return
|
||||
for (const n of e.detail.nodes) {
|
||||
activeJob.value.nodes[n] = true
|
||||
@@ -275,6 +274,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
|
||||
if (!isJobForActiveWorkflow(e.detail.prompt_id)) return
|
||||
if (!activeJob.value) return
|
||||
activeJob.value.nodes[e.detail.node] = true
|
||||
}
|
||||
@@ -335,26 +335,28 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
const { nodes, prompt_id: jobId } = e.detail
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
// Update the per-job progress map (always, regardless of active tab)
|
||||
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (nodeState.state === 'running' && !previousForJob[nodeId]) {
|
||||
// This node just started executing, revoke its previews
|
||||
// Note that we're doing the *actual* node id instead of the display node id
|
||||
// here intentionally. That way, we don't clear the preview every time a new node
|
||||
// within an expanded graph starts executing.
|
||||
const { revokePreviewsByExecutionId } = useNodeOutputStore()
|
||||
revokePreviewsByExecutionId(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the progress states for all nodes
|
||||
nodeProgressStatesByJob.value = {
|
||||
...nodeProgressStatesByJob.value,
|
||||
[jobId]: nodes
|
||||
}
|
||||
evictOldProgressJobs()
|
||||
|
||||
// Only update the "current view" progress if this job belongs to the active workflow tab
|
||||
if (!isJobForActiveWorkflow(jobId)) return
|
||||
|
||||
// Revoke previews for nodes that are starting to execute.
|
||||
// Gated behind isJobForActiveWorkflow so background jobs with overlapping
|
||||
// node IDs don't clear previews in the currently viewed workflow.
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (nodeState.state === 'running' && !previousForJob[nodeId]) {
|
||||
const { revokePreviewsByExecutionId } = useNodeOutputStore()
|
||||
revokePreviewsByExecutionId(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
@@ -370,6 +372,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
if (!isJobForActiveWorkflow(e.detail.prompt_id)) return
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
|
||||
@@ -557,25 +560,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
jobIdToWorkflowId.value.set(String(id), String(wid))
|
||||
}
|
||||
if (workflow?.path) {
|
||||
ensureSessionWorkflowPath(String(id), workflow.path)
|
||||
sessionJobPaths.set(String(id), workflow.path)
|
||||
}
|
||||
}
|
||||
|
||||
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
|
||||
const MAX_SESSION_PATH_ENTRIES = 4000
|
||||
|
||||
function ensureSessionWorkflowPath(jobId: string, path: string) {
|
||||
if (jobIdToSessionWorkflowPath.value.get(jobId) === path) return
|
||||
const next = new Map(jobIdToSessionWorkflowPath.value)
|
||||
next.set(jobId, path)
|
||||
while (next.size > MAX_SESSION_PATH_ENTRIES) {
|
||||
const oldest = next.keys().next().value
|
||||
if (oldest !== undefined) next.delete(oldest)
|
||||
else break
|
||||
}
|
||||
jobIdToSessionWorkflowPath.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or update a mapping from job ID to workflow ID.
|
||||
*/
|
||||
@@ -617,6 +605,63 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return jobIdToSessionWorkflowPath.value.get(activeJobId.value) === path
|
||||
})
|
||||
|
||||
/**
|
||||
* Check whether a job (by prompt_id) was initiated from the currently
|
||||
* active workflow tab. Used to filter incoming WS messages so that
|
||||
* visual state (node outputs, previews, progress indicators) only
|
||||
* applies to the workflow the user is looking at.
|
||||
*
|
||||
* Returns `true` (permissive) when:
|
||||
* - promptId is null/undefined (legacy message without prompt_id)
|
||||
* - promptId is not in the session map (job from before this session
|
||||
* or from another browser tab — graceful degradation)
|
||||
* - No active workflow is open
|
||||
*/
|
||||
function isJobForActiveWorkflow(
|
||||
promptId: string | null | undefined
|
||||
): boolean {
|
||||
if (!promptId) return true
|
||||
const jobPath = jobIdToSessionWorkflowPath.value.get(promptId)
|
||||
if (!jobPath) return true
|
||||
const activePath = workflowStore.activeWorkflow?.path
|
||||
if (!activePath) return true
|
||||
return jobPath === activePath
|
||||
}
|
||||
|
||||
// Rehydrate the "current view" progress when the user switches workflow tabs
|
||||
// so stale progress from the previous tab is not displayed.
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
(newPath) => {
|
||||
_executingNodeProgress.value = null
|
||||
if (!newPath) {
|
||||
nodeProgressStates.value = {}
|
||||
return
|
||||
}
|
||||
// Find the most recent job that belongs to the new active workflow
|
||||
const jobEntries = Object.entries(nodeProgressStatesByJob.value)
|
||||
for (let i = jobEntries.length - 1; i >= 0; i--) {
|
||||
const [jobId, states] = jobEntries[i]
|
||||
if (jobIdToSessionWorkflowPath.value.get(jobId) === newPath) {
|
||||
nodeProgressStates.value = states
|
||||
const firstRunning = Object.values(states).find(
|
||||
(state) => state.state === 'running'
|
||||
)
|
||||
if (firstRunning) {
|
||||
_executingNodeProgress.value = {
|
||||
value: firstRunning.value,
|
||||
max: firstRunning.max,
|
||||
prompt_id: firstRunning.prompt_id,
|
||||
node: firstRunning.display_node_id || firstRunning.node_id
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
nodeProgressStates.value = {}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -637,6 +682,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
runningWorkflowCount,
|
||||
initializingJobIds,
|
||||
isActiveWorkflowRunning,
|
||||
isJobForActiveWorkflow,
|
||||
isJobInitializing,
|
||||
clearInitializationByJobId,
|
||||
clearInitializationByJobIds,
|
||||
@@ -652,6 +698,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath
|
||||
ensureSessionWorkflowPath: sessionJobPaths.set
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
@@ -143,6 +144,12 @@ 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,13 @@ import {
|
||||
VIEWPORT_CACHE_MAX_SIZE
|
||||
} from '@/stores/subgraphNavigationStore'
|
||||
|
||||
const { mockSetDirty, mockFitView } = vi.hoisted(() => ({
|
||||
mockSetDirty: vi.fn(),
|
||||
mockFitView: vi.fn()
|
||||
}))
|
||||
const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
|
||||
() => ({
|
||||
mockSetDirty: vi.fn(),
|
||||
mockFitView: vi.fn(),
|
||||
mockRequestSlotSyncAll: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
@@ -66,6 +69,13 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ fitView: mockFitView })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
|
||||
() => ({
|
||||
requestSlotLayoutSyncForAllNodes: mockRequestSlotSyncAll
|
||||
})
|
||||
)
|
||||
|
||||
const mockCanvas = app.canvas
|
||||
|
||||
let rafCallbacks: FrameRequestCallback[] = []
|
||||
@@ -86,6 +96,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
mockCanvas.ds.state.offset = [0, 0]
|
||||
mockSetDirty.mockClear()
|
||||
mockFitView.mockClear()
|
||||
mockRequestSlotSyncAll.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -217,6 +228,53 @@ 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')
|
||||
|
||||
@@ -256,7 +256,10 @@ 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(),
|
||||
|
||||
52
src/utils/errorReportUtil.test.ts
Normal file
52
src/utils/errorReportUtil.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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"')
|
||||
})
|
||||
})
|
||||
166
src/utils/sessionTabMap.test.ts
Normal file
166
src/utils/sessionTabMap.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSessionTabMap } from '@/utils/sessionTabMap'
|
||||
|
||||
const PREFIX = 'test-prefix'
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
;(window as { name: string }).name = 'test-client'
|
||||
})
|
||||
|
||||
describe('createSessionTabMap', () => {
|
||||
describe('basic operations', () => {
|
||||
it('stores a value readable via map.value.get', () => {
|
||||
const { map, set } = createSessionTabMap(PREFIX)
|
||||
set('node-1', 'tab-a')
|
||||
expect(map.value.get('node-1')).toBe('tab-a')
|
||||
})
|
||||
|
||||
it('overwrites an existing key with a new value', () => {
|
||||
const { map, set } = createSessionTabMap(PREFIX)
|
||||
set('node-1', 'tab-a')
|
||||
set('node-1', 'tab-b')
|
||||
expect(map.value.get('node-1')).toBe('tab-b')
|
||||
expect(map.value.size).toBe(1)
|
||||
})
|
||||
|
||||
it('is a no-op when setting the same key/value pair', () => {
|
||||
const { map, set } = createSessionTabMap(PREFIX)
|
||||
set('node-1', 'tab-a')
|
||||
const refAfterFirst = map.value
|
||||
|
||||
set('node-1', 'tab-a')
|
||||
expect(map.value).toBe(refAfterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LRU eviction', () => {
|
||||
it('evicts oldest entries when exceeding maxEntries', () => {
|
||||
const { map, set } = createSessionTabMap(PREFIX, 3)
|
||||
set('a', '1')
|
||||
set('b', '2')
|
||||
set('c', '3')
|
||||
set('d', '4')
|
||||
|
||||
expect(map.value.size).toBe(3)
|
||||
expect(map.value.has('a')).toBe(false)
|
||||
expect(map.value.get('b')).toBe('2')
|
||||
expect(map.value.get('c')).toBe('3')
|
||||
expect(map.value.get('d')).toBe('4')
|
||||
})
|
||||
|
||||
it('refreshes key position on update, evicting the actual oldest', () => {
|
||||
const { map, set } = createSessionTabMap(PREFIX, 3)
|
||||
set('a', '1')
|
||||
set('b', '2')
|
||||
set('c', '3')
|
||||
|
||||
// Update 'a' with a new value makes it newest; 'b' is now oldest
|
||||
set('a', 'updated')
|
||||
set('d', '4')
|
||||
|
||||
expect(map.value.size).toBe(3)
|
||||
expect(map.value.has('b')).toBe(false)
|
||||
expect(map.value.get('a')).toBe('updated')
|
||||
expect(map.value.get('c')).toBe('3')
|
||||
expect(map.value.get('d')).toBe('4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionStorage persistence', () => {
|
||||
it('persists data to sessionStorage under the correct key', () => {
|
||||
const { set } = createSessionTabMap(PREFIX)
|
||||
set('node-1', 'tab-a')
|
||||
|
||||
const raw = sessionStorage.getItem(`${PREFIX}:test-client`)
|
||||
expect(raw).not.toBeNull()
|
||||
|
||||
const entries: [string, string][] = JSON.parse(raw!)
|
||||
expect(entries).toEqual([['node-1', 'tab-a']])
|
||||
})
|
||||
|
||||
it('persists multiple entries in insertion order', () => {
|
||||
const { set } = createSessionTabMap(PREFIX)
|
||||
set('x', '1')
|
||||
set('y', '2')
|
||||
|
||||
const entries: [string, string][] = JSON.parse(
|
||||
sessionStorage.getItem(`${PREFIX}:test-client`)!
|
||||
)
|
||||
expect(entries).toEqual([
|
||||
['x', '1'],
|
||||
['y', '2']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('restore on creation', () => {
|
||||
it('restores previously persisted data into the new map', () => {
|
||||
const entries: [string, string][] = [
|
||||
['node-1', 'tab-a'],
|
||||
['node-2', 'tab-b']
|
||||
]
|
||||
sessionStorage.setItem(`${PREFIX}:test-client`, JSON.stringify(entries))
|
||||
|
||||
const { map } = createSessionTabMap(PREFIX)
|
||||
expect(map.value.get('node-1')).toBe('tab-a')
|
||||
expect(map.value.get('node-2')).toBe('tab-b')
|
||||
expect(map.value.size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates data from a different client key with the same prefix', () => {
|
||||
const entries: [string, string][] = [['node-1', 'tab-a']]
|
||||
sessionStorage.setItem(`${PREFIX}:client-1`, JSON.stringify(entries))
|
||||
;(window as { name: string }).name = 'client-2'
|
||||
|
||||
const { map } = createSessionTabMap(PREFIX)
|
||||
|
||||
expect(map.value.get('node-1')).toBe('tab-a')
|
||||
// Old key is removed
|
||||
expect(sessionStorage.getItem(`${PREFIX}:client-1`)).toBeNull()
|
||||
// Data is persisted under the new key
|
||||
expect(sessionStorage.getItem(`${PREFIX}:client-2`)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not migrate data from a different prefix', () => {
|
||||
sessionStorage.setItem(
|
||||
'other-prefix:client-1',
|
||||
JSON.stringify([['x', '1']])
|
||||
)
|
||||
;(window as { name: string }).name = 'client-2'
|
||||
|
||||
const { map } = createSessionTabMap(PREFIX)
|
||||
expect(map.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graceful degradation', () => {
|
||||
it('works in-memory when window.name is empty', () => {
|
||||
;(window as { name: string }).name = ''
|
||||
|
||||
const { map, set } = createSessionTabMap(PREFIX)
|
||||
set('node-1', 'tab-a')
|
||||
|
||||
expect(map.value.get('node-1')).toBe('tab-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactivity', () => {
|
||||
it('produces a new Map reference on each set call', () => {
|
||||
const { map, set } = createSessionTabMap(PREFIX)
|
||||
const ref1 = map.value
|
||||
|
||||
set('a', '1')
|
||||
const ref2 = map.value
|
||||
|
||||
set('b', '2')
|
||||
const ref3 = map.value
|
||||
|
||||
expect(ref1).not.toBe(ref2)
|
||||
expect(ref2).not.toBe(ref3)
|
||||
})
|
||||
})
|
||||
})
|
||||
77
src/utils/sessionTabMap.ts
Normal file
77
src/utils/sessionTabMap.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ShallowRef } from 'vue'
|
||||
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
interface SessionTabMap {
|
||||
readonly map: ShallowRef<Map<string, string>>
|
||||
set(key: string, value: string): void
|
||||
}
|
||||
|
||||
export function createSessionTabMap(
|
||||
prefix: string,
|
||||
maxEntries: number = 200
|
||||
): SessionTabMap {
|
||||
const capacity = Math.max(0, Math.floor(maxEntries))
|
||||
const map = shallowRef<Map<string, string>>(restore(prefix))
|
||||
|
||||
function set(key: string, value: string): void {
|
||||
if (map.value.get(key) === value) return
|
||||
const next = new Map(map.value)
|
||||
next.delete(key)
|
||||
next.set(key, value)
|
||||
|
||||
while (next.size > capacity) {
|
||||
const oldest = next.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
next.delete(oldest)
|
||||
}
|
||||
|
||||
map.value = next
|
||||
persist(prefix, next)
|
||||
}
|
||||
|
||||
return { map, set }
|
||||
}
|
||||
|
||||
function storageKey(prefix: string): string | null {
|
||||
const clientId = window.name
|
||||
return clientId ? `${prefix}:${clientId}` : null
|
||||
}
|
||||
|
||||
function persist(prefix: string, data: Map<string, string>): void {
|
||||
const key = storageKey(prefix)
|
||||
if (!key) return
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(Array.from(data.entries())))
|
||||
} catch {
|
||||
// Graceful degradation
|
||||
}
|
||||
}
|
||||
|
||||
function restore(prefix: string): Map<string, string> {
|
||||
const key = storageKey(prefix)
|
||||
if (!key) return new Map()
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
if (raw) return new Map(JSON.parse(raw) as [string, string][])
|
||||
return migrate(prefix, key)
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(prefix: string, newKey: string): Map<string, string> {
|
||||
const searchPrefix = `${prefix}:`
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const existingKey = sessionStorage.key(i)
|
||||
if (!existingKey?.startsWith(searchPrefix) || existingKey === newKey)
|
||||
continue
|
||||
const raw = sessionStorage.getItem(existingKey)
|
||||
if (!raw) continue
|
||||
const migrated = new Map(JSON.parse(raw) as [string, string][])
|
||||
persist(prefix, migrated)
|
||||
sessionStorage.removeItem(existingKey)
|
||||
return migrated
|
||||
}
|
||||
return new Map()
|
||||
}
|
||||
Reference in New Issue
Block a user