mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +00:00
Compare commits
37 Commits
v1.44.4
...
fix/promot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5abb36cdc | ||
|
|
6966a6053c | ||
|
|
8eef299941 | ||
|
|
6adca6bfbe | ||
|
|
91a754f600 | ||
|
|
10f3350956 | ||
|
|
5c6be4ed66 | ||
|
|
6847c7ba2d | ||
|
|
988a546721 | ||
|
|
25ac047b58 | ||
|
|
165984fe4c | ||
|
|
34a02a29c9 | ||
|
|
4bd655f625 | ||
|
|
a09bb81b98 | ||
|
|
aeedb60628 | ||
|
|
43fb5a8b19 | ||
|
|
c484c3984f | ||
|
|
2524846f5c | ||
|
|
edb2e530af | ||
|
|
1c749b3907 | ||
|
|
9ef65eda7a | ||
|
|
4a8fd059fe | ||
|
|
e6e1b5ff05 | ||
|
|
0ddec541c5 | ||
|
|
c439282915 | ||
|
|
278a1eddb6 | ||
|
|
2330807b06 | ||
|
|
894768a4b6 | ||
|
|
210384bbf7 | ||
|
|
288f52d033 | ||
|
|
8402c2ae96 | ||
|
|
7f0ab180b9 | ||
|
|
735d639d64 | ||
|
|
71a4098aa9 | ||
|
|
c601aab2c3 | ||
|
|
fd9e732b7f | ||
|
|
8a923a2094 |
@@ -1,9 +1,86 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
|
||||
"Bash(pnpx vitest run \"draftCacheV2.property\")",
|
||||
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
24
.github/workflows/release-biweekly-comfyui.yaml
vendored
24
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -1,14 +1,23 @@
|
||||
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases
|
||||
name: 'Release: Bi-weekly ComfyUI'
|
||||
# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR.
|
||||
# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases.
|
||||
name: 'Release: ComfyUI'
|
||||
|
||||
on:
|
||||
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
|
||||
# Bi-weekly schedule: Monday at 20:00 UTC
|
||||
schedule:
|
||||
- cron: '0 20 * * 1'
|
||||
|
||||
# Allow manual triggering (bypasses bi-weekly check)
|
||||
# Manual trigger for both on-demand minor and patch/hotfix releases
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_type:
|
||||
description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version'
|
||||
required: true
|
||||
default: 'minor'
|
||||
type: choice
|
||||
options:
|
||||
- minor
|
||||
- patch
|
||||
comfyui_fork:
|
||||
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
|
||||
required: false
|
||||
@@ -41,10 +50,11 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
resolve-version:
|
||||
needs: check-release-week
|
||||
@@ -76,6 +86,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -89,6 +101,8 @@ jobs:
|
||||
- name: Resolve release information
|
||||
id: resolve
|
||||
working-directory: frontend
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
154
browser_tests/assets/linear-basic-app-1.json
Normal file
154
browser_tests/assets/linear-basic-app-1.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413, 389],
|
||||
"size": [425.27801513671875, 180.6060791015625],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [6],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [415, 186],
|
||||
"size": [422.84503173828125, 164.31304931640625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [4],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473, 609],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [863, 186],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209, 188],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 189],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [26, 474],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[2, 5, 0, 3, 3, "LATENT"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||
[7, 3, 0, 8, 0, "LATENT"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [
|
||||
["3", "seed"],
|
||||
["3", "steps"],
|
||||
["3", "cfg"]
|
||||
],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
153
browser_tests/assets/linear-basic-app-2.json
Normal file
153
browser_tests/assets/linear-basic-app-2.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413, 389],
|
||||
"size": [425.27801513671875, 180.6060791015625],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [6],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [415, 186],
|
||||
"size": [422.84503173828125, 164.31304931640625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [4],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473, 609],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [863, 186],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209, 188],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 189],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [26, 474],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[2, 5, 0, 3, 3, "LATENT"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||
[7, 3, 0, 8, 0, "LATENT"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [
|
||||
["3", "seed"],
|
||||
["3", "steps"]
|
||||
],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -272,7 +272,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["5", "string_a"],
|
||||
["9", "string_a"],
|
||||
["11", "value"],
|
||||
["9", "value"],
|
||||
["10", "string_a"]
|
||||
|
||||
@@ -94,6 +94,50 @@ export class VueNodeHelpers {
|
||||
await this.page.mouse.click(50, 50)
|
||||
}
|
||||
|
||||
private async fitNodeInViewport(nodeId: string): Promise<void> {
|
||||
await this.page.evaluate((id) => {
|
||||
const app = window.app
|
||||
const canvas = app?.canvas
|
||||
const node = canvas?.graph?.getNodeById(id)
|
||||
if (!canvas || !node) {
|
||||
throw new Error(`Node ${id} not found`)
|
||||
}
|
||||
|
||||
const [x, y, width, height] = node.getBounding()
|
||||
canvas.ds.fitToBounds([x - 20, y - 20, width + 40, height + 80], {
|
||||
zoom: 1
|
||||
})
|
||||
canvas.setDirty(true, true)
|
||||
}, nodeId)
|
||||
|
||||
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
|
||||
}
|
||||
|
||||
private getVisibleClickPosition(
|
||||
box: NonNullable<Awaited<ReturnType<Locator['boundingBox']>>>
|
||||
) {
|
||||
const viewport = this.page.viewportSize()
|
||||
if (!viewport) {
|
||||
return { x: box.width / 2, y: box.height * 0.75 }
|
||||
}
|
||||
|
||||
const visibleLeft = Math.max(box.x, 0)
|
||||
const visibleRight = Math.min(box.x + box.width, viewport.width)
|
||||
const visibleTop = Math.max(box.y, 0)
|
||||
const visibleBottom = Math.min(box.y + box.height, viewport.height)
|
||||
|
||||
if (visibleLeft >= visibleRight || visibleTop >= visibleBottom) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no visible viewport intersection'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
x: visibleLeft - box.x + (visibleRight - visibleLeft) / 2,
|
||||
y: Math.max(1, Math.min(box.height - 2, visibleBottom - box.y - 2))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Delete key
|
||||
*/
|
||||
@@ -187,24 +231,43 @@ export class VueNodeHelpers {
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not
|
||||
* provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const hostNode = nodeId
|
||||
? this.getNodeLocator(nodeId)
|
||||
: this.nodes
|
||||
.filter({
|
||||
has: this.page.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
})
|
||||
.first()
|
||||
const resolvedNodeId =
|
||||
nodeId ?? (await hostNode.getAttribute('data-node-id'))
|
||||
const editButton = hostNode.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
// Click at the bottom 25% of the button which is the genuinely visible
|
||||
// and unobstructed area outside the node body boundary.
|
||||
const box = await editButton.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
|
||||
)
|
||||
const clickEnterButton = async () => {
|
||||
// The footer tab sits below the node body and can be partially clipped
|
||||
// by the viewport. Click inside the visible slice of the button instead
|
||||
// of using the raw bounding-box center, which can land on the page root.
|
||||
const box = await editButton.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
|
||||
)
|
||||
}
|
||||
|
||||
await editButton.click({
|
||||
position: this.getVisibleClickPosition(box)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await clickEnterButton()
|
||||
} catch (error) {
|
||||
if (!resolvedNodeId) throw error
|
||||
await this.fitNodeInViewport(resolvedNodeId)
|
||||
await clickEnterButton()
|
||||
}
|
||||
await editButton.click({
|
||||
position: { x: box.width / 2, y: box.height * 0.75 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
22
browser_tests/fixtures/components/ApiSignin.ts
Normal file
22
browser_tests/fixtures/components/ApiSignin.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class ApiSignin extends BaseDialog {
|
||||
readonly cancel: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.dialogs.apiSignin)
|
||||
this.cancel = this.root.getByRole('button', { name: 'Cancel' })
|
||||
}
|
||||
async open(nodes: string[] = []) {
|
||||
const result = this.page.evaluate(
|
||||
(nodes) =>
|
||||
window.app!.extensionManager.dialog.showApiNodesSignInDialog(nodes),
|
||||
nodes
|
||||
)
|
||||
await this.waitForVisible()
|
||||
return { result }
|
||||
}
|
||||
}
|
||||
21
browser_tests/fixtures/components/CloudNotification.ts
Normal file
21
browser_tests/fixtures/components/CloudNotification.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class CloudNotification extends BaseDialog {
|
||||
readonly toCloud: Locator
|
||||
readonly back: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.dialogs.cloudNotification)
|
||||
this.toCloud = this.root.getByRole('button', { name: 'Try Cloud for Free' })
|
||||
this.back = this.root.getByRole('button', { name: 'Continue Locally' })
|
||||
}
|
||||
async open() {
|
||||
await this.page.evaluate(() => {
|
||||
void window.app!.extensionManager.dialog.showCloudNotification()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type KeysOfType<T, Match> = {
|
||||
export class ConfirmDialog {
|
||||
public readonly root: Locator
|
||||
public readonly delete: Locator
|
||||
public readonly noWarnOverwriteToggle: Locator
|
||||
public readonly overwrite: Locator
|
||||
public readonly reject: Locator
|
||||
public readonly confirm: Locator
|
||||
@@ -21,6 +22,7 @@ export class ConfirmDialog {
|
||||
this.reject = this.root.getByRole('button', { name: 'Cancel' })
|
||||
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
|
||||
this.save = this.root.getByRole('button', { name: 'Save' })
|
||||
this.noWarnOverwriteToggle = this.root.locator('#doNotAskAgain')
|
||||
}
|
||||
|
||||
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
|
||||
|
||||
@@ -57,4 +57,12 @@ export class SignInDialog extends BaseDialog {
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
async openWithResult(): Promise<{ result: Promise<boolean> }> {
|
||||
const result = this.page.evaluate(() =>
|
||||
window.app!.extensionManager.dialog.showSignInDialog()
|
||||
)
|
||||
await this.waitForVisible()
|
||||
return { result }
|
||||
}
|
||||
}
|
||||
|
||||
23
browser_tests/fixtures/components/UpdatePassword.ts
Normal file
23
browser_tests/fixtures/components/UpdatePassword.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class UpdatePassword extends BaseDialog {
|
||||
readonly confirm: Locator
|
||||
readonly password: Locator
|
||||
readonly confirmPassword: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.dialogs.updatePassword)
|
||||
this.confirm = this.root.getByRole('button', { name: 'Update Password' })
|
||||
this.password = this.root.getByLabel('Password', { exact: true })
|
||||
this.confirmPassword = this.root.getByLabel('Confirm Password')
|
||||
}
|
||||
async open() {
|
||||
await this.page.evaluate(() => {
|
||||
void window.app!.extensionManager.dialog.showUpdatePasswordDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CanvasPointerEvent,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -514,6 +515,23 @@ export class SubgraphHelper {
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async publishSubgraph(name: string = 'test blueprint') {
|
||||
await this.comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name
|
||||
})
|
||||
}
|
||||
|
||||
//Blueprints will show an overwrite confirmation dialogue if they have not
|
||||
//already been saved during the active session.
|
||||
//Forcibly reset this flag without an expensive reload operation.
|
||||
async setSaveUnpromptedOnActiveBlueprint() {
|
||||
await this.page.evaluate(() => {
|
||||
const { activeWorkflow } = window.app!.extensionManager.workflow
|
||||
;(
|
||||
activeWorkflow as ComfyWorkflow & { hasPromptedSave: boolean }
|
||||
).hasPromptedSave = false
|
||||
})
|
||||
}
|
||||
|
||||
static getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
@@ -73,6 +75,9 @@ export class WorkflowHelper {
|
||||
assetPath(`${workflowName}.json`)
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
if (test.info().tags.includes('@vue-nodes')) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWorkflow(
|
||||
@@ -175,6 +180,11 @@ export class WorkflowHelper {
|
||||
)
|
||||
}
|
||||
|
||||
async switchToTab(tabName: string): Promise<void> {
|
||||
await this.comfyPage.menu.topbar.getWorkflowTab(tabName).click()
|
||||
await this.waitForWorkflowIdle()
|
||||
}
|
||||
|
||||
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
|
||||
async getExportedWorkflow(options?: {
|
||||
api?: false
|
||||
|
||||
@@ -68,7 +68,10 @@ export const TestIds = {
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel'
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
@@ -122,21 +122,3 @@ export async function saveAndReopenInAppMode(
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder, select the given widgets as inputs + SaveImage as output,
|
||||
* save as an app, and close the success dialog.
|
||||
*
|
||||
* Returns on the builder arrange/preview step.
|
||||
*/
|
||||
export async function createAndSaveApp(
|
||||
comfyPage: ComfyPage,
|
||||
appName: string,
|
||||
widgetNames: string[] = ['seed']
|
||||
): Promise<void> {
|
||||
await setupBuilder(comfyPage, undefined, widgetNames)
|
||||
await comfyPage.appMode.steps.goToPreview()
|
||||
await builderSaveAs(comfyPage.appMode, appName)
|
||||
await comfyPage.appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
test.describe('Beta Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Can display workflow name', async ({ comfyPage }) => {
|
||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
|
||||
@@ -2,11 +2,10 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
builderSaveAs,
|
||||
createAndSaveApp,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
@@ -160,46 +159,38 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const suffix = String(Date.now())
|
||||
const app1Name = `app1-${suffix}`
|
||||
const app2Name = `app2-${suffix}`
|
||||
const app2Widgets = ['seed', 'steps']
|
||||
|
||||
// Create and save app1 with [seed, steps, cfg]
|
||||
await createAndSaveApp(comfyPage, app1Name, WIDGETS)
|
||||
await appMode.footer.exitBuilder()
|
||||
|
||||
// Create app2 in a new tab so both apps are open simultaneously
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await createAndSaveApp(comfyPage, app2Name, app2Widgets)
|
||||
await appMode.footer.exitBuilder()
|
||||
|
||||
// Switch to app1 tab and enter builder
|
||||
await comfyPage.menu.topbar.getWorkflowTab(app1Name).click()
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
// Reorder app1 inputs: drag 'seed' from first to last
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
const app1Reordered = ['steps', 'cfg', 'seed']
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
|
||||
|
||||
// Switch to app2 tab and enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.getWorkflowTab(app2Name).click()
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await test.step('Load both apps', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('linear-basic-app-1')
|
||||
await comfyPage.workflow.loadWorkflow('linear-basic-app-2')
|
||||
})
|
||||
|
||||
// Verify app2 inputs are not corrupted — still [seed, steps]
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(app2Widgets)
|
||||
await test.step('Reorder app1 inputs', async () => {
|
||||
await comfyPage.workflow.switchToTab('linear-basic-app-1')
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
// Switch back to app1 and verify reorder persisted
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.getWorkflowTab(app1Name).click()
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
|
||||
})
|
||||
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
|
||||
await test.step('Verify app2 inputs are not corrupted', async () => {
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.workflow.switchToTab('linear-basic-app-2')
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(app2Widgets)
|
||||
})
|
||||
|
||||
await test.step('Verify app1 reorder persisted', async () => {
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.workflow.switchToTab('linear-basic-app-1')
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ test.describe(
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
|
||||
import { ApiSignin } from '@e2e/fixtures/components/ApiSignin'
|
||||
import { CloudNotification } from '@e2e/fixtures/components/CloudNotification'
|
||||
import { UpdatePassword } from '@e2e/fixtures/components/UpdatePassword'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -151,4 +155,161 @@ test.describe('Signin dialog', () => {
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||
})
|
||||
|
||||
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
const { result: dialogResult } = await dialog.openWithResult()
|
||||
|
||||
await dialog.emailInput.fill('test@example.com')
|
||||
await dialog.passwordInput.fill('TestPassword123!')
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await dialog.signInButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
expect(await dialogResult).toBe(true)
|
||||
})
|
||||
|
||||
test('Sign-in dialog resolves false when closed without sign-in', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
const { result: dialogResult } = await dialog.openWithResult()
|
||||
|
||||
await dialog.close()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
expect(await dialogResult).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test('API Nodes sign-in dialog', async ({ comfyPage }) => {
|
||||
const dialog = new ApiSignin(comfyPage.page)
|
||||
const { result: dialogResult } = await dialog.open([
|
||||
'FluxProGenerate',
|
||||
'StableDiffusion3Generate'
|
||||
])
|
||||
|
||||
await expect(dialog.root.getByText('FluxProGenerate')).toBeVisible()
|
||||
await expect(dialog.root.getByText('StableDiffusion3Generate')).toBeVisible()
|
||||
|
||||
await dialog.cancel.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
expect(await dialogResult).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Update password dialog', () => {
|
||||
test('Should only allow submission when inputs are valid', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = new UpdatePassword(comfyPage.page)
|
||||
await dialog.open()
|
||||
|
||||
await dialog.confirm.click()
|
||||
await expect(dialog.root, 'Check that password exists').toBeVisible()
|
||||
|
||||
const testPassword = 'Unguessable Password #2'
|
||||
await dialog.password.fill(testPassword)
|
||||
|
||||
await dialog.confirm.click()
|
||||
await expect(dialog.root, 'Check that inputs match').toBeVisible()
|
||||
|
||||
await dialog.confirmPassword.fill(testPassword)
|
||||
await dialog.confirm.click()
|
||||
await expect(dialog.root, 'Dialog closes after submission').toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud notification dialog', () => {
|
||||
test('Should display cloud notification and navigate to comfy.org on Explore', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = new CloudNotification(comfyPage.page)
|
||||
await dialog.open()
|
||||
|
||||
await expect(
|
||||
dialog.root.getByText('Run ComfyUI in the Cloud')
|
||||
).toBeVisible()
|
||||
|
||||
const popupPromise = comfyPage.page.waitForEvent('popup')
|
||||
await dialog.toCloud.click()
|
||||
const popup = await popupPromise
|
||||
|
||||
expect(new URL(popup.url()).hostname).toContain('comfy.org')
|
||||
await popup.close()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should close when Continue Locally is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = new CloudNotification(comfyPage.page)
|
||||
await dialog.open()
|
||||
|
||||
await dialog.back.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
|
||||
const blueprintName = `test-blueprint-overwrite-${Date.now()}`
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite',
|
||||
true
|
||||
)
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await test.step('Publish a basic subgraph', async () => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.contextMenu.openForVueNode(ksampler.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
await comfyPage.subgraph.publishSubgraph(blueprintName)
|
||||
|
||||
await tab.open()
|
||||
await tab.getFolder('My Blueprints').click()
|
||||
await tab.getFolder('User').click()
|
||||
})
|
||||
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('KSampler', 'steps')
|
||||
|
||||
await test.step('Edit the published subgraph', async () => {
|
||||
const blueprintNode = tab.getNode(blueprintName)
|
||||
await expect(blueprintNode, 'blueprint visible in library').toBeVisible()
|
||||
await blueprintNode.getByRole('button', { name: 'Edit' }).click()
|
||||
await steps.waitFor({ state: 'visible' })
|
||||
})
|
||||
|
||||
const confirmDialog = comfyPage.confirmDialog.root
|
||||
const { incrementButton } = comfyPage.vueNodes.getInputNumberControls(steps)
|
||||
const dirtyGraphAndSave = async () => {
|
||||
await incrementButton.click()
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
}
|
||||
|
||||
await test.step('No dialog: user prompted on publish', async () => {
|
||||
await dirtyGraphAndSave()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
})
|
||||
|
||||
await test.step('Should show dialog', async () => {
|
||||
await comfyPage.subgraph.setSaveUnpromptedOnActiveBlueprint()
|
||||
await dirtyGraphAndSave()
|
||||
const { noWarnOverwriteToggle } = comfyPage.confirmDialog
|
||||
await expect(noWarnOverwriteToggle).toBeVisible()
|
||||
|
||||
await test.step('Disable overwrite warning', async () => {
|
||||
await noWarnOverwriteToggle.check()
|
||||
await expect(confirmDialog.getByText(/Re-enable in/i)).toBeVisible()
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
})
|
||||
})
|
||||
|
||||
await test.step('No dialog: disabled by setting', async () => {
|
||||
await comfyPage.subgraph.setSaveUnpromptedOnActiveBlueprint()
|
||||
await dirtyGraphAndSave()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
|
||||
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.queuePanel.overlayToggle.click()
|
||||
})
|
||||
|
||||
|
||||
@@ -137,4 +137,59 @@ test.describe('Error dialog', () => {
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('Should display extension file hint when available', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const error = new Error('Extension error!')
|
||||
;(error as Error & { fileName: string }).fileName =
|
||||
'/extensions/my-custom-extension/main.js'
|
||||
|
||||
window.app!.extensionManager.dialog.showErrorDialog(error)
|
||||
})
|
||||
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
errorDialog.getByText('/extensions/my-custom-extension/main.js')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
errorDialog.getByText('This may be due to the following script')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display string error messages', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.dialog.showErrorDialog(
|
||||
'Something went wrong',
|
||||
{
|
||||
title: 'Custom Error Title'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
await expect(errorDialog.getByText('Custom Error Title')).toBeVisible()
|
||||
await expect(errorDialog.getByText('Something went wrong')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display default title when no title provided', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.dialog.showErrorDialog(
|
||||
'A simple string error'
|
||||
)
|
||||
})
|
||||
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
await expect(errorDialog.getByText('Unknown Error')).toBeVisible()
|
||||
await expect(errorDialog.getByText('A simple string error')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Focus mode hides UI chrome', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function createTestImageDataUrl(label: string, color: string): string {
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
|
||||
test.describe('Job History Actions', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
|
||||
// Expand the queue overlay so the JobHistoryActionsMenu is visible
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
})
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
@@ -87,7 +87,6 @@ async function setLocaleAndWaitForWorkflowReload(
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
|
||||
@@ -4,10 +4,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,10 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('MediaLightbox', { tag: ['@slow', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function runAndOpenGallery(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
// Wait for SaveImage node to produce output
|
||||
|
||||
@@ -12,7 +12,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
|
||||
@@ -191,4 +191,59 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for #10612: promoted widget indicator ring missing on
|
||||
* nested subgraph nodes.
|
||||
*
|
||||
* Uses the 3-level nested fixture (subgraph-nested-promotion):
|
||||
* Root → Sub 0 (node 5) → Sub 1 (node 6) → Sub 2 (node 9)
|
||||
*
|
||||
* Node 6 (Sub 1) has proxyWidgets promoting widgets from inner nodes,
|
||||
* and those promotions are also promoted up to node 5 (Sub 0). When
|
||||
* navigating into Sub 0, node 6 should show the promoted ring on its
|
||||
* widgets. The fixture's proxyWidgets entries are scoped to Sub 1's
|
||||
* local graph, so the nested `string_a` promotion correctly points at
|
||||
* inner node 9 instead of the outer SubgraphNode 5.
|
||||
*/
|
||||
test.describe(
|
||||
'Promoted indicator on 3-level nested subgraphs (#10612)',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
test('Intermediate SubgraphNode shows promoted ring inside parent subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// At root level, node 5 (Sub 0) is the outermost SubgraphNode.
|
||||
// Its widgets are not promoted further, so no ring expected.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const outerRings = outerNode.locator(`.${PROMOTED_BORDER_CLASS}`)
|
||||
await comfyExpect(outerRings).toHaveCount(0)
|
||||
|
||||
// Exercise the same enter-subgraph control users click.
|
||||
await comfyPage.vueNodes.enterSubgraph('5')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 6 (Sub 1) has proxyWidgets promoted up to Sub 0
|
||||
// (node 5). Those promoted widgets should carry the ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('6')
|
||||
await comfyExpect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediateRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(intermediateRings).not.toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
@@ -8,6 +9,31 @@ const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -85,5 +111,35 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Toast Notifications', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function triggerErrorToast(comfyPage: {
|
||||
page: { evaluate: (fn: () => void) => Promise<void> }
|
||||
nextFrame: () => Promise<void>
|
||||
|
||||
@@ -128,7 +128,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
test('should allow fitting group to contents', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
|
||||
@@ -106,7 +106,6 @@ test.describe(
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ test.describe(
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
@@ -69,7 +68,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const firstSubgraphNode = comfyPage.vueNodes.getNodeLocator('7')
|
||||
const secondSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
|
||||
@@ -2,24 +2,13 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Vue Nodes - Delete Key Interaction',
|
||||
{ tag: '@vue-nodes' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Can select all and delete Vue nodes with Delete key', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Get initial Vue node count
|
||||
await expect
|
||||
.poll(() => comfyPage.vueNodes.getNodeCount())
|
||||
@@ -44,8 +33,6 @@ test.describe(
|
||||
test('Can select specific Vue node and delete it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Get initial Vue node count
|
||||
await expect
|
||||
.poll(() => comfyPage.vueNodes.getNodeCount())
|
||||
@@ -71,8 +58,6 @@ test.describe(
|
||||
test('Can select and delete Vue node with Backspace key', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Select first Vue node
|
||||
@@ -114,8 +99,6 @@ test.describe(
|
||||
test('Delete key does not delete node when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Ensure no Vue nodes are selected
|
||||
await comfyPage.vueNodes.clearSelection()
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(0)
|
||||
@@ -132,7 +115,6 @@ test.describe(
|
||||
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Multi-select first two Vue nodes using Ctrl+click
|
||||
|
||||
@@ -41,7 +41,6 @@ test.describe(
|
||||
|
||||
test('should load node colors from workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-custom-colors-dark-all-colors.png'
|
||||
)
|
||||
@@ -52,7 +51,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-custom-colors-light-all-colors.png'
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
@@ -23,7 +22,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node causes execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.workflow.loadWorkflow('links/single_connected_reroute_node')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
|
||||
@@ -10,7 +10,6 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.describe('without source image', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -72,7 +71,6 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('2').locator('img')
|
||||
|
||||
@@ -8,7 +8,6 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'seed')
|
||||
|
||||
@@ -6,9 +6,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('choose file to upload', { exact: true })
|
||||
|
||||
@@ -137,35 +137,71 @@ function resolveRelease(
|
||||
// Fetch all branches
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
let targetMinor = currentMinor + 1
|
||||
let targetBranch = `core/1.${targetMinor}`
|
||||
// Determine target branch based on release type:
|
||||
// 'patch' → target current minor (hotfix for production version)
|
||||
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
|
||||
const releaseTypeInput =
|
||||
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
|
||||
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
|
||||
console.error(
|
||||
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const releaseType: 'minor' | 'patch' = releaseTypeInput
|
||||
let targetMinor: number
|
||||
let targetBranch: string
|
||||
|
||||
const nextMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!nextMinorExists) {
|
||||
// Fall back to current minor for patch releases
|
||||
if (releaseType === 'patch') {
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const currentMinorExists = exec(
|
||||
const branchExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!currentMinorExists) {
|
||||
if (!branchExists) {
|
||||
console.error(
|
||||
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
|
||||
`Patch release requested but branch ${targetBranch} does not exist`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for patch release`
|
||||
`Patch release: targeting current production branch ${targetBranch}`
|
||||
)
|
||||
} else {
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
targetMinor = currentMinor + 1
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const nextMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!nextMinorExists) {
|
||||
// Fall back to current minor for minor release
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const currentMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!currentMinorExists) {
|
||||
console.error(
|
||||
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for minor release`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest patch tag for target minor
|
||||
@@ -264,7 +300,7 @@ if (!releaseInfo) {
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
|
||||
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
|
||||
29
src/components/common/ApiNodesList.vue
Normal file
29
src/components/common/ApiNodesList.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex text-xs">
|
||||
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
|
||||
</div>
|
||||
<ScrollPanel class="h-0 grow">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="nodeName in nodeNames"
|
||||
:key="nodeName"
|
||||
class="flex items-center justify-between rounded-md bg-(--p-content-border-color) px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base/tight font-medium">{{ nodeName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { nodeNames } = defineProps<{ nodeNames: string[] }>()
|
||||
</script>
|
||||
@@ -26,21 +26,35 @@
|
||||
</slot>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
<Button
|
||||
v-if="isUserBlueprint"
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="opacity-0 group-hover/tree-node:opacity-100"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
<i class="icon-[lucide--trash-2] bg-destructive-background" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isUserBlueprint"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="opacity-0 group-hover/tree-node:opacity-100"
|
||||
:aria-label="$t('g.edit')"
|
||||
@click.stop="editBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="opacity-0 group-hover/tree-node:opacity-100"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +107,7 @@ import { computed, inject } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -107,9 +122,6 @@ defineOptions({
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
}>()
|
||||
@@ -147,6 +159,13 @@ function deleteBlueprint() {
|
||||
void subgraphStore.deleteBlueprint(nodeDef.value.name)
|
||||
}
|
||||
}
|
||||
const editBlueprint = async () => {
|
||||
if (!nodeDef.value)
|
||||
throw new Error(
|
||||
'Failed to edit subgraph blueprint lacking backing node data'
|
||||
)
|
||||
await useSubgraphStore().editBlueprint(nodeDef.value.name)
|
||||
}
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex h-110 max-w-96 flex-col gap-4 p-2">
|
||||
<div
|
||||
data-testid="api-signin-dialog"
|
||||
class="flex h-110 max-w-96 flex-col gap-4 p-2"
|
||||
>
|
||||
<div class="mb-2 text-2xl font-medium">
|
||||
{{ t('apiNodesSignInDialog.title') }}
|
||||
</div>
|
||||
@@ -29,6 +32,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ApiNodesList from '@/components/common/ApiNodesList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Form
|
||||
data-testid="update-password-dialog"
|
||||
class="flex w-96 flex-col gap-6"
|
||||
:resolver="zodResolver(updatePasswordSchema)"
|
||||
@submit="onSubmit"
|
||||
|
||||
@@ -70,6 +70,43 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('infers the leaf-node disambiguator for nested subgraph entries', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: 'number' }]
|
||||
})
|
||||
|
||||
const samplerNode = new LGraphNode('Sampler')
|
||||
const samplerInput = samplerNode.addInput('seed', 'number')
|
||||
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
|
||||
samplerInput.widget = { name: 'noise_seed' }
|
||||
innerSubgraph.add(samplerNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const nestedNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(nestedNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(nestedNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(nestedNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: String(samplerNode.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a single legacy prefix from widget name', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
|
||||
@@ -6,34 +6,30 @@ const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
|
||||
|
||||
function canResolve(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
).status === 'resolved'
|
||||
)
|
||||
}
|
||||
|
||||
function tryResolveCandidate(
|
||||
function resolveCandidate(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
|
||||
return undefined
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
)
|
||||
if (result.status !== 'resolved') return undefined
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(sourceNodeId)
|
||||
const inferredDisambiguator =
|
||||
disambiguator ??
|
||||
(sourceNode?.isSubgraphNode() ? String(result.resolved.node.id) : undefined)
|
||||
|
||||
return {
|
||||
sourceWidgetName: widgetName,
|
||||
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
|
||||
...(inferredDisambiguator && {
|
||||
disambiguatingSourceNodeId: inferredDisambiguator
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +55,7 @@ function resolveLegacyPrefixedEntry(
|
||||
]
|
||||
|
||||
for (const disambiguator of disambiguators) {
|
||||
const resolved = tryResolveCandidate(
|
||||
const resolved = resolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
remaining,
|
||||
@@ -76,18 +72,19 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetSource {
|
||||
if (
|
||||
canResolve(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
) {
|
||||
const exactMatch = resolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
if (exactMatch) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
sourceWidgetName: exactMatch.sourceWidgetName,
|
||||
...(exactMatch.disambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: exactMatch.disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,14 +95,14 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
const normalizedDisambiguatingSourceNodeId =
|
||||
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
...(normalizedDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: normalizedDisambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
152
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
152
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: {
|
||||
rootGraph: {
|
||||
id: 'graph-test'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const PROMOTED_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
const WidgetStub = defineComponent({
|
||||
props: { widget: { type: Object, default: undefined } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-testid': 'widget-stub',
|
||||
class: props.widget?.borderStyle
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
getComponent: () => WidgetStub,
|
||||
shouldExpand: () => false,
|
||||
shouldRenderAsVue: () => true
|
||||
})
|
||||
)
|
||||
|
||||
function createMockWidget(
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
): SafeWidgetData {
|
||||
return {
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
options: undefined,
|
||||
callback: undefined,
|
||||
spec: undefined,
|
||||
isDOMWidget: false,
|
||||
slotMetadata: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNodeData(
|
||||
nodeType: string,
|
||||
widgets: SafeWidgetData[],
|
||||
id: string
|
||||
): VueNodeData {
|
||||
return {
|
||||
id,
|
||||
type: nodeType,
|
||||
widgets,
|
||||
title: 'Test Node',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(nodeData: VueNodeData, setupStores?: () => void) {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return render(NodeWidgets, {
|
||||
props: { nodeData },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { InputSlot: true },
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('promoted widget indicator on nested subgraphs', () => {
|
||||
test('shows promoted ring when promotion includes disambiguatingSourceNodeId', async () => {
|
||||
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
|
||||
// ConcreteNode (id=1). During hydration, SubgraphNode.configure
|
||||
// resolves the concrete node and stores the promotion WITH
|
||||
// disambiguatingSourceNodeId so that the exact key matches the
|
||||
// renderer's lookup.
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
renderComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
|
||||
const widgets = screen.getAllByTestId('widget-stub')
|
||||
const hasPromotedRing = widgets.some((el) =>
|
||||
el.classList.contains(PROMOTED_CLASS)
|
||||
)
|
||||
expect(hasPromotedRing).toBe(true)
|
||||
})
|
||||
|
||||
test('shows promoted ring via base-key lookup when disambiguator is unknown', async () => {
|
||||
// Legacy callers (e.g. BaseWidget) may not have the disambiguator.
|
||||
// The dual-indexed base key ensures the ring still shows.
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'test_node',
|
||||
isDOMWidget: false
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
renderComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
|
||||
const widgets = screen.getAllByTestId('widget-stub')
|
||||
const hasPromotedRing = widgets.some((el) =>
|
||||
el.classList.contains(PROMOTED_CLASS)
|
||||
)
|
||||
expect(hasPromotedRing).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,8 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
/** Whether the resolved source widget is workflow-persistent. */
|
||||
readonly sourceSerialize: boolean
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
@@ -50,6 +53,43 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
const promotedSourceWriteMetaByGraph = new WeakMap<
|
||||
LGraph,
|
||||
Map<string, PromotedSourceWriteMeta>
|
||||
>()
|
||||
|
||||
interface PromotedSourceWriteMeta {
|
||||
value: IBaseWidget['value']
|
||||
writerInstanceId: string
|
||||
}
|
||||
|
||||
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
|
||||
value: TValue
|
||||
): TValue {
|
||||
return value != null && typeof value === 'object'
|
||||
? (JSON.parse(JSON.stringify(value)) as TValue)
|
||||
: value
|
||||
}
|
||||
|
||||
function getPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string
|
||||
): PromotedSourceWriteMeta | undefined {
|
||||
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
|
||||
}
|
||||
|
||||
function setPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string,
|
||||
meta: PromotedSourceWriteMeta
|
||||
): void {
|
||||
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
|
||||
if (!metaBySource) {
|
||||
metaBySource = new Map<string, PromotedSourceWriteMeta>()
|
||||
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
|
||||
}
|
||||
metaBySource.set(sourceKey, meta)
|
||||
}
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
@@ -77,6 +117,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
/**
|
||||
* Whether the resolved source widget is workflow-persistent.
|
||||
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
|
||||
* whose source sets serialize = false.
|
||||
*/
|
||||
get sourceSerialize(): boolean {
|
||||
return this.resolveDeepest()?.widget.serialize !== false
|
||||
}
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
@@ -149,13 +198,52 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
private get _instanceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
private get _sharedSourceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
return this.getTrackedValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution-time serialization — returns the per-instance value stored
|
||||
* during configure, falling back to the regular value getter.
|
||||
*
|
||||
* The widget state store is shared across instances (keyed by inner node
|
||||
* ID), so the regular getter returns the last-configured value for all
|
||||
* instances. graphToPrompt already prefers serializeValue over .value,
|
||||
* so this is the hook that makes multi-instance execution correct.
|
||||
*/
|
||||
serializeValue(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.captureSiblingFallbackValues()
|
||||
|
||||
// Keep per-instance map in sync for execution (graphToPrompt)
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(value)
|
||||
)
|
||||
setPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey,
|
||||
{
|
||||
value: cloneWidgetValue(value),
|
||||
writerInstanceId: String(this.subgraphNode.id)
|
||||
}
|
||||
)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
@@ -385,6 +473,39 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getTrackedValue(): IBaseWidget['value'] {
|
||||
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
|
||||
this._instanceKey
|
||||
)
|
||||
const sharedValue = this.getSharedValue()
|
||||
|
||||
if (instanceValue === undefined) return sharedValue
|
||||
|
||||
const sourceWriteMeta = getPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey
|
||||
)
|
||||
if (
|
||||
sharedValue !== undefined &&
|
||||
sourceWriteMeta &&
|
||||
!isEqual(sharedValue, sourceWriteMeta.value)
|
||||
) {
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(sharedValue)
|
||||
)
|
||||
return sharedValue
|
||||
}
|
||||
|
||||
return instanceValue as IBaseWidget['value']
|
||||
}
|
||||
|
||||
private getSharedValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
@@ -451,6 +572,30 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private captureSiblingFallbackValues(): void {
|
||||
const { rootGraph } = this.subgraphNode
|
||||
|
||||
for (const node of rootGraph.nodes) {
|
||||
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
|
||||
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
|
||||
if (node._instanceWidgetValues.has(this._instanceKey)) continue
|
||||
|
||||
const siblingView = node.widgets.find(
|
||||
(widget): widget is IPromotedWidgetView =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId === this.sourceNodeId &&
|
||||
widget.sourceWidgetName === this.sourceWidgetName &&
|
||||
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!siblingView) continue
|
||||
|
||||
node._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(siblingView.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
31
src/core/graph/subgraph/promotionLookup.ts
Normal file
31
src/core/graph/subgraph/promotionLookup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import type { Pinia } from 'pinia'
|
||||
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
let cachedPromotionStore: ReturnType<typeof usePromotionStore> | undefined
|
||||
let cachedPinia: Pinia | undefined
|
||||
|
||||
function getPromotionStore() {
|
||||
const activePinia = getActivePinia()
|
||||
if (!cachedPromotionStore || cachedPinia !== activePinia) {
|
||||
cachedPromotionStore = usePromotionStore(activePinia)
|
||||
cachedPinia = activePinia
|
||||
}
|
||||
return cachedPromotionStore
|
||||
}
|
||||
|
||||
export function isWidgetPromoted(
|
||||
graphId: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): boolean {
|
||||
return getPromotionStore().isWidgetPromoted(
|
||||
graphId,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,9 +265,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
|
||||
@@ -186,11 +186,16 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
if (!widget) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
// Prefer serializeValue (per-instance) over the shared .value getter
|
||||
// so multiple SubgraphNode instances return their own configured values.
|
||||
const widgetValue = widget.serializeValue
|
||||
? widget.serializeValue(subgraphNode, -1)
|
||||
: widget.value
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widget.value }
|
||||
widgetInfo: { value: widgetValue }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: number = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 7)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
|
||||
instance1.graph!.add(instance1)
|
||||
instance2.graph!.add(instance2)
|
||||
|
||||
const widget1 = instance1.widgets?.[0]
|
||||
const widget2 = instance2.widgets?.[0]
|
||||
|
||||
expect(widget1?.value).toBe(7)
|
||||
expect(widget2?.value).toBe(7)
|
||||
|
||||
widget1!.value = 10
|
||||
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(7)
|
||||
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
|
||||
})
|
||||
|
||||
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 602,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
|
||||
widget.value = 45
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(45)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(45)
|
||||
})
|
||||
|
||||
it('clears stale per-instance values when reconfigured without widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
widget.value = 17
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
id: instance.id,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(instance.widgets?.[0].value).toBe(17)
|
||||
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -993,7 +993,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
/**
|
||||
* Per-instance promoted widget values.
|
||||
* Multiple SubgraphNode instances share the same inner nodes, so
|
||||
* promoted widget values must be stored per-instance to avoid collisions.
|
||||
* Key: `${sourceNodeId}:${sourceWidgetName}`
|
||||
*/
|
||||
readonly _instanceWidgetValues = new Map<string, unknown>()
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
this._instanceWidgetValues.clear()
|
||||
this._pendingWidgetsValues = info.widgets_values
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
@@ -1124,6 +1138,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
// Hydrate per-instance promoted widget values from serialized data.
|
||||
// LGraphNode.configure skips promoted widgets (serialize === false on
|
||||
// the view), so they must be applied here after promoted views exist.
|
||||
// Only iterate serializable views to match what serialize() wrote.
|
||||
if (this._pendingWidgetsValues) {
|
||||
const views = this._getPromotedViews()
|
||||
let i = 0
|
||||
for (const view of views) {
|
||||
if (!view.sourceSerialize) continue
|
||||
if (i >= this._pendingWidgetsValues.length) break
|
||||
view.value = this._pendingWidgetsValues[i++] as typeof view.value
|
||||
}
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1518,6 +1547,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._instanceWidgetValues.clear()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1573,28 +1603,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
@@ -1602,7 +1611,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
return super.serialize()
|
||||
const serialized = super.serialize()
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
if (serializableViews.length > 0) {
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.serializeValue
|
||||
? view.serializeValue(this, -1)
|
||||
: view.value
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: (value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
return serialized
|
||||
}
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
|
||||
import { t } from '@/i18n'
|
||||
import { drawTextInArea } from '@/lib/litegraph/src/draw'
|
||||
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
|
||||
@@ -17,7 +18,6 @@ import type {
|
||||
NodeBindable,
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -211,10 +211,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
if (
|
||||
graphId &&
|
||||
!suppressPromotedOutline &&
|
||||
usePromotionStore().isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
isWidgetPromoted(graphId, String(this.node.id), this.name)
|
||||
)
|
||||
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
|
||||
return this.advanced
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="relative grid h-full grid-cols-5">
|
||||
<div
|
||||
data-testid="cloud-notification-dialog"
|
||||
class="relative grid h-full grid-cols-5"
|
||||
>
|
||||
<Button
|
||||
size="unset"
|
||||
variant="muted-textonly"
|
||||
|
||||
@@ -247,6 +247,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses slotName when ancestor promotion keeps the host widget alias', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:10',
|
||||
storeNodeId: 'inner-subgraph:10',
|
||||
storeName: 'string_a',
|
||||
slotName: 'value_1'
|
||||
})
|
||||
|
||||
usePromotionStore().promote('graph-test', '5', {
|
||||
sourceNodeId: '6',
|
||||
sourceWidgetName: 'value_1',
|
||||
disambiguatingSourceNodeId: '10'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '6',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [promotedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(
|
||||
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -167,7 +167,6 @@ export function computeProcessedWidgets({
|
||||
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
@@ -253,7 +252,7 @@ export function computeProcessedWidgets({
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const promotionSourceNodeId = widget.storeName
|
||||
const disambiguatingSourceNodeId = widget.storeName
|
||||
? String(bareWidgetId)
|
||||
: undefined
|
||||
|
||||
@@ -270,17 +269,23 @@ export function computeProcessedWidgets({
|
||||
? { ...mergedOptions, disabled: true }
|
||||
: mergedOptions
|
||||
|
||||
const borderStyle =
|
||||
// Nested SubgraphNode promotions are keyed by the subgraph slot name.
|
||||
// storeName still identifies the concrete leaf widget and is only used
|
||||
// as the disambiguator when multiple promoted views share that slot.
|
||||
const sourceWidgetName = widget.slotName ?? widget.name
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
isWidgetPromoted(
|
||||
graphId,
|
||||
hostNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
const borderStyle = isPromoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
||||
slotMetadata?.linked && slotMetadata.originNodeId
|
||||
|
||||
@@ -4,7 +4,6 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type {
|
||||
@@ -51,7 +50,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -211,7 +209,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipFilterOption,
|
||||
@@ -16,7 +15,6 @@ import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
@@ -135,7 +133,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -198,7 +195,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="ownershipPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -261,7 +257,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="baseModelPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
|
||||
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
|
||||
const isWidgetPromotedMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
@@ -12,10 +12,8 @@ vi.mock('@/stores/domWidgetStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/promotionStore', () => ({
|
||||
usePromotionStore: () => ({
|
||||
isPromotedByAny: isPromotedByAnyMock
|
||||
})
|
||||
vi.mock('@/core/graph/subgraph/promotionLookup', () => ({
|
||||
isWidgetPromoted: isWidgetPromotedMock
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
@@ -120,7 +118,7 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
})
|
||||
|
||||
test('draws promoted outline for visible promoted widgets', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(true)
|
||||
isWidgetPromotedMock.mockReturnValue(true)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
@@ -138,16 +136,17 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
|
||||
sourceNodeId: '-1',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(isWidgetPromotedMock).toHaveBeenCalledWith(
|
||||
'root-graph-id',
|
||||
'-1',
|
||||
'seed'
|
||||
)
|
||||
expect(ctx.strokeRect).toHaveBeenCalledOnce()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('does not draw promoted outline when widget is not promoted', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(false)
|
||||
isWidgetPromotedMock.mockReturnValue(false)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
@@ -187,7 +186,7 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
|
||||
expect(isWidgetPromotedMock).not.toHaveBeenCalled()
|
||||
expect(ctx.strokeRect).not.toHaveBeenCalled()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
|
||||
import { type Component, toRaw } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
|
||||
import {
|
||||
LGraphNode,
|
||||
LegacyWidget,
|
||||
@@ -13,7 +14,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
export interface BaseDOMWidget<
|
||||
@@ -125,8 +125,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
declare readonly name: string
|
||||
declare readonly options: DOMWidgetOptions<V>
|
||||
declare callback?: (value: V) => void
|
||||
readonly promotionStore = usePromotionStore()
|
||||
|
||||
readonly id: string
|
||||
|
||||
constructor(obj: {
|
||||
@@ -189,11 +187,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
this.promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
graphId && isWidgetPromoted(graphId, String(this.node.id), this.name)
|
||||
if (!isPromoted) {
|
||||
this.options.onDraw?.(this)
|
||||
return
|
||||
|
||||
@@ -125,6 +125,7 @@ export const useDialogService = () => {
|
||||
error: {
|
||||
exceptionType: options.title ?? 'Unknown Error',
|
||||
exceptionMessage: errorProps.errorMessage,
|
||||
extensionFile: errorProps.extensionFile,
|
||||
traceback: errorProps.stackTrace ?? t('errorDialog.noStackTrace'),
|
||||
reportType: options.reportType
|
||||
}
|
||||
|
||||
@@ -7,12 +7,21 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
// Create mock functions that will be shared
|
||||
const mockNodeExecutionIdToNodeLocatorId = vi.fn()
|
||||
const mockNodeIdToNodeLocatorId = vi.fn()
|
||||
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
|
||||
const {
|
||||
mockNodeExecutionIdToNodeLocatorId,
|
||||
mockNodeIdToNodeLocatorId,
|
||||
mockNodeLocatorIdToNodeExecutionId,
|
||||
mockShowTextPreview
|
||||
} = vi.hoisted(() => ({
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn()
|
||||
}))
|
||||
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -38,7 +47,7 @@ declare global {
|
||||
|
||||
vi.mock('@/composables/node/useNodeProgressText', () => ({
|
||||
useNodeProgressText: () => ({
|
||||
showTextPreview: vi.fn()
|
||||
showTextPreview: mockShowTextPreview
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -431,6 +440,56 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - progress_text startup guard', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function fireProgressText(detail: {
|
||||
nodeId: string
|
||||
text: string
|
||||
prompt_id?: string
|
||||
}) {
|
||||
const handler = apiEventHandlers.get('progress_text')
|
||||
if (!handler) throw new Error('progress_text handler not bound')
|
||||
handler(new CustomEvent('progress_text', { detail }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
it('should ignore progress_text before the canvas is initialized', async () => {
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = null
|
||||
|
||||
expect(() =>
|
||||
fireProgressText({
|
||||
nodeId: '1',
|
||||
text: 'warming up'
|
||||
})
|
||||
).not.toThrow()
|
||||
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call showTextPreview when canvas is available', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = {
|
||||
graph: { getNodeById: vi.fn(() => mockNode) }
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
fireProgressText({ nodeId: '1', text: 'warming up' })
|
||||
|
||||
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
|
||||
@@ -527,7 +527,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// Handle execution node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
if (!currentId) return
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
const node = canvasStore.canvas?.graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
|
||||
useNodeProgressText().showTextPreview(node, text)
|
||||
|
||||
@@ -193,6 +193,29 @@ describe(usePromotionStore, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetPromoted', () => {
|
||||
it('matches exact disambiguated promotion keys', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(store.isWidgetPromoted(graphA, '3', 'text', '1')).toBe(true)
|
||||
expect(store.isWidgetPromoted(graphA, '3', 'text', '2')).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the base key when disambiguatingSourceNodeId is omitted', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(store.isWidgetPromoted(graphA, '3', 'text')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions', () => {
|
||||
it('replaces existing entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
@@ -817,7 +840,7 @@ describe(usePromotionStore, () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
|
||||
it('isPromotedByAny with disambiguatingSourceNodeId matches exact key and base key', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
@@ -837,12 +860,14 @@ describe(usePromotionStore, () => {
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(false)
|
||||
// Base-key lookup succeeds because dual-indexing keeps a ref count
|
||||
// on the base key for callers that lack a disambiguator.
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
).toBe(false)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
|
||||
|
||||
@@ -7,8 +7,12 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
|
||||
|
||||
function makePromotionBaseKey(source: PromotedWidgetSource): string {
|
||||
return `${source.sourceNodeId}:${source.sourceWidgetName}`
|
||||
}
|
||||
|
||||
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
|
||||
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
|
||||
const base = makePromotionBaseKey(source)
|
||||
return source.disambiguatingSourceNodeId
|
||||
? `${base}:${source.disambiguatingSourceNodeId}`
|
||||
: base
|
||||
@@ -40,14 +44,33 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
return nextRefCounts
|
||||
}
|
||||
|
||||
function _incrementKey(refCounts: Map<string, number>, key: string): void {
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function _decrementKey(refCounts: Map<string, number>, key: string): void {
|
||||
const current = refCounts.get(key)
|
||||
if (current === undefined) return
|
||||
|
||||
if (current <= 1) {
|
||||
refCounts.delete(key)
|
||||
} else {
|
||||
refCounts.set(key, current - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function _incrementKeys(
|
||||
graphId: UUID,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = makePromotionEntryKey(e)
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
_incrementKey(refCounts, makePromotionEntryKey(e))
|
||||
// Also index the base key so callers without a disambiguator can
|
||||
// still query whether any widget with this name is promoted.
|
||||
if (e.disambiguatingSourceNodeId) {
|
||||
_incrementKey(refCounts, makePromotionBaseKey(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +80,9 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = makePromotionEntryKey(e)
|
||||
const count = (refCounts.get(key) ?? 1) - 1
|
||||
if (count <= 0) {
|
||||
refCounts.delete(key)
|
||||
} else {
|
||||
refCounts.set(key, count)
|
||||
_decrementKey(refCounts, makePromotionEntryKey(e))
|
||||
if (e.disambiguatingSourceNodeId) {
|
||||
_decrementKey(refCounts, makePromotionBaseKey(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,11 +210,31 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
graphRefCounts.value.delete(graphId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a widget is promoted by any subgraph node in the given
|
||||
* graph. When disambiguatingSourceNodeId is provided, does an exact-key
|
||||
* lookup; otherwise falls back to a base-key lookup (which succeeds if
|
||||
* any widget with that name on the source node is promoted).
|
||||
*/
|
||||
function isWidgetPromoted(
|
||||
graphId: UUID,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): boolean {
|
||||
return isPromotedByAny(graphId, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getPromotionsRef,
|
||||
getPromotions,
|
||||
isPromoted,
|
||||
isPromotedByAny,
|
||||
isWidgetPromoted,
|
||||
setPromotions,
|
||||
promote,
|
||||
demote,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ExecutionErrorWsMessage, NodeError } from '@/schemas/apiSchema'
|
||||
import type { useDialogService } from '@/services/dialogService'
|
||||
@@ -113,6 +114,7 @@ export interface ExtensionManager {
|
||||
get: <T = unknown>(id: string) => T | undefined
|
||||
set: <T = unknown>(id: string, value: T) => void
|
||||
}
|
||||
workflow: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
// Execution error state (read-only)
|
||||
lastNodeErrors: Record<NodeId, NodeError> | null
|
||||
|
||||
Reference in New Issue
Block a user