mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 05:01:02 +00:00
Compare commits
29 Commits
bl/subscri
...
refactor/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abde46a7f | ||
|
|
632edb79b2 | ||
|
|
9b0b67aa5b | ||
|
|
1227072d4e | ||
|
|
a1e6fb36d2 | ||
|
|
394e36984f | ||
|
|
19fff29204 | ||
|
|
b3b895a2a9 | ||
|
|
e5c81488e4 | ||
|
|
5c07198acb | ||
|
|
6fb90b224d | ||
|
|
38934fc3b5 | ||
|
|
4ded224173 | ||
|
|
2693ec2560 | ||
|
|
31303ad6d7 | ||
|
|
803d5856d0 | ||
|
|
86f5067908 | ||
|
|
1911b843e9 | ||
|
|
bdfb3ec5ed | ||
|
|
f44c72fd67 | ||
|
|
0dac6e178f | ||
|
|
2c874d092b | ||
|
|
6456a79491 | ||
|
|
706b930e2c | ||
|
|
065b9a37c1 | ||
|
|
0087c24ded | ||
|
|
c3f7beea0c | ||
|
|
7c4b91fca9 | ||
|
|
959bd0f830 |
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
|
||||
|
||||
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"id": "selection-bbox-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [300, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [800, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 200, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
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
|
||||
|
||||
@@ -27,6 +27,15 @@ export class KeyboardHelper {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async altSend(
|
||||
keyToPress: string,
|
||||
locator: Locator | null = this.canvas
|
||||
): Promise<void> {
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Alt+${keyToPress}`)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async selectAll(locator?: Locator | null): Promise<void> {
|
||||
await this.ctrlSend('KeyA', locator)
|
||||
}
|
||||
@@ -35,6 +44,10 @@ export class KeyboardHelper {
|
||||
await this.ctrlSend('KeyB', locator)
|
||||
}
|
||||
|
||||
async collapse(locator?: Locator | null): Promise<void> {
|
||||
await this.altSend('KeyC', locator)
|
||||
}
|
||||
|
||||
async undo(locator?: Locator | null): Promise<void> {
|
||||
await this.ctrlSend('KeyZ', locator)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -120,6 +123,27 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
|
||||
return this.page.evaluate(
|
||||
() => window.app!.graph.serialize() as ComfyWorkflowJSON
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
(d) => window.app!.loadGraphData(d, true, true, null),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
async repositionNodes(
|
||||
positions: Record<string, [number, number]>
|
||||
): Promise<void> {
|
||||
const data = await this.getSerializedGraph()
|
||||
applyNodePositions(data, positions)
|
||||
await this.loadGraph(data)
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -202,3 +226,13 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodePositions(
|
||||
data: ComfyWorkflowJSON,
|
||||
positions: Record<string, [number, number]>
|
||||
): void {
|
||||
for (const node of data.nodes) {
|
||||
const pos = positions[String(node.id)]
|
||||
if (pos) node.pos = pos
|
||||
}
|
||||
}
|
||||
|
||||
99
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
99
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
|
||||
const SELECTION_PADDING = 10
|
||||
|
||||
export async function measureSelectionBounds(
|
||||
page: Page,
|
||||
nodeIds: string[]
|
||||
): Promise<MeasureResult> {
|
||||
return page.evaluate(
|
||||
({ ids, padding }) => {
|
||||
const canvas = window.app!.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
const selectedItems = canvas.selectedItems
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const item of selectedItems) {
|
||||
const rect = item.boundingRect
|
||||
minX = Math.min(minX, rect[0])
|
||||
minY = Math.min(minY, rect[1])
|
||||
maxX = Math.max(maxX, rect[0] + rect[2])
|
||||
maxY = Math.max(maxY, rect[1] + rect[3])
|
||||
}
|
||||
const selectionBounds =
|
||||
selectedItems.size > 0
|
||||
? {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
w: maxX - minX + 2 * padding,
|
||||
h: maxY - minY + 2 * padding
|
||||
}
|
||||
: null
|
||||
|
||||
const canvasEl = canvas.canvas as HTMLCanvasElement
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
const nodeVisualBounds: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${id}"]`
|
||||
) as HTMLElement | null
|
||||
|
||||
// Legacy mode: no Vue DOM element, use boundingRect directly
|
||||
if (!nodeEl) {
|
||||
const node = window.app!.graph._nodes.find(
|
||||
(n: { id: number | string }) => String(n.id) === id
|
||||
)
|
||||
if (node) {
|
||||
const rect = node.boundingRect
|
||||
nodeVisualBounds[id] = {
|
||||
x: rect[0],
|
||||
y: rect[1],
|
||||
w: rect[2],
|
||||
h: rect[3]
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const domRect = nodeEl.getBoundingClientRect()
|
||||
const footerEls = nodeEl.querySelectorAll(
|
||||
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
|
||||
)
|
||||
let bottom = domRect.bottom
|
||||
for (const footerEl of footerEls) {
|
||||
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
|
||||
}
|
||||
|
||||
nodeVisualBounds[id] = {
|
||||
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
|
||||
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
|
||||
w: domRect.width / ds.scale,
|
||||
h: (bottom - domRect.top) / ds.scale
|
||||
}
|
||||
}
|
||||
|
||||
return { selectionBounds, nodeVisualBounds }
|
||||
},
|
||||
{ ids: nodeIds, padding: SELECTION_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
234
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
234
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
const REGULAR_ID = '3'
|
||||
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
||||
|
||||
const REF_POS: [number, number] = [100, 100]
|
||||
const TARGET_POSITIONS: Record<string, [number, number]> = {
|
||||
'bottom-left': [50, 500],
|
||||
'bottom-right': [600, 500]
|
||||
}
|
||||
|
||||
type NodeType = 'subgraph' | 'regular'
|
||||
type NodeState = 'expanded' | 'collapsed'
|
||||
type Position = 'bottom-left' | 'bottom-right'
|
||||
|
||||
function getTargetId(type: NodeType): string {
|
||||
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
|
||||
}
|
||||
|
||||
function getRefId(type: NodeType): string {
|
||||
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
|
||||
}
|
||||
|
||||
async function userToggleCollapse(
|
||||
comfyPage: ComfyPage,
|
||||
nodeRef: NodeReference
|
||||
) {
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.keyboard.collapse()
|
||||
}
|
||||
|
||||
async function userToggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
}
|
||||
|
||||
async function assertSelectionEncompassesNodes(
|
||||
page: Page,
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(2)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await measureSelectionBounds(page, nodeIds)
|
||||
expect(result.selectionBounds).not.toBeNull()
|
||||
|
||||
const sel = result.selectionBounds!
|
||||
const selRight = sel.x + sel.w
|
||||
const selBottom = sel.y + sel.h
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const vis = result.nodeVisualBounds[nodeId]
|
||||
expect(vis).toBeDefined()
|
||||
|
||||
expect(sel.x).toBeLessThanOrEqual(vis.x)
|
||||
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
|
||||
expect(sel.y).toBeLessThanOrEqual(vis.y)
|
||||
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const nodeTypes: NodeType[] = ['subgraph', 'regular']
|
||||
const nodeStates: NodeState[] = ['expanded', 'collapsed']
|
||||
const positions: Position[] = ['bottom-left', 'bottom-right']
|
||||
|
||||
for (const type of nodeTypes) {
|
||||
for (const state of nodeStates) {
|
||||
for (const pos of positions) {
|
||||
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const targetId = getTargetId(type)
|
||||
const refId = getRefId(type)
|
||||
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[refId]: REF_POS,
|
||||
[targetId]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
|
||||
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
refId,
|
||||
targetId
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('collapsed node narrows bounding box when bypass is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await userToggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await userToggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
|
||||
test('collapsed node widens bounding box when bypass is added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await userToggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (legacy mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const nodeStates: NodeState[] = ['expanded', 'collapsed']
|
||||
const positions: Position[] = ['bottom-left', 'bottom-right']
|
||||
|
||||
for (const state of nodeStates) {
|
||||
for (const pos of positions) {
|
||||
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Point,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
@@ -653,4 +654,47 @@ describe('LGraphNode', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measure() collapsed branching', () => {
|
||||
let out: Rect
|
||||
|
||||
beforeEach(() => {
|
||||
out = [0, 0, 0, 0] as unknown as Rect
|
||||
node.flags.collapsed = true
|
||||
node.size[0] = 150
|
||||
node.size[1] = 10
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
test('legacy mode uses NODE_TITLE_HEIGHT-based fallback when no ctx', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
node.measure(out)
|
||||
|
||||
// No ctx → legacy collapsed branch falls back to NODE_COLLAPSED_WIDTH
|
||||
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
|
||||
test('Vue mode uses this.size directly for collapsed nodes', () => {
|
||||
LiteGraph.vueNodesMode = true
|
||||
node.measure(out)
|
||||
|
||||
// Vue mode collapsed takes the expanded-style branch
|
||||
expect(out[2]).toBe(150)
|
||||
expect(out[3]).toBe(10 + LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
|
||||
test('Vue mode expanded behaves identically to legacy expanded', () => {
|
||||
LiteGraph.vueNodesMode = true
|
||||
node.flags.collapsed = false
|
||||
node.size[0] = 200
|
||||
node.size[1] = 120
|
||||
node.measure(out)
|
||||
|
||||
expect(out[2]).toBe(200)
|
||||
expect(out[3]).toBe(120 + LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2088,7 +2088,10 @@ export class LGraphNode
|
||||
|
||||
out[0] = this.pos[0]
|
||||
out[1] = this.pos[1] + -titleHeight
|
||||
if (!this.flags?.collapsed) {
|
||||
// In Vue mode, `this.size` is kept in sync with the DOM-measured
|
||||
// collapsed dimensions via ResizeObserver → layoutStore → useLayoutSync,
|
||||
// so the expanded branch produces correct bounds for collapsed nodes too.
|
||||
if (!this.flags?.collapsed || LiteGraph.vueNodesMode) {
|
||||
out[2] = this.size[0]
|
||||
out[3] = this.size[1] + titleHeight
|
||||
} else {
|
||||
|
||||
@@ -155,7 +155,6 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -170,8 +169,7 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { syncStatusAfterCheckout } = useSubscription()
|
||||
const { fetchStatus, isActiveSubscription } = useBillingContext()
|
||||
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
@@ -192,20 +190,48 @@ const telemetry = useTelemetry()
|
||||
// Always show custom pricing table for cloud subscriptions
|
||||
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
|
||||
|
||||
const refreshSubscriptionStatus = async () => {
|
||||
try {
|
||||
await syncStatusAfterCheckout()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to refresh subscription status',
|
||||
error
|
||||
)
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_ATTEMPTS = 3
|
||||
let pollInterval: number | null = null
|
||||
let pollAttempts = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollAttempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
pollAttempts++
|
||||
|
||||
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
if (showCustomPricingTable.value) {
|
||||
void refreshSubscriptionStatus()
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +242,7 @@ watch(
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
} else {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -225,6 +252,7 @@ watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
@@ -235,6 +263,7 @@ const handleSubscribed = () => {
|
||||
}
|
||||
|
||||
const handleChooseTeam = () => {
|
||||
stopPolling()
|
||||
if (onChooseTeam) {
|
||||
onChooseTeam()
|
||||
} else {
|
||||
@@ -243,6 +272,7 @@ const handleChooseTeam = () => {
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -265,6 +295,7 @@ const handleViewEnterprise = () => {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,6 @@ const {
|
||||
})),
|
||||
mockTelemetry: {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
},
|
||||
mockUserId: { value: 'user-123' }
|
||||
@@ -135,40 +134,20 @@ describe('useSubscription', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mockUserId.value = 'user-123'
|
||||
mockIsCloud.value = true
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
localStorage.clear()
|
||||
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
if (url.includes('/customers/pending-subscription-success/')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (url.includes('/customers/pending-subscription-success')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response
|
||||
})
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response)
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
@@ -296,146 +275,6 @@ describe('useSubscription', () => {
|
||||
|
||||
await expect(fetchStatus()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('syncs and consumes pending subscription success when requested', async () => {
|
||||
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
if (url.includes('/customers/cloud-subscription-status')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (url.endsWith('/customers/pending-subscription-success')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'event-123',
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (
|
||||
url.endsWith(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`)
|
||||
})
|
||||
|
||||
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
|
||||
|
||||
await syncStatusAfterCheckout()
|
||||
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: 'POST'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retrack a subscription success already delivered in this browser', async () => {
|
||||
localStorage.setItem(
|
||||
'comfy.subscription_success.delivered_transactions',
|
||||
JSON.stringify(['stripe-event-123'])
|
||||
)
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
if (url.includes('/customers/cloud-subscription-status')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (url.endsWith('/customers/pending-subscription-success')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'event-123',
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 20,
|
||||
currency: 'USD',
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (
|
||||
url.endsWith(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`)
|
||||
})
|
||||
|
||||
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
|
||||
|
||||
await syncStatusAfterCheckout()
|
||||
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded
|
||||
).not.toHaveBeenCalled()
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: 'POST'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribe', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
@@ -13,7 +13,6 @@ import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
@@ -25,82 +24,6 @@ export type CloudSubscriptionStatusResponse = NonNullable<
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
type TrackedSubscriptionTierKey = Exclude<TierKey, 'free'>
|
||||
|
||||
type PendingSubscriptionSuccessResponse = {
|
||||
id: string
|
||||
transaction_id: string
|
||||
value: number
|
||||
currency: string
|
||||
tier: TrackedSubscriptionTierKey
|
||||
cycle: 'monthly' | 'yearly'
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TrackedSubscriptionTierKey | null
|
||||
}
|
||||
|
||||
type FetchSubscriptionStatusOptions = {
|
||||
syncPendingSuccess?: boolean
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY =
|
||||
'comfy.subscription_success.delivered_transactions'
|
||||
|
||||
function readDeliveredSubscriptionSuccessTransactions(): string[] {
|
||||
if (typeof window === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(
|
||||
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY
|
||||
)
|
||||
if (!rawValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsedValue = JSON.parse(rawValue)
|
||||
if (!Array.isArray(parsedValue)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsedValue.filter(
|
||||
(transactionId): transactionId is string =>
|
||||
typeof transactionId === 'string' && transactionId.length > 0
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function hasDeliveredSubscriptionSuccess(transactionId: string): boolean {
|
||||
return readDeliveredSubscriptionSuccessTransactions().includes(transactionId)
|
||||
}
|
||||
|
||||
function markSubscriptionSuccessAsDelivered(transactionId: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTransactions = [
|
||||
transactionId,
|
||||
...readDeliveredSubscriptionSuccessTransactions().filter(
|
||||
(existingTransactionId) => existingTransactionId !== transactionId
|
||||
)
|
||||
].slice(0, 20)
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY,
|
||||
JSON.stringify(nextTransactions)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[Subscription] Failed to persist delivered subscription success transaction',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
@@ -188,111 +111,8 @@ function useSubscriptionInternal() {
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
return {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPendingSubscriptionSuccess = async (
|
||||
headers: Record<string, string>
|
||||
): Promise<PendingSubscriptionSuccessResponse | null> => {
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/pending-subscription-success'),
|
||||
{
|
||||
headers
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch pending subscription success: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const consumePendingSubscriptionSuccess = async (
|
||||
headers: Record<string, string>,
|
||||
id: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
buildApiUrl(`/customers/pending-subscription-success/${id}/consume`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(
|
||||
`Failed to consume pending subscription success: ${response.status}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const syncPendingSubscriptionSuccess = async (
|
||||
headers: Record<string, string>
|
||||
): Promise<void> => {
|
||||
const pendingSuccess = await fetchPendingSubscriptionSuccess(headers)
|
||||
if (!pendingSuccess) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasDeliveredSubscriptionSuccess(pendingSuccess.transaction_id)) {
|
||||
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
|
||||
return
|
||||
}
|
||||
|
||||
telemetry?.trackMonthlySubscriptionSucceeded({
|
||||
...(authStore.userId ? { user_id: authStore.userId } : {}),
|
||||
transaction_id: pendingSuccess.transaction_id,
|
||||
value: pendingSuccess.value,
|
||||
currency: pendingSuccess.currency,
|
||||
tier: pendingSuccess.tier,
|
||||
cycle: pendingSuccess.cycle,
|
||||
checkout_type: pendingSuccess.checkout_type,
|
||||
...(pendingSuccess.previous_tier
|
||||
? { previous_tier: pendingSuccess.previous_tier }
|
||||
: {}),
|
||||
ecommerce: {
|
||||
transaction_id: pendingSuccess.transaction_id,
|
||||
value: pendingSuccess.value,
|
||||
currency: pendingSuccess.currency,
|
||||
items: [
|
||||
{
|
||||
item_name: pendingSuccess.tier,
|
||||
item_category: 'subscription',
|
||||
item_variant: pendingSuccess.cycle,
|
||||
price: pendingSuccess.value,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
markSubscriptionSuccessAsDelivered(pendingSuccess.transaction_id)
|
||||
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
|
||||
}
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
() => fetchSubscriptionStatus(),
|
||||
reportError
|
||||
)
|
||||
|
||||
const syncStatusAfterCheckout = wrapWithErrorHandlingAsync(
|
||||
() => fetchSubscriptionStatus({ syncPendingSuccess: true }),
|
||||
fetchSubscriptionStatus,
|
||||
reportError
|
||||
)
|
||||
|
||||
@@ -368,15 +188,19 @@ function useSubscriptionInternal() {
|
||||
* Fetch the current cloud subscription status for the authenticated user
|
||||
* @returns Subscription status or null if no subscription exists
|
||||
*/
|
||||
async function fetchSubscriptionStatus(
|
||||
options?: FetchSubscriptionStatusOptions
|
||||
): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const headers = await buildAuthHeaders()
|
||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-status'),
|
||||
{
|
||||
headers
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -392,47 +216,15 @@ function useSubscriptionInternal() {
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
|
||||
if (options?.syncPendingSuccess && statusData.is_active) {
|
||||
await syncPendingSubscriptionSuccess(headers)
|
||||
}
|
||||
|
||||
return statusData
|
||||
}
|
||||
|
||||
const handleDeliveredSubscriptionSuccessChange = (event: StorageEvent) => {
|
||||
if (
|
||||
event.key !== SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY ||
|
||||
!isCloud ||
|
||||
!isLoggedIn.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void fetchSubscriptionStatus().catch((error) => {
|
||||
console.error(
|
||||
'[Subscription] Failed to refresh subscription status after cross-tab success delivery:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', handleDeliveredSubscriptionSuccessChange)
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.removeEventListener(
|
||||
'storage',
|
||||
handleDeliveredSubscriptionSuccessChange
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn && isCloud) {
|
||||
try {
|
||||
await fetchSubscriptionStatus({ syncPendingSuccess: true })
|
||||
await fetchSubscriptionStatus()
|
||||
} catch (error) {
|
||||
// Network errors are expected during navigation/component unmount
|
||||
// and when offline - log for debugging but don't surface to user
|
||||
@@ -451,14 +243,20 @@ function useSubscriptionInternal() {
|
||||
|
||||
const initiateSubscriptionCheckout =
|
||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||
const headers = await buildAuthHeaders()
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
)
|
||||
@@ -495,7 +293,6 @@ function useSubscriptionInternal() {
|
||||
// Actions
|
||||
subscribe,
|
||||
fetchStatus,
|
||||
syncStatusAfterCheckout,
|
||||
showSubscriptionDialog,
|
||||
manageSubscription,
|
||||
requireActiveSubscription,
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryDispatcher,
|
||||
@@ -81,12 +80,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackMonthlySubscriptionSucceeded?.(metadata)
|
||||
)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
|
||||
@@ -92,34 +92,9 @@ describe('GtmTelemetryProvider', () => {
|
||||
|
||||
it('pushes subscription_success for subscription activation', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackMonthlySubscriptionSucceeded({
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard',
|
||||
ecommerce: {
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
item_name: 'creator',
|
||||
item_category: 'subscription',
|
||||
item_variant: 'monthly',
|
||||
price: 35,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
provider.trackMonthlySubscriptionSucceeded()
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'subscription_success',
|
||||
transaction_id: 'stripe-event-123',
|
||||
tier: 'creator',
|
||||
checkout_type: 'change'
|
||||
event: 'subscription_success'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryProvider,
|
||||
@@ -168,17 +167,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
this.pushEvent('signup_opened')
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
if (metadata?.ecommerce) {
|
||||
window.dataLayer?.push({ ecommerce: null })
|
||||
}
|
||||
|
||||
this.pushEvent(
|
||||
'subscription_success',
|
||||
metadata ? { ...metadata } : undefined
|
||||
)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.pushEvent('subscription_success')
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
|
||||
@@ -31,7 +31,6 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
@@ -236,10 +235,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
@@ -256,10 +255,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
|
||||
@@ -344,29 +344,6 @@ export interface BeginCheckoutMetadata
|
||||
previous_tier?: TierKey
|
||||
}
|
||||
|
||||
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
user_id?: string
|
||||
transaction_id: string
|
||||
value: number
|
||||
currency: string
|
||||
tier: Exclude<TierKey, 'free'>
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: Exclude<TierKey, 'free'>
|
||||
ecommerce: {
|
||||
transaction_id: string
|
||||
value: number
|
||||
currency: string
|
||||
items: Array<{
|
||||
item_name: string
|
||||
item_category: 'subscription'
|
||||
item_variant: BillingCycle
|
||||
price: number
|
||||
quantity: 1
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
@@ -383,9 +360,7 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionMetadata
|
||||
): void
|
||||
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
|
||||
trackMonthlySubscriptionSucceeded?(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionSucceeded?(): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
@@ -584,4 +559,3 @@ export type TelemetryEventProperties =
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -176,13 +176,7 @@ describe('LGraphNode', () => {
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'node'
|
||||
)
|
||||
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
|
||||
const id = toValue(idArg)
|
||||
expect(id).toEqual('test-node-123')
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
cn(
|
||||
'group/node lg-node absolute isolate text-sm',
|
||||
'flex flex-col contain-layout contain-style',
|
||||
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
|
||||
isRerouteNode
|
||||
? 'h-(--node-height)'
|
||||
: 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
@@ -55,8 +57,7 @@
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing',
|
||||
footerStateOutlineBottomClass
|
||||
: 'border-node-stroke-executing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
@@ -66,8 +67,7 @@
|
||||
cn(
|
||||
'pointer-events-none absolute border border-solid border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
hasAnyError ? '-inset-1' : 'inset-0',
|
||||
footerRootBorderBottomClass
|
||||
hasAnyError ? '-inset-1' : 'inset-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
@@ -77,16 +77,12 @@
|
||||
cn(
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
!isRerouteNode && 'min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
hasAnyError && 'ring-4 ring-destructive-background',
|
||||
{
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
|
||||
bypassed,
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
|
||||
muted,
|
||||
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
|
||||
}
|
||||
bypassed && bypassOverlayClass,
|
||||
muted && mutedOverlayClass,
|
||||
isDraggingOver && 'bg-primary-500/10 ring-4 ring-primary-500'
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
@@ -196,7 +192,6 @@
|
||||
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
|
||||
:has-any-error="hasAnyError"
|
||||
:show-errors-tab-enabled="showErrorsTabEnabled"
|
||||
:is-collapsed="isCollapsed"
|
||||
:show-advanced-inputs-button="showAdvancedInputsButton"
|
||||
:show-advanced-state="showAdvancedState"
|
||||
:header-color="applyLightThemeColor(nodeData?.color)"
|
||||
@@ -222,8 +217,6 @@
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
handle.positionClasses,
|
||||
(handle.corner === 'SE' || handle.corner === 'SW') &&
|
||||
footerResizeHandleBottomClass,
|
||||
handle.cursorClass,
|
||||
'group-hover/node:opacity-100'
|
||||
)
|
||||
@@ -271,6 +264,7 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { st } from '@/i18n'
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
@@ -316,7 +310,6 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
|
||||
@@ -346,7 +339,7 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
useVueElementTracking(String(nodeData.id), 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
@@ -566,30 +559,6 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
}
|
||||
)
|
||||
|
||||
const hasFooter = computed(() => {
|
||||
return !!(
|
||||
(hasAnyError.value && showErrorsTabEnabled.value) ||
|
||||
lgraphNode.value?.isSubgraphNode() ||
|
||||
(!lgraphNode.value?.isSubgraphNode() &&
|
||||
(showAdvancedState.value || showAdvancedInputsButton.value))
|
||||
)
|
||||
})
|
||||
|
||||
// Footer offset computed classes
|
||||
|
||||
const footerStateOutlineBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-[35px]' : ''
|
||||
)
|
||||
|
||||
const footerRootBorderBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-8' : ''
|
||||
)
|
||||
|
||||
const footerResizeHandleBottomClass = computed(() => {
|
||||
if (!hasFooter.value) return ''
|
||||
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
if (nodeData.flags?.pinned) return 'cursor-default'
|
||||
return layoutStore.isDraggingVueNodes.value
|
||||
@@ -658,14 +627,28 @@ const selectionShapeClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const beforeShapeClass = computed(() => {
|
||||
const BEFORE_OVERLAY_BASE =
|
||||
'before:pointer-events-none before:absolute before:inset-0'
|
||||
|
||||
const bypassOverlayClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
return `${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
case RenderShape.CARD:
|
||||
return 'before:rounded-tl-2xl before:rounded-br-2xl'
|
||||
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
default:
|
||||
return 'before:rounded-2xl'
|
||||
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
}
|
||||
})
|
||||
|
||||
const mutedOverlayClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return BEFORE_OVERLAY_BASE
|
||||
case RenderShape.CARD:
|
||||
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE}`
|
||||
default:
|
||||
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE}`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
|
||||
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
|
||||
<div
|
||||
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -23,37 +26,38 @@
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
enterRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enter') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
|
||||
<template
|
||||
<div
|
||||
v-else-if="
|
||||
!isSubgraph &&
|
||||
hasAnyError &&
|
||||
showErrorsTabEnabled &&
|
||||
(showAdvancedInputsButton || showAdvancedState)
|
||||
"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -68,15 +72,15 @@
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
enterRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{
|
||||
showAdvancedState
|
||||
? t('rightSidePanel.hideAdvancedShort')
|
||||
@@ -91,17 +95,20 @@
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 2: Error Only (Full Width) -->
|
||||
<template v-else-if="hasAnyError && showErrorsTabEnabled">
|
||||
<div
|
||||
v-else-if="hasAnyError && showErrorsTabEnabled"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
enterTabFullWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -111,18 +118,27 @@
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 3: Subgraph only (Full Width) -->
|
||||
<template v-else-if="isSubgraph">
|
||||
<div
|
||||
v-else-if="isSubgraph"
|
||||
:class="
|
||||
cn(
|
||||
footerWrapperBase,
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@@ -133,37 +149,47 @@
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 4: Advanced Footer (Regular Nodes) -->
|
||||
<Button
|
||||
<div
|
||||
v-else-if="showAdvancedInputsButton || showAdvancedState"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
footerWrapperBase,
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -179,67 +205,67 @@ interface Props {
|
||||
isSubgraph: boolean
|
||||
hasAnyError: boolean
|
||||
showErrorsTabEnabled: boolean
|
||||
isCollapsed: boolean
|
||||
showAdvancedInputsButton?: boolean
|
||||
showAdvancedState?: boolean
|
||||
headerColor?: string
|
||||
shape?: RenderShape
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const {
|
||||
isSubgraph,
|
||||
hasAnyError,
|
||||
showErrorsTabEnabled,
|
||||
showAdvancedInputsButton,
|
||||
showAdvancedState,
|
||||
headerColor,
|
||||
shape
|
||||
} = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'enterSubgraph'): void
|
||||
(e: 'openErrors'): void
|
||||
(e: 'toggleAdvanced'): void
|
||||
enterSubgraph: []
|
||||
openErrors: []
|
||||
toggleAdvanced: []
|
||||
}>()
|
||||
|
||||
const footerRadiusClass = computed(() => {
|
||||
const isExpanded = props.hasAnyError
|
||||
// Static lookup to keep class names scannable by Tailwind
|
||||
const RADIUS_CLASS = {
|
||||
'rounded-b-17': 'rounded-b-[17px]',
|
||||
'rounded-b-20': 'rounded-b-[20px]',
|
||||
'rounded-br-17': 'rounded-br-[17px]',
|
||||
'rounded-br-20': 'rounded-br-[20px]'
|
||||
} as const
|
||||
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
|
||||
default:
|
||||
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns shared size/position classes for footer tabs
|
||||
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
|
||||
*/
|
||||
const getTabStyles = (isBackground = false) => {
|
||||
let sizeClasses = ''
|
||||
if (props.isCollapsed) {
|
||||
let pt = 'pt-10'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
|
||||
}
|
||||
sizeClasses = cn('-mt-7.5 h-15', pt)
|
||||
} else {
|
||||
let pt = 'pt-12.5'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
|
||||
}
|
||||
sizeClasses = cn('-mt-10 h-17.5', pt)
|
||||
}
|
||||
|
||||
return cn(
|
||||
'pointer-events-auto absolute top-full left-0 text-xs',
|
||||
footerRadiusClass.value,
|
||||
sizeClasses,
|
||||
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
|
||||
)
|
||||
function getBottomRadius(
|
||||
nodeShape: RenderShape | undefined,
|
||||
size: '17px' | '20px',
|
||||
corners: 'both' | 'right' = 'both'
|
||||
): string {
|
||||
if (nodeShape === RenderShape.BOX) return ''
|
||||
const prefix =
|
||||
nodeShape === RenderShape.CARD || corners === 'right'
|
||||
? 'rounded-br'
|
||||
: 'rounded-b'
|
||||
const key =
|
||||
`${prefix}-${size === '17px' ? '17' : '20'}` as keyof typeof RADIUS_CLASS
|
||||
return RADIUS_CLASS[key]
|
||||
}
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
props.headerColor ? { backgroundColor: props.headerColor } : undefined
|
||||
const footerRadiusClass = computed(() =>
|
||||
getBottomRadius(shape, hasAnyError ? '20px' : '17px')
|
||||
)
|
||||
|
||||
// Case 1 context: Split widths
|
||||
const errorTabWidth = 'w-[calc(50%+4px)]'
|
||||
const enterTabFullWidth = 'w-[calc(100%+8px)]'
|
||||
const errorRadiusClass = computed(() => getBottomRadius(shape, '20px'))
|
||||
|
||||
const enterRadiusClass = computed(() => getBottomRadius(shape, '20px', 'right'))
|
||||
|
||||
const tabStyles = 'pointer-events-auto h-9 text-xs'
|
||||
const footerWrapperBase = 'isolate -z-1 -mt-5 box-border flex'
|
||||
const errorWrapperStyles = cn(
|
||||
footerWrapperBase,
|
||||
'-mx-1 -mb-2 w-[calc(100%+8px)] pb-1'
|
||||
)
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
headerColor ? { backgroundColor: headerColor } : undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,8 @@ const testState = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
|
||||
createSharedComposable: <T>(fn: T) => fn
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -99,6 +100,8 @@ function createResizeEntry(options?: {
|
||||
if (collapsed) {
|
||||
element.dataset.collapsed = ''
|
||||
}
|
||||
Object.defineProperty(element, 'offsetWidth', { value: width })
|
||||
Object.defineProperty(element, 'offsetHeight', { value: height })
|
||||
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
|
||||
element.getBoundingClientRect = rectSpy
|
||||
const boxSizes = [{ inlineSize: width, blockSize: height }]
|
||||
@@ -264,18 +267,56 @@ describe('useVueNodeResizeTracking', () => {
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
|
||||
it('writes collapsed dimensions through the normal bounds path', () => {
|
||||
const nodeId = 'test-node'
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
const collapsedWidth = 200
|
||||
const collapsedHeight = 40
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight,
|
||||
left: 100,
|
||||
top: 200,
|
||||
collapsed: true
|
||||
})
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
// Seed with larger expanded size so the collapsed write is a real change
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: 100,
|
||||
y: 200 + titleHeight,
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('updates bounds with expanded dimensions on collapse-to-expand transition', () => {
|
||||
const nodeId = 'test-node'
|
||||
|
||||
// Seed with smaller (collapsed) size so expand triggers a real bounds update
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 200, height: 10 })
|
||||
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: 240,
|
||||
height: 180,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import { getCurrentInstance, onMounted, onUnmounted, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
@@ -139,25 +138,12 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
const nodeId: NodeId | undefined =
|
||||
elementType === 'node' ? elementId : undefined
|
||||
|
||||
// Skip collapsed nodes — their DOM height is just the header, and writing
|
||||
// that back to the layout store would overwrite the stored expanded size.
|
||||
if (elementType === 'node' && element.dataset.collapsed != null) {
|
||||
if (nodeId) {
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Measure the full root element (including footer in flow).
|
||||
// min-height is applied to the root, so footer height in node.size
|
||||
// does not accumulate on Vue/legacy mode switching.
|
||||
const width = Math.max(0, element.offsetWidth)
|
||||
const height = Math.max(0, element.offsetHeight)
|
||||
|
||||
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
|
||||
// Border box is the border included FULL wxh DOM value.
|
||||
const borderBox = Array.isArray(entry.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
const width = Math.max(0, borderBox.inlineSize)
|
||||
const height = Math.max(0, borderBox.blockSize)
|
||||
const nodeLayout = nodeId
|
||||
? layoutStore.getNodeLayoutRef(nodeId).value
|
||||
: null
|
||||
@@ -281,10 +267,9 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifierMaybe: MaybeRefOrGetter<string>,
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
const appIdentifier = toValue(appIdentifierMaybe)
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
@@ -309,6 +294,7 @@ export function useVueElementTracking(
|
||||
delete element.dataset[config.dataAttribute]
|
||||
cachedNodeMeasurements.delete(element)
|
||||
elementsNeedingFreshMeasurement.delete(element)
|
||||
deferredElements.delete(element)
|
||||
resizeObserver.unobserve(element)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user