mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-17 11:59:23 +00:00
Compare commits
1 Commits
jaewon/fix
...
fix/widget
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d40405d16 |
24
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
24
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -109,27 +109,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No PostHog references found'
|
||||
|
||||
- name: Scan dist for Customer.io telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Customer.io references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'CustomerIoTelemetryProvider' \
|
||||
-e '@customerio/cdp-analytics-browser' \
|
||||
-e 'customerio-gist-web' \
|
||||
-e '(?i)cdp\.customer\.io' \
|
||||
-e 'Comfy\.CustomerIo' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Customer.io references found in dist assets!'
|
||||
echo 'Customer.io must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
"no-useless-rename": "off",
|
||||
@@ -74,14 +73,12 @@
|
||||
"import/namespace": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"vitest/expect-expect": "off",
|
||||
"vitest/no-conditional-expect": "off",
|
||||
"vitest/no-disabled-tests": "off",
|
||||
"vitest/no-standalone-expect": "off",
|
||||
"vitest/valid-title": "off",
|
||||
"vitest/require-to-throw-message": "off",
|
||||
"jest/expect-expect": "off",
|
||||
"jest/no-conditional-expect": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
"jest/no-standalone-expect": "off",
|
||||
"jest/valid-title": "off",
|
||||
"typescript/no-this-alias": "off",
|
||||
"typescript/no-useless-default-assignment": "off",
|
||||
"typescript/no-unnecessary-parameter-property-assignment": "off",
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["cloud_importable_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [560, 100],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "cloud_importable_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "seed"]]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "seed"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 14,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [498.26665242513025, 471.46666463216144],
|
||||
"size": [510.328125, 252.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500.66667683919275, 227.8666280110677],
|
||||
"size": [507.40625, 233.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [570.266591389974, 735.4665120442708],
|
||||
"size": [378, 216],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1453.466512044271, 230.26666768391925],
|
||||
"size": [252, 138],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.866658528646, 231.46666463216144],
|
||||
"size": [252, 148],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.866689046223996, 573.4666951497395],
|
||||
"size": [378, 196],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.9444173177085, 343.52284749348956],
|
||||
"size": [225, 220],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["-1", "seed"],
|
||||
["3", "control_after_generate"]
|
||||
]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.35.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -60,16 +60,14 @@ export const TestIds = {
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
missingModelImport: 'missing-model-import',
|
||||
missingModelImportableRows: 'missing-model-importable-rows',
|
||||
missingModelLocate: 'missing-model-locate',
|
||||
missingModelReferenceCount: 'missing-model-reference-count',
|
||||
missingModelUnsupportedSection:
|
||||
'missing-model-import-not-supported-section',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-header-refresh',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
swapNodeGroupCount: 'swap-node-group-count',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
@@ -7,13 +8,8 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
interface ResolvedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: ResolvedWidgetSource
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
@@ -24,22 +20,23 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the promoted widgets of a subgraph host node from the live graph.
|
||||
*
|
||||
* Promoted widgets are now store-backed: a host input is promoted iff it
|
||||
* carries a `widgetId`, and its interior source identity is resolved on demand
|
||||
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
|
||||
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
|
||||
* denormalization, so the helper reflects the real projection rather than a
|
||||
* deleted widget-object contract.
|
||||
*/
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -47,49 +44,21 @@ export async function getPromotedWidgets(
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const previewExposures = node?.serialize()?.properties?.previewExposures
|
||||
if (!node?.isSubgraphNode?.())
|
||||
return { widgetSources: [], previewExposures }
|
||||
|
||||
const { subgraph } = node
|
||||
const resolveSource = (
|
||||
inputName: string
|
||||
): ResolvedWidgetSource | undefined => {
|
||||
const inputSlot = subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
for (const linkId of inputSlot.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
if (inputNode.isSubgraphNode?.()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const widgetSources = (node.inputs ?? []).flatMap((input) => {
|
||||
if (!input.widgetId) return []
|
||||
const source = resolveSource(input.name)
|
||||
return source ? [source] : []
|
||||
]
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
@@ -98,7 +67,7 @@ export async function getPromotedWidgets(
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.map(widgetSourceToEntry),
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 321 KiB |
@@ -1,103 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
@@ -131,14 +131,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
'normal',
|
||||
1
|
||||
])
|
||||
|
||||
if (mode.vueNodesEnabled) {
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'denoise')
|
||||
.locator('input')
|
||||
).toHaveValue(/^1(?:\.0+)?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('Success toast is shown after replacement', async ({
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
AssetCreated,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
'models/checkpoints/cloud_importable_model.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
@@ -40,62 +27,13 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-existing-cloud-importable-model',
|
||||
name: 'asset-record-display-name.safetensors',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
|
||||
size: 2_048,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
new URL(requestUrl).searchParams
|
||||
.get('include_tags')
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function filterAssetsByRequest(
|
||||
assets: ReadonlyArray<Asset>,
|
||||
requestUrl: string
|
||||
): Asset[] {
|
||||
const includeTags = getRequestedIncludeTags(requestUrl)
|
||||
return includeTags.length
|
||||
? assets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: [...assets]
|
||||
}
|
||||
|
||||
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
model_upload_button_enabled: true,
|
||||
private_models_enabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableMissingModelImportFeatures(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
@@ -150,216 +88,5 @@ test.describe(
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
|
||||
test('separates importable cloud models from unsupported rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const importableRows = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelImportableRows
|
||||
)
|
||||
const unsupportedSection = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelUnsupportedSection
|
||||
)
|
||||
|
||||
await expect(
|
||||
importableRows.getByRole('button', {
|
||||
name: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(unsupportedSection).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Import Not Supported')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(
|
||||
/Nodes that reference the models below do not support imported models/
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Unknown', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByRole('button', {
|
||||
name: 'Load Image',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('opens cloud import with missing-model replacement context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 1024,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: 'replacement.safetensors',
|
||||
tags: ['loras']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const urlInput = comfyPage.page.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await expect(urlInput).toBeVisible()
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
|
||||
)
|
||||
await comfyPage.page
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
|
||||
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
/Locked to (Checkpoints|checkpoints) for this missing model/
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('uses the synced asset filename when applying an already imported cloud model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let isImportedAssetAvailable = false
|
||||
const visibleAssets = () =>
|
||||
isImportedAssetAvailable
|
||||
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
|
||||
: [LOTUS_DIFFUSION_MODEL]
|
||||
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
|
||||
const assets = filterAssetsByRequest(
|
||||
visibleAssets(),
|
||||
route.request().url()
|
||||
)
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 2048,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
tags: ['checkpoints']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/download', (route) => {
|
||||
isImportedAssetAvailable = true
|
||||
const response: AssetCreated = {
|
||||
...EXISTING_CLOUD_IMPORTABLE_MODEL,
|
||||
created_new: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
const urlInput = uploadDialog.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
|
||||
)
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step2-confirm-button"]')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -12,18 +11,6 @@ import {
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
).toHaveText(String(count))
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -47,14 +34,15 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name and metadata', async ({ comfyPage }) => {
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(modelsGroup)).toBeVisible()
|
||||
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
|
||||
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
|
||||
})
|
||||
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
@@ -65,33 +53,32 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
)
|
||||
const expandButton = modelsGroup.getByTestId(
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
)
|
||||
await expect(expandButton.first()).toBeVisible()
|
||||
await expectReferenceBadge(modelsGroup, 2)
|
||||
await expandButton.first().click()
|
||||
|
||||
await expect(
|
||||
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(2)
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('/api/devtools/')
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
})
|
||||
|
||||
test.describe('OSS-specific', { tag: '@oss' }, () => {
|
||||
@@ -100,9 +87,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -115,7 +102,6 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelDownload
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
await expect(downloadButton.first()).toHaveText('Download')
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -9,18 +8,6 @@ import {
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
).toHaveText(String(count))
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -143,9 +130,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
@@ -169,7 +156,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -179,7 +168,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
@@ -261,17 +252,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -396,7 +384,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
@@ -449,7 +439,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
|
||||
@@ -217,14 +217,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Each promoted input must surface its own source value, so assert the
|
||||
// name->value mapping rather than the first textbox in DOM order.
|
||||
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
|
||||
value: /Inner 1/,
|
||||
value_1: /Inner 2/,
|
||||
value_1_1: /Inner 3/
|
||||
}
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -236,16 +228,11 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgets).toHaveCount(4)
|
||||
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNode.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
const valueWidget = outerNode
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
||||
@@ -284,16 +271,11 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
||||
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNodeAfter.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
const valueWidget = outerNodeAfter
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,22 +53,6 @@ test.describe(
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator(String(subgraphNode.id))
|
||||
.getByRole('textbox', { name: 'text', exact: true })
|
||||
await expect(promotedTextarea).toHaveCount(1)
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
wstest(
|
||||
'Seed handling',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
async function verifySeedControl(initializeState = true) {
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
|
||||
const { input, valueControl } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
|
||||
if (initializeState) {
|
||||
await input.fill('1')
|
||||
await valueControl.click()
|
||||
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
await execution.run()
|
||||
await expect.soft(input).toHaveValue('2')
|
||||
}
|
||||
|
||||
await test.step('seed updates on generation', async () => {
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
await test.step('subgraph seed updates on generation', async () => {
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
|
||||
await test.step(`seed updates for old workflow: ${w}`, async () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
|
||||
await verifySeedControl(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -484,14 +484,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Assert against the visible textbox the user sees, not the internal
|
||||
// graph/widget projection.
|
||||
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(1)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
@@ -505,58 +497,31 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test(
|
||||
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const editedValue = 'Edited prompt that must survive cloning'
|
||||
const originalTextbox = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(originalTextbox).toBeVisible()
|
||||
await expect(originalTextbox).toHaveValue('')
|
||||
await originalTextbox.fill(editedValue)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const textbox = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(
|
||||
textbox,
|
||||
`node ${nodeId} promoted text widget reset to default after clone`
|
||||
).toHaveValue(editedValue)
|
||||
}
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
|
||||
@@ -177,30 +177,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not drag contents when control is held', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const initialNodeBounds = await ksampler.boundingBox()
|
||||
expect(initialNodeBounds).toBeTruthy()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect
|
||||
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
|
||||
.not.toEqual(groupPos)
|
||||
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -140,46 +139,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'Displays previews inside subgraphs received while workflow inactive',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
const previewImage = new VueNodeFixture(previewLocator)
|
||||
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const subgraphNode = new VueNodeFixture(subgraphLocator)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
await expect(previewImage.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await previewImage.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(subgraphNode.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Inject Previews from different tab', async () => {
|
||||
const jobId = await execution.run()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await comfyPage.vueNodes.waitForNodes(7)
|
||||
|
||||
const images = [{ filename: 'example.png', type: 'input' }]
|
||||
execution.executed(jobId, '2:1', { images })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
|
||||
@@ -98,43 +98,4 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
|
||||
})
|
||||
|
||||
test('opens the switcher to the left of the profile menu without overlap', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
const panel = page.getByTestId('workspace-switcher-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
const profileMenu = page.locator('.current-user-popover')
|
||||
const panelBox = await panel.boundingBox()
|
||||
const profileBox = await profileMenu.boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(profileBox).not.toBeNull()
|
||||
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(profileBox!.x)
|
||||
})
|
||||
|
||||
test('opens the create-workspace dialog with DES-246 copy', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
await page.getByText('Create a workspace').click()
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.'
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Ex: Comfy Org')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -49,11 +49,6 @@ interface Window {
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Record<string, unknown>
|
||||
customer_io?: {
|
||||
write_key?: string
|
||||
site_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.46.14",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -66,7 +66,6 @@
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@customerio/cdp-analytics-browser": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
@@ -207,7 +206,7 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25 <26",
|
||||
"node": ">=25",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
|
||||
@@ -7,10 +7,7 @@ export type { ClassValue } from 'clsx'
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs'],
|
||||
// tailwind-merge does not know Tailwind's `max-h-none`, so it never
|
||||
// resolves conflicts like `max-h-[80vh] max-h-none` (both survive).
|
||||
'max-h': [{ 'max-h': ['none'] }]
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
572
pnpm-lock.yaml
generated
572
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ catalog:
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@customerio/cdp-analytics-browser': ^0.5.3
|
||||
'@eslint/js': ^10.0.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
@@ -80,7 +79,7 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.2
|
||||
eslint-plugin-oxlint: 1.69.0
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
@@ -102,9 +101,9 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
|
||||
default: {
|
||||
name: 'ComfyQueueButton',
|
||||
template: '<div data-testid="queue-button" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
|
||||
default: {
|
||||
name: 'SubscribeToRun',
|
||||
template: '<div data-testid="subscribe-to-run-button" />'
|
||||
}
|
||||
}))
|
||||
|
||||
function renderWrapper() {
|
||||
return render(CloudRunButtonWrapper)
|
||||
}
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-to-run-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-to-run-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedWidgetId(widgetId: WidgetId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const widgetId = widget.widgetId
|
||||
if (!widgetId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.widgetId, getWidgetBounding(entry)] as [
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedWidgetId(entry.widgetId)"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
|
||||
@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { widgetId, node, widget, config } = entry
|
||||
const { entityId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
return vueWidget.widgetId === widgetId
|
||||
return vueWidget.entityId === entityId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: widgetId,
|
||||
key: entityId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
@@ -23,29 +22,18 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs: [],
|
||||
isSubgraphNode: () => false,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs,
|
||||
isSubgraphNode: () => true,
|
||||
widgets: []
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
@@ -100,27 +88,4 @@ describe('useResolvedSelectedInputs', () => {
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('resolves promoted subgraph inputs from their host input widgetId', () => {
|
||||
const node = makeSubgraphNode(1, [
|
||||
fromPartial<INodeInputSlot>({
|
||||
name: 'seed',
|
||||
label: 'renamed_seed',
|
||||
widgetId: entitySeed
|
||||
})
|
||||
])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
|
||||
expect(resolved.value[0]).toMatchObject({
|
||||
status: 'resolved',
|
||||
node,
|
||||
displayName: 'seed',
|
||||
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
widgetId: WidgetId
|
||||
entityId: WidgetEntityId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
@@ -21,7 +20,7 @@ export type ResolvedSelection =
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
widgetId: WidgetId
|
||||
entityId: WidgetEntityId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
@@ -55,19 +54,16 @@ export function useResolvedSelectedInputs() {
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([widgetId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetId(widgetId)) return []
|
||||
const { nodeId, name } = parseWidgetId(widgetId)
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widgets = node?.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: node?.widgets
|
||||
const widget = widgets?.find((w) => w.name === name)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', widgetId, displayName, config }]
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', widgetId, node, widget, displayName, config }
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,9 +17,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { close: 'Close', maximizeDialog: 'Maximize' } }
|
||||
},
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -195,68 +193,6 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies headerClass and bodyClass on the non-headless path', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-section-classes',
|
||||
title: 'Section classes',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'p-0'
|
||||
}
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const header = screen.getByText('Section classes').parentElement
|
||||
expect(header?.classList.contains('p-2')).toBe(true)
|
||||
// twMerge drops the default header padding in favor of headerClass
|
||||
expect(header?.classList.contains('px-4')).toBe(false)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const body = screen.getByTestId('body').parentElement
|
||||
expect(body?.classList.contains('p-0')).toBe(true)
|
||||
expect(body?.classList.contains('px-4')).toBe(false)
|
||||
})
|
||||
|
||||
it('maximize overrides custom dimension classes from contentClass', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-maximize-wins',
|
||||
title: 'Maximize wins',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
maximizable: true,
|
||||
contentClass:
|
||||
'w-[80vw] max-w-[80vw] sm:max-w-[80vw] h-[80vh] max-h-[80vh]'
|
||||
}
|
||||
})
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Maximize' }))
|
||||
|
||||
// Maximized dimensions win over the caller's fixed dimensions,
|
||||
// mirroring PrimeVue's `.p-dialog-maximized` !important behavior.
|
||||
expect(dialog.classList.contains('size-auto')).toBe(true)
|
||||
expect(dialog.classList.contains('max-h-none')).toBe(true)
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('sm:max-w-[80vw]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
@@ -302,22 +238,6 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
|
||||
// A backgrounded dialog must never dismiss on an outside pointer — the
|
||||
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding
|
||||
// opening over Settings). Target is outside any overlay, so only the
|
||||
// is-active gate can prevent it.
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, false)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('allows the top-most dialog to dismiss on a true outside pointer', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
|
||||
@@ -18,19 +18,13 @@
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@open-auto-focus="(e) => onRekaOpenAutoFocus(e, item.key)"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
onRekaPointerDownOutside(
|
||||
item.dialogComponentProps,
|
||||
e,
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
@@ -43,7 +37,7 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader :class="item.dialogComponentProps.headerClass">
|
||||
<DialogHeader>
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
@@ -64,24 +58,14 @@
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 overflow-auto px-4 py-2',
|
||||
item.dialogComponentProps.bodyClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter
|
||||
v-if="item.footerComponent"
|
||||
:class="item.dialogComponentProps.footerClass"
|
||||
>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
@@ -125,8 +109,6 @@
|
||||
<script setup lang="ts">
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
@@ -154,22 +136,6 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
// Reka's FocusScope focuses the first tabbable element on open (often a header
|
||||
// or footer button). Dialog content that marks an input with `autofocus` (e.g.
|
||||
// the keybinding capture input, the prompt input) relied on PrimeVue honoring
|
||||
// that attribute, so honor it here: focus the autofocus target and cancel
|
||||
// Reka's default auto-focus when one is present.
|
||||
function onRekaOpenAutoFocus(event: Event, key: string) {
|
||||
const content = document.querySelector<HTMLElement>(
|
||||
`[aria-labelledby="${CSS.escape(key)}"]`
|
||||
)
|
||||
const autofocusEl = content?.querySelector<HTMLElement>('[autofocus]')
|
||||
if (autofocusEl) {
|
||||
event.preventDefault()
|
||||
autofocusEl.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
@@ -45,28 +44,23 @@ const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as { endDate: string | null } | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
cancelSubscription: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
subscription: mockSubscription
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -92,54 +86,6 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
it('shows an error toast and keeps the dialog open when cancellation fails', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce(
|
||||
new Error('Subscription cancellation timed out')
|
||||
)
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Subscription cancellation timed out'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the dialog and shows a success toast when cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
)
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
it('uses the localized fallback when no cancel timestamp is available', () => {
|
||||
mockSubscription.value = { endDate: null }
|
||||
|
||||
@@ -27,19 +27,8 @@ function isInsideOverlay(target: EventTarget | null): boolean {
|
||||
|
||||
export function onRekaPointerDownOutside(
|
||||
options: { dismissableMask?: boolean },
|
||||
event: OutsideEvent,
|
||||
isActive = true
|
||||
event: OutsideEvent
|
||||
) {
|
||||
// Stacked dialogs each render an independent Reka `Dialog` root, so a lower
|
||||
// dialog's DismissableLayer sees a pointer-down that opened (or landed on)
|
||||
// the dialog above it as "outside" and would dismiss itself — including via
|
||||
// the upper dialog's overlay, whose element matches none of the portal
|
||||
// selectors below. Only the top-most dialog may dismiss on an outside
|
||||
// pointer, mirroring the escape-key handling in `GlobalDialog`.
|
||||
if (!isActive) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
|
||||
@@ -57,85 +57,154 @@ function drawFrame(canvas: LGraphCanvas) {
|
||||
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
|
||||
}
|
||||
|
||||
describe('DomWidgets positioning', () => {
|
||||
describe('DomWidgets transition grace characterization', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('positions an active visible widget relative to its owning node', () => {
|
||||
it('applies transition grace for exactly one frame when override exists but is not active', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-pos', node, 14)
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
|
||||
|
||||
const widget = createWidget('widget-transition', interiorNode, 14)
|
||||
const overrideWidget = createWidget('override-widget', overrideNode, 22)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
|
||||
const canvas = createCanvas(graph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([110, 224])
|
||||
})
|
||||
|
||||
it('hides a widget whose owning node is in a different graph', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const currentGraph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = createNode(otherGraph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-other-graph', node, 14)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
|
||||
const canvas = createCanvas(currentGraph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('hides an inactive widget', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [0, 0])
|
||||
const widget = createWidget('widget-inactive', node, 10)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
widgetState.visible = true
|
||||
widgetState.pos = [321, 654]
|
||||
|
||||
const canvas = createCanvas(graph)
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([321, 654])
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
|
||||
|
||||
const widget = createWidget('widget-override-active', interiorNode, 8)
|
||||
const overrideWidget = createWidget(
|
||||
'override-position-source',
|
||||
overrideNode,
|
||||
18
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(widgetState.visible).toBe(false)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([310, 428])
|
||||
})
|
||||
|
||||
it('cleans orphaned transition-grace ids after widget removal', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
const overrideWidget = createWidget(
|
||||
'shared-override-widget',
|
||||
overrideNode,
|
||||
14
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(oldWidget)
|
||||
domWidgetStore.setPositionOverride(oldWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
domWidgetStore.unregisterWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
domWidgetStore.registerWidget(replacementWidget)
|
||||
domWidgetStore.setPositionOverride(replacementWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(replacementWidget.id)
|
||||
|
||||
const replacementState = domWidgetStore.widgetStates.get(
|
||||
replacementWidget.id
|
||||
)
|
||||
if (!replacementState) throw new Error('Replacement widget missing state')
|
||||
replacementState.visible = true
|
||||
replacementState.pos = [999, 999]
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(replacementState.visible).toBe(true)
|
||||
expect(replacementState.pos).toEqual([999, 999])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const overrideTransitionGrace = new Set<string>()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
|
||||
@@ -30,16 +31,47 @@ const updateWidgets = () => {
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
const seenWidgetIds = new Set<string>()
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
seenWidgetIds.add(widget.id)
|
||||
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
// Use position override only when the override node (SubgraphNode) is
|
||||
// in the current graph. When the user enters the subgraph, the override
|
||||
// node is no longer visible — fall back to the widget's own node.
|
||||
// Use graph reference equality (IDs are not unique across graphs).
|
||||
const override = widgetState.positionOverride
|
||||
const useOverride = !!override && currentGraph === override.node.graph
|
||||
const inOverrideTransitionGap =
|
||||
!!override && !useOverride && !widgetState.active
|
||||
const useTransitionGrace =
|
||||
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
|
||||
|
||||
if (useTransitionGrace) {
|
||||
overrideTransitionGrace.add(widget.id)
|
||||
} else if (!inOverrideTransitionGap) {
|
||||
overrideTransitionGrace.delete(widget.id)
|
||||
}
|
||||
|
||||
// Early exit for non-visible widgets.
|
||||
// When a position override is active (widget promoted to SubgraphNode),
|
||||
// the interior widget's `active` flag is false (its node is in the
|
||||
// subgraph, not the current graph) — bypass that check.
|
||||
if (
|
||||
!widget.isVisible() ||
|
||||
(!widgetState.active && !useOverride && !useTransitionGrace)
|
||||
) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
const posNode = widget.node
|
||||
// During graph transitions, hold the previous position for one frame
|
||||
// so promoted widgets don't briefly disappear before activation flips.
|
||||
if (useTransitionGrace) continue
|
||||
|
||||
const posNode = useOverride ? override.node : widget.node
|
||||
const posWidget = useOverride ? override.widget : widget
|
||||
|
||||
const isInCorrectGraph = posNode.graph === currentGraph
|
||||
const nodeVisible = lgCanvas.isNodeVisible(posNode)
|
||||
@@ -53,16 +85,22 @@ const updateWidgets = () => {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [
|
||||
posNode.pos[0] + margin,
|
||||
posNode.pos[1] + margin + widget.y
|
||||
posNode.pos[1] + margin + posWidget.y
|
||||
]
|
||||
widgetState.size = [
|
||||
(widget.width ?? posNode.width) - margin * 2,
|
||||
(widget.computedHeight ?? 50) - margin * 2
|
||||
(posWidget.width ?? posNode.width) - margin * 2,
|
||||
(posWidget.computedHeight ?? 50) - margin * 2
|
||||
]
|
||||
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
|
||||
widgetState.readonly = lgCanvas.read_only
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetId of overrideTransitionGrace) {
|
||||
if (!seenWidgetIds.has(widgetId)) {
|
||||
overrideTransitionGrace.delete(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
function createWidgetState(disabled: boolean): DomWidgetState {
|
||||
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const node = createMockLGraphNode({
|
||||
id: 1,
|
||||
@@ -70,10 +70,14 @@ function createWidgetState(disabled: boolean): DomWidgetState {
|
||||
value: '',
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: disabled
|
||||
computedDisabled: false
|
||||
})
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: createMockLGraphNode({ id: 2 }),
|
||||
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
|
||||
})
|
||||
|
||||
const state = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!state) throw new Error('Expected registered DomWidgetState')
|
||||
@@ -94,7 +98,7 @@ describe('DomWidget disabled style', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses disabled style when widget is computedDisabled', async () => {
|
||||
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||
const widgetState = createWidgetState(true)
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
|
||||
@@ -69,7 +69,11 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
|
||||
const isSelected = selectedNode === ownerNode
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -100,7 +104,10 @@ const updateDomClipping = () => {
|
||||
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
|
||||
|
||||
function composeStyle() {
|
||||
const isDisabled = widget.computedDisabled
|
||||
const override = widgetState.positionOverride
|
||||
const isDisabled = override
|
||||
? (override.widget.computedDisabled ?? widget.computedDisabled)
|
||||
: widget.computedDisabled
|
||||
|
||||
style.value = {
|
||||
...positionStyle.value,
|
||||
@@ -160,7 +167,13 @@ onMounted(() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const ownerNode = widgetState.widget.node
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph
|
||||
? override.node
|
||||
: widgetState.widget.node
|
||||
|
||||
lgCanvas.selectNode(ownerNode)
|
||||
lgCanvas.bringToFront(ownerNode)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
:source-format="sourceFormat"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@@ -167,7 +166,6 @@ const {
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
sourceFormat,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
:source-format="sourceFormat"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
@@ -135,8 +134,7 @@ const {
|
||||
canUseHdri = true,
|
||||
canUseBackgroundImage = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
@@ -145,7 +143,6 @@ const {
|
||||
canUseBackgroundImage?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -59,7 +59,6 @@ function buildViewerStub() {
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
sourceFormat: ref<string | null>(null),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
|
||||
@@ -82,10 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls
|
||||
:source-format="viewer.sourceFormat.value"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,9 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel, sourceFormat },
|
||||
props: { onExportModel },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
@@ -66,23 +63,6 @@ describe('ExportControls', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only the source format for direct-export files (e.g. ply)', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel, 'ply')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
|
||||
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: label })
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'PLY' }))
|
||||
expect(onExportModel).toHaveBeenCalledWith('ply')
|
||||
})
|
||||
|
||||
it('hides the popup when a click happens outside the trigger', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
|
||||
@@ -35,14 +35,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
|
||||
const { sourceFormat = null } = defineProps<{
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
@@ -50,7 +45,12 @@ const emit = defineEmits<{
|
||||
|
||||
const showExportFormats = ref(false)
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
function toggleExportFormats() {
|
||||
showExportFormats.value = !showExportFormats.value
|
||||
|
||||
@@ -41,9 +41,7 @@ const openIn3DViewer = () => {
|
||||
component: Load3DViewerContent,
|
||||
props: props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true,
|
||||
onClose: async () => {
|
||||
await useLoad3dService().handleViewerClose(props.node)
|
||||
|
||||
@@ -72,12 +72,9 @@ const i18n = createI18n({
|
||||
messages: { en: { load3d: { export: 'Export' } } }
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ViewerExportControls, {
|
||||
props: { onExportModel, sourceFormat },
|
||||
props: { onExportModel },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
@@ -117,32 +114,4 @@ describe('ViewerExportControls', () => {
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
|
||||
it('offers only the source format for direct-export files (e.g. spz)', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel, 'spz')
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('spz')
|
||||
})
|
||||
|
||||
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user, rerender } = renderComponent(onExportModel, null)
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
|
||||
expect(select.value).toBe('obj')
|
||||
|
||||
await rerender({ onExportModel, sourceFormat: 'ply' })
|
||||
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('ply')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -34,30 +34,20 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
|
||||
const { sourceFormat = null } = defineProps<{
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
const exportFormat = ref('obj')
|
||||
|
||||
watch(
|
||||
exportFormats,
|
||||
(formats) => {
|
||||
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
|
||||
exportFormat.value = formats[0]?.value ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const exportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,31 +1,29 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -36,7 +34,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -51,7 +49,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -61,29 +59,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -98,13 +96,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -117,61 +115,60 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,10 +78,6 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -122,6 +118,9 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -212,13 +211,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -411,7 +404,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -421,40 +414,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -472,7 +431,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -494,7 +453,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -536,12 +495,6 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -573,7 +526,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
@@ -586,7 +539,7 @@ describe('TabErrors.vue', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -604,8 +557,11 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-header-refresh')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,62 +11,49 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="getGroupCount(group)"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -75,7 +62,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -96,7 +83,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -107,10 +94,9 @@
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
@@ -126,6 +112,7 @@
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
{{ t('rightSidePanel.missingModels.refresh') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
@@ -142,142 +129,141 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -315,23 +301,25 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -359,6 +347,7 @@ const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
@@ -372,6 +361,22 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
@@ -458,35 +463,20 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
function getGroupCount(group: ErrorGroup): number {
|
||||
switch (group.type) {
|
||||
case 'execution':
|
||||
return getExecutionGroupCount(group)
|
||||
case 'missing_node':
|
||||
return missingPackGroups.value.length
|
||||
case 'swap_nodes':
|
||||
return swapNodeGroups.value.length
|
||||
case 'missing_model':
|
||||
return missingModelGroups.value.reduce(
|
||||
(total, modelGroup) => total + modelGroup.models.length,
|
||||
0
|
||||
)
|
||||
case 'missing_media':
|
||||
return countMissingMediaReferences(missingMediaGroups.value)
|
||||
}
|
||||
}
|
||||
const missingModelDownloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
|
||||
)
|
||||
return getDownloadableModels(missingModelGroups.value)
|
||||
})
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
() =>
|
||||
!isCloud &&
|
||||
missingModelGroups.value.length > 0 &&
|
||||
missingModelDownloadableModels.value.length === 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -982,7 +982,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,15 +12,14 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -147,17 +146,16 @@ function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (source) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? source.nodeId
|
||||
? widget.sourceNodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
@@ -236,10 +234,7 @@ function navigateToErrorTab() {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
|
||||
// are addressed by widgetId; writing there keeps the displayed value in sync.
|
||||
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -250,18 +245,18 @@ function handleResetAllWidgets() {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
setWidgetValue(widget, defaultValue)
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
setWidgetValue(widget, newValue)
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
setWidgetValue(widget, newValue)
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import TabSubgraphInputs from './TabSubgraphInputs.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
|
||||
})
|
||||
|
||||
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
|
||||
rows: []
|
||||
}
|
||||
|
||||
const SectionWidgetsStub = {
|
||||
props: ['widgets', 'node', 'parents'],
|
||||
setup(props: Record<string, unknown>) {
|
||||
captured.rows = props.widgets as {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}[]
|
||||
return () => null
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostWithPromotedSeed(): {
|
||||
host: SubgraphNode
|
||||
sourceNode: LGraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('Sampler')
|
||||
const input = sourceNode.addInput('seed', 'INT')
|
||||
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
|
||||
input.widget = { name: seedWidget.name }
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
|
||||
return { host, sourceNode }
|
||||
}
|
||||
|
||||
function renderPanel(node: SubgraphNode) {
|
||||
return render(TabSubgraphInputs, {
|
||||
props: { node },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SectionWidgets: SectionWidgetsStub,
|
||||
AsyncSearchInput: true,
|
||||
CollapseToggleButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TabSubgraphInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
captured.rows = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow).toBeDefined()
|
||||
expect(seedRow?.node.id).toBe(host.id)
|
||||
expect(seedRow?.widget.type).toBe('number')
|
||||
expect(seedRow?.widget.widgetId).toBe(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
)
|
||||
expect(seedRow?.widget.value).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the current host widget value from the store', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
const id = widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
useWidgetValueStore().setValue(id, 7)
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow?.widget.value).toBe(7)
|
||||
})
|
||||
|
||||
it('reflects value changes through the same descriptor without rebuilding it', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
|
||||
expect(seedRow.widget.value).toBe(42)
|
||||
|
||||
// A value edit must not require a new descriptor object: the same row
|
||||
// reflects the store change via its live getter, keeping render keys stable.
|
||||
useWidgetValueStore().setValue(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed'),
|
||||
100
|
||||
)
|
||||
expect(seedRow.widget.value).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,14 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
@@ -44,6 +45,32 @@ const isAllCollapsed = computed({
|
||||
})
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(a) &&
|
||||
isPromotedWidgetView(b) &&
|
||||
a.sourceNodeId === b.sourceNodeId &&
|
||||
a.sourceWidgetName === b.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotedWidgets(): IBaseWidget[] {
|
||||
const inputWidgets = node.inputs
|
||||
.map((input) => input._widget)
|
||||
.filter((widget): widget is IBaseWidget =>
|
||||
Boolean(widget && isPromotedWidgetView(widget))
|
||||
)
|
||||
const extraWidgets = (node.widgets ?? []).filter(
|
||||
(widget) =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
!inputWidgets.some((inputWidget) =>
|
||||
isSamePromotedWidget(inputWidget, widget)
|
||||
)
|
||||
)
|
||||
|
||||
return [...inputWidgets, ...extraWidgets]
|
||||
}
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
@@ -66,7 +93,7 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -47,10 +45,8 @@ const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode()) return false
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (!source) return false
|
||||
return isLinkedPromotion(node, source.nodeId, source.widgetName)
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
@@ -68,16 +64,9 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const currentValue = computed(
|
||||
() =>
|
||||
(widget.widgetId &&
|
||||
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
|
||||
widget.value
|
||||
)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(currentValue.value, defaultValue.value)
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
@@ -88,15 +77,21 @@ async function handleRename() {
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id)
|
||||
demoteWidget(
|
||||
{
|
||||
id: sourceNodeId,
|
||||
title: node.title,
|
||||
type: node.type
|
||||
},
|
||||
widget,
|
||||
[parent]
|
||||
)
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
|
||||
@@ -7,8 +7,6 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
@@ -44,6 +42,10 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
@@ -94,6 +96,43 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock PromotedWidgetView that mirrors the real class:
|
||||
* properties like name, type, value, options are prototype getters,
|
||||
* NOT own properties — so object spread loses them.
|
||||
*/
|
||||
function createMockPromotedWidgetView(
|
||||
sourceOptions: IBaseWidget['options'] = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
): IBaseWidget {
|
||||
class MockPromotedWidgetView {
|
||||
readonly sourceNodeId = '42'
|
||||
readonly sourceWidgetName = 'ckpt_name'
|
||||
readonly serialize = false
|
||||
|
||||
get name(): string {
|
||||
return 'ckpt_name'
|
||||
}
|
||||
get type(): string {
|
||||
return 'combo'
|
||||
}
|
||||
get value(): unknown {
|
||||
return 'model_a.safetensors'
|
||||
}
|
||||
get options(): IBaseWidget['options'] {
|
||||
return sourceOptions
|
||||
}
|
||||
get label(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
get y(): number {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
}
|
||||
|
||||
function renderWidgetItem(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode = createMockNode()
|
||||
@@ -128,7 +167,7 @@ describe('WidgetItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('widget state rendering', () => {
|
||||
describe('promoted widget options', () => {
|
||||
it('passes options from a regular widget to the widget component', () => {
|
||||
const widget = createMockWidget({
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
@@ -141,63 +180,35 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes options from widget state to the widget component', () => {
|
||||
it('passes options from a PromotedWidgetView to the widget component', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: expectedOptions
|
||||
})
|
||||
|
||||
const widget = createMockPromotedWidgetView(expectedOptions)
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.options).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
it('passes type from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, type: 'string' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
it('passes type from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.type).toBe('combo')
|
||||
})
|
||||
|
||||
it('passes name from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
it('passes name from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.name).toBe('ckpt_name')
|
||||
})
|
||||
|
||||
it('passes value from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, value: 'source value' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
it('passes value from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -16,12 +17,11 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -67,32 +67,35 @@ const widgetComponent = computed(() => {
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const source = resolvePromotedWidgetSource(node, widget)
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(node.id))
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
|
||||
: undefined
|
||||
const widgetName = widgetState?.name ?? widget.name
|
||||
const widgetType = widgetState?.type ?? widget.type
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
name: widgetName,
|
||||
type: widgetType,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
|
||||
controlWidget: getControlWidget(widget)
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
}
|
||||
})
|
||||
|
||||
const displayNodeName = computed((): string | null => {
|
||||
if (!node) return null
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
|
||||
if (!sourceNode) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(node, {
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
@@ -164,10 +167,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && displayNodeName"
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ displayNodeName }}
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
|
||||
@@ -14,9 +14,8 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
@@ -168,20 +167,11 @@ describe('SubgraphEditor', () => {
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
const rowFor = (sourceNode: LGraphNode) => {
|
||||
const input = host.inputs.find((input) => {
|
||||
if (!input.widgetId) return false
|
||||
const target = resolveSubgraphInputTarget(host, input.name)
|
||||
return target?.nodeId === String(sourceNode.id)
|
||||
})!
|
||||
return {
|
||||
kind: 'promoted',
|
||||
node: sourceNode,
|
||||
input,
|
||||
widget: promotedInputWidget(input)!
|
||||
}
|
||||
}
|
||||
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
|
||||
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
|
||||
const reversed = [
|
||||
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
|
||||
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
|
||||
] as PromotedRow[]
|
||||
listSetter?.(reversed)
|
||||
await nextTick()
|
||||
|
||||
@@ -192,42 +182,6 @@ describe('SubgraphEditor', () => {
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('moves a widget to shown when promoted from the hidden section', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const sourceInput = sourceNode.addInput('first', 'STRING')
|
||||
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
|
||||
sourceInput.widget = { name: sourceWidget.name }
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
|
||||
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
|
||||
await nextTick()
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -259,13 +213,13 @@ describe('SubgraphEditor', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const hideAllLink = within(shown).getByText('Hide all')
|
||||
await userEvent.click(hideAllLink)
|
||||
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
isLinkedPromotion,
|
||||
@@ -15,14 +16,8 @@ import {
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
promotedInputSource,
|
||||
promotedInputWidget
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -38,8 +33,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
input: INodeInputSlot
|
||||
widget: IBaseWidget
|
||||
widget: PromotedWidgetView
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
@@ -60,23 +54,11 @@ const activeNode = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const promotedRows = shallowRef<readonly PromotedRow[]>([])
|
||||
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return node.inputs.flatMap((input): PromotedRow[] => {
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) return []
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, input, widget }]
|
||||
})
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
}
|
||||
function refreshPromotedRows() {
|
||||
const node = activeNode.value
|
||||
promotedRows.value = node ? buildPromotedRows(node) : []
|
||||
}
|
||||
watch(activeNode, refreshPromotedRows, { immediate: true })
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
@@ -86,29 +68,34 @@ useEventListener(
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedRows
|
||||
refreshPromotedWidgets
|
||||
)
|
||||
|
||||
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
|
||||
const node = activeNode.value
|
||||
return node ? promotedInputSource(node, row.input) : undefined
|
||||
}
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...promotedRows.value, ...getActivePreviewRows(node)]
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
return [...promotedRows.value]
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedRows(node) : []
|
||||
},
|
||||
set(value: PromotedRow[]) {
|
||||
updateActivePromotedRows(value, activePromotedRows.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, widget }]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const hostLocator = String(node.id)
|
||||
const rootGraphId = node.rootGraph.id
|
||||
@@ -143,7 +130,7 @@ function updateActivePromotedRows(
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => ({ widgetId: row.widget.widgetId }))
|
||||
value.map((row) => row.widget)
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
@@ -164,11 +151,9 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
if (row.kind !== 'promoted')
|
||||
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
|
||||
const source = promotedRowSource(row)
|
||||
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
@@ -243,16 +228,18 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
!!source &&
|
||||
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}`
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
@@ -269,14 +256,7 @@ function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
if (row.kind === 'promoted') {
|
||||
const source = promotedRowSource(row)
|
||||
if (source) {
|
||||
demotePromotedInput(subgraphNode, {
|
||||
sourceNodeId: source.nodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
@@ -294,18 +274,13 @@ function demoteRow(row: ActiveRow) {
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
const source = promotedRowSource(row)
|
||||
const sourceWidget = source
|
||||
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
|
||||
: undefined
|
||||
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
refreshPromotedRows()
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
|
||||
@@ -586,9 +586,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
modelUrl: asset.preview_url || getAssetUrl(asset)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,9 +189,7 @@ const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
modelUrl: previewOutput.url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,15 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
dialogContentVariants({ size, maximized }),
|
||||
customClass,
|
||||
// Custom dimension classes must yield to maximize, mirroring the
|
||||
// PrimeVue `.p-dialog-maximized` !important behavior.
|
||||
maximized && 'size-auto max-h-none max-w-none sm:max-w-none'
|
||||
)
|
||||
"
|
||||
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,8 +4,12 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -252,6 +256,273 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['model.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'other_model.safetensors',
|
||||
'model.safetensors',
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears range errors for promoted widgets by interior widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps_input', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('KSampler')
|
||||
const interiorInput = interiorNode.addInput('steps_input', 'INT')
|
||||
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
interiorInput.widget = { name: 'steps' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'steps',
|
||||
50,
|
||||
150,
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.type = 'CheckpointLoaderSimple'
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
id: 65,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
})
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
|
||||
canvasX: 190,
|
||||
canvasY: 20,
|
||||
deltaX: 0
|
||||
})
|
||||
const pointer = fromAny<CanvasPointer, unknown>({
|
||||
eDown: clickEvent
|
||||
})
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({
|
||||
graph_mouse: [190, 20],
|
||||
last_mouseclick: 0
|
||||
})
|
||||
|
||||
const handled = promotedWidget!.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
canvas
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(pointer.onClick).toBeDefined()
|
||||
|
||||
pointer.onClick?.(clickEvent)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first_ckpt', type: '*' },
|
||||
{ name: 'second_ckpt', type: '*' }
|
||||
]
|
||||
})
|
||||
const firstNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
firstNode.type = 'CheckpointLoaderSimple'
|
||||
const firstInput = firstNode.addInput('first_ckpt', '*')
|
||||
const firstWidget = firstNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
firstInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
secondNode.type = 'CheckpointLoaderSimple'
|
||||
const secondInput = secondNode.addInput('second_ckpt', '*')
|
||||
secondNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
secondInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const promotedWidgets =
|
||||
subgraphNode.widgets?.filter(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget &&
|
||||
widget.sourceWidgetName === 'ckpt_name'
|
||||
) ?? []
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
|
||||
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: firstExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate,
|
||||
{
|
||||
nodeId: secondExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
firstWidget.value = 'present.safetensors'
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'present.safetensors',
|
||||
'missing.safetensors',
|
||||
firstWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: secondExecId,
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
@@ -978,54 +1249,4 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears promoted widget errors by interior execution id', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const graph = subgraph.rootGraph
|
||||
const host = createTestSubgraphNode(subgraph, { id: 2 })
|
||||
graph.add(host)
|
||||
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.id = 1
|
||||
subgraph.add(interiorNode)
|
||||
const input = interiorNode.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'fake_model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
|
||||
)
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
|
||||
).toBe(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const missingModelStore = useMissingModelStore()
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: '2:1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const promotedWidget = host.widgets[0]
|
||||
host.onWidgetChanged!.call(
|
||||
host,
|
||||
promotedWidget.name,
|
||||
'real_model.safetensors',
|
||||
'fake_model.safetensors',
|
||||
promotedWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.hasMissingModels).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
@@ -43,6 +46,130 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface WidgetErrorClearingTarget {
|
||||
executionId: string
|
||||
validationInputName: string
|
||||
assetWidgetName: string
|
||||
currentValue: unknown
|
||||
options?: { min?: number; max?: number }
|
||||
}
|
||||
|
||||
function getWidgetRangeOptions(widget: IBaseWidget): {
|
||||
min?: number
|
||||
max?: number
|
||||
} {
|
||||
return {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max
|
||||
}
|
||||
}
|
||||
|
||||
function plainWidgetToErrorTarget(
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
return {
|
||||
executionId: hostExecId,
|
||||
validationInputName: widget.name,
|
||||
assetWidgetName: widget.name,
|
||||
currentValue: widget.value,
|
||||
options: getWidgetRangeOptions(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function promotedWidgetToErrorTarget(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: PromotedWidgetView,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
const execId =
|
||||
result.status === 'resolved' && result.resolved.node
|
||||
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
|
||||
: hostExecId
|
||||
const resolvedWidget =
|
||||
result.status === 'resolved' ? result.resolved.widget : widget
|
||||
|
||||
return {
|
||||
executionId: execId,
|
||||
validationInputName: resolvedWidget.name,
|
||||
assetWidgetName: widget.sourceWidgetName,
|
||||
currentValue: resolvedWidget.value,
|
||||
options: getWidgetRangeOptions(resolvedWidget)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
|
||||
|
||||
// Canvas-path events lose promoted identity, so the post-write value
|
||||
// disambiguates same-named promoted widgets.
|
||||
return (hostNode.widgets ?? [])
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
|
||||
.map((promotedWidget) =>
|
||||
promotedWidgetToErrorTarget(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
promotedWidget,
|
||||
hostExecId
|
||||
)
|
||||
)
|
||||
.filter((target) => Object.is(target.currentValue, newValue))
|
||||
}
|
||||
|
||||
function resolveWidgetErrorTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return [
|
||||
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
return canvasPathTargets.length
|
||||
? canvasPathTargets
|
||||
: [plainWidgetToErrorTarget(widget, hostExecId)]
|
||||
}
|
||||
|
||||
function clearWidgetErrorTargets(
|
||||
targets: WidgetErrorClearingTarget[],
|
||||
newValue: unknown
|
||||
): void {
|
||||
const store = useExecutionErrorStore()
|
||||
for (const target of targets) {
|
||||
store.clearWidgetRelatedErrors(
|
||||
target.executionId,
|
||||
target.validationInputName,
|
||||
target.assetWidgetName,
|
||||
newValue,
|
||||
target.options
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
@@ -76,24 +203,21 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
node.onWidgetChanged = useChainCallback(
|
||||
node.onWidgetChanged,
|
||||
// _name is the LiteGraph callback arg; re-derive from the widget
|
||||
// object to handle promoted widgets where sourceWidgetName differs.
|
||||
function (_name, newValue, _oldValue, widget) {
|
||||
if (!app.rootGraph) return
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
executionId,
|
||||
widgetName,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
const targets = resolveWidgetErrorTargets(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
clearWidgetErrorTargets(targets, newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -45,10 +47,9 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
const id = widgetId(graph.id, node.id, 'testnum')
|
||||
expect(store.getWidget(id)?.value).toBe(2)
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(id)
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
@@ -73,7 +74,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
@@ -210,32 +211,105 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
|
||||
// Subgraph input named "value" promotes an interior "prompt" widget. The
|
||||
// projected widget's name is the input slot name "value"; the interior
|
||||
// source widget name "prompt" is carried separately for backend lookups.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
const interiorInput = interiorNode.addInput('value', 'STRING')
|
||||
interiorNode.id = 10
|
||||
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
interiorInput.widget = { name: 'prompt' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
'value'
|
||||
)
|
||||
|
||||
// Host the promoted view on a regular node so we can control widgets
|
||||
// directly (SubgraphNode.widgets is a synthetic getter).
|
||||
const graph = new LGraph()
|
||||
const hostNode = new LGraphNode('host')
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceNodeId = '9999'
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.sourceWidgetName).toBe('prompt')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
@@ -374,8 +448,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -410,13 +484,13 @@ describe('Nested promoted widget mapping', () => {
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'first_seed')
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
expect(widgets?.[1]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'second_seed')
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -454,11 +528,10 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_input'
|
||||
(w) => w.name === 'ckpt_name'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
|
||||
// The interior node is inside subgraphNode (id=65),
|
||||
// so its execution ID should be "65:<interiorNodeId>"
|
||||
expect(promotedWidget?.sourceExecutionId).toBe(
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidgets
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -26,11 +27,10 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -38,8 +38,7 @@ import type {
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam,
|
||||
SubgraphNode
|
||||
LGraphTriggerParam
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -61,7 +60,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
widgetId?: WidgetId
|
||||
entityId?: WidgetEntityId
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
@@ -82,12 +81,17 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
@@ -95,14 +99,10 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
* widget name, which can differ from the slot name (e.g. after a rename).
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -143,6 +143,18 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
if (!isPromotedWidgetView(widget)) return false
|
||||
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
|
||||
if (!sourceWidget) return false
|
||||
|
||||
const innerWidget = sourceWidget.widget
|
||||
return (
|
||||
('element' in innerWidget && !!innerWidget.element) ||
|
||||
('component' in innerWidget && !!innerWidget.component)
|
||||
)
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
@@ -202,83 +214,73 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
('element' in widget && !!widget.element) ||
|
||||
('component' in widget && !!widget.component)
|
||||
)
|
||||
}
|
||||
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the interior source of a promoted subgraph input to derive the
|
||||
* metadata that backend lookups key by (execution ID, interior widget name)
|
||||
* plus the source widget's control + DOM nature. Also seeds host widget state
|
||||
* if it is somehow missing. Returns undefined when the widget is not promoted.
|
||||
*/
|
||||
function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input?.widgetId) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
const resolved =
|
||||
resolution.status === 'resolved' ? resolution.resolved : undefined
|
||||
const sourceWidget = resolved?.widget
|
||||
const sourceNode = resolved?.node
|
||||
|
||||
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
|
||||
|
||||
return {
|
||||
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
|
||||
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
sourceWidgetName: sourceWidget?.name
|
||||
}
|
||||
}
|
||||
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const duplicateIndexByKey = new Map<string, number>()
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
displayName: widget.name,
|
||||
promotedSource: null
|
||||
}
|
||||
}
|
||||
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
promotedSource
|
||||
}
|
||||
}
|
||||
|
||||
return function (widget) {
|
||||
try {
|
||||
const duplicateKey = `${widget.name}:${widget.type}`
|
||||
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
|
||||
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
const { displayName, promotedSource } =
|
||||
resolvePromotedWidgetIdentity(widget)
|
||||
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo =
|
||||
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
@@ -292,26 +294,67 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const promoted = node.isSubgraphNode()
|
||||
? resolvePromotedMetadata(node, widget)
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const resolvedSourceResult =
|
||||
isPromotedWidgetView(widget) && promotedSource
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
resolvedSourceResult?.status === 'resolved'
|
||||
? resolvedSourceResult.resolved
|
||||
: undefined
|
||||
const sourceWidget = resolvedSource?.widget
|
||||
const sourceNode = resolvedSource?.node
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...getSharedWidgetEnhancements(node, widget),
|
||||
...(promoted?.controlWidget && {
|
||||
controlWidget: promoted.controlWidget
|
||||
}),
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
name,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
|
||||
options: extractWidgetDisplayOptions(widget),
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? {
|
||||
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
canvasOnly: true
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
sourceExecutionId: promoted?.sourceExecutionId,
|
||||
sourceWidgetName: promoted?.sourceWidgetName,
|
||||
tooltip: widget.tooltip
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -327,24 +370,6 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePromotedHostWidgetState(
|
||||
id: WidgetId,
|
||||
input: INodeInputSlot,
|
||||
sourceWidget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!sourceWidget) return
|
||||
const store = useWidgetValueStore()
|
||||
if (store.getWidget(id)) return
|
||||
store.registerWidget(id, {
|
||||
type: sourceWidget.type,
|
||||
value: sourceWidget.value,
|
||||
options: cloneDeep(sourceWidget.options ?? {}),
|
||||
label: input.label ?? input.name,
|
||||
serialize: sourceWidget.serialize,
|
||||
disabled: sourceWidget.disabled
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
@@ -446,16 +471,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
|
||||
const widgets = node.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: (node.widgets ?? [])
|
||||
return widgets.map(safeWidgetMapper(node, slotMetadata))
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
@@ -511,7 +534,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.name)
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,7 +812,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so the label reflects the rename
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
@@ -264,8 +265,16 @@ export function useMoreOptionsMenu() {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
const [widgetName] = hoveredWidget.value ?? []
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [widgetName, nodeId] = hoveredWidget.value ?? []
|
||||
const widget =
|
||||
nodeId !== undefined
|
||||
? node?.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
: node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
const widgetOptions = convertContextMenuToOptions(
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
@@ -43,18 +43,13 @@ export function useKeyboard() {
|
||||
}
|
||||
|
||||
const addListeners = (): void => {
|
||||
// Capture phase: the Mask Editor content root carries `@keydown.stop`
|
||||
// (MaskEditorContent.vue), so a bubble-phase listener never sees keydowns
|
||||
// that originate inside it. Under the Reka dialog the focus trap keeps
|
||||
// focus on an in-editor input, so Ctrl+Z/Y (undo/redo) and the space-pan
|
||||
// blur were swallowed. Capturing runs this before that stopPropagation.
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
const removeListeners = (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
@@ -23,16 +23,21 @@ export function useMaskEditor() {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// `mask-editor-dialog` is a styling-free hook class consumed by
|
||||
// browser_tests (MaskEditorHelper, maskEditor.spec).
|
||||
contentClass: 'mask-editor-dialog w-[90vw] h-[90vh] max-h-[90vh]',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'flex min-h-0 flex-col p-0',
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
@@ -64,43 +54,4 @@ describe('subgraph pricing', () => {
|
||||
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'uses promoted widget override from any matching internal link',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
const apiNode = new ApiNode('api node')
|
||||
apiNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
const apiInput = apiNode.addInput('prompt', 'STRING')
|
||||
apiInput.widget = { name: 'prompt' }
|
||||
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
|
||||
|
||||
const decoyNode = new LGraphNode('decoy node')
|
||||
const decoyInput = decoyNode.addInput('prompt', 'STRING')
|
||||
decoyInput.widget = { name: 'prompt' }
|
||||
decoyNode.addWidget(
|
||||
'string',
|
||||
'prompt',
|
||||
'decoy value',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
|
||||
subgraph.add(decoyNode)
|
||||
subgraph.add(apiNode)
|
||||
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
|
||||
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const inputWidgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
|
||||
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
|
||||
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
|
||||
expect(getBadgeText(subgraphNode)).toBe('outer value')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,14 +2,9 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: { linkIds?: number[] }
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
componentIconSvg.src =
|
||||
@@ -100,20 +95,11 @@ export const usePriceBadge = () => {
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
|
||||
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
|
||||
if (!input.widgetId) continue
|
||||
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
|
||||
const link = wrapper.subgraph.getLink(linkId)
|
||||
if (link?.target_id !== innerNode.id) continue
|
||||
const targetInput = innerNode.inputs[link.target_slot]
|
||||
const widgetName = targetInput?.widget?.name
|
||||
if (!widgetName) continue
|
||||
overrides.set(
|
||||
widgetName,
|
||||
useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
)
|
||||
}
|
||||
const innerId = String(innerNode.id)
|
||||
for (const w of wrapper.widgets ?? []) {
|
||||
if (!isPromotedWidgetView(w)) continue
|
||||
if (w.sourceNodeId !== innerId) continue
|
||||
overrides.set(w.sourceWidgetName, w.value)
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { render } from '@testing-library/vue'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobState } from '@/types/queue'
|
||||
@@ -20,24 +19,32 @@ type TestTask = {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
const createTestI18n = () =>
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: {
|
||||
'en-US': {
|
||||
queue: {
|
||||
jobList: {
|
||||
undated: 'Undated'
|
||||
}
|
||||
},
|
||||
g: {
|
||||
emDash: '--',
|
||||
untitled: 'Untitled'
|
||||
}
|
||||
}
|
||||
const translations: Record<string, string> = {
|
||||
'queue.jobList.undated': 'Undated',
|
||||
'g.emDash': '--',
|
||||
'g.untitled': 'Untitled'
|
||||
}
|
||||
let localeRef: Ref<string>
|
||||
let tMock: ReturnType<typeof vi.fn>
|
||||
const ensureLocaleMocks = () => {
|
||||
if (!localeRef) {
|
||||
localeRef = ref('en-US') as Ref<string>
|
||||
}
|
||||
if (!tMock) {
|
||||
tMock = vi.fn((key: string) => translations[key] ?? key)
|
||||
}
|
||||
return { localeRef, tMock }
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => {
|
||||
ensureLocaleMocks()
|
||||
return {
|
||||
t: tMock,
|
||||
locale: localeRef
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
|
||||
@@ -177,20 +184,13 @@ const createTask = (
|
||||
|
||||
const mountUseJobList = () => {
|
||||
let composable: ReturnType<typeof useJobList>
|
||||
const result = render(
|
||||
{
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
}
|
||||
},
|
||||
{
|
||||
global: {
|
||||
plugins: [createTestI18n()]
|
||||
}
|
||||
const result = render({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
}
|
||||
)
|
||||
})
|
||||
return { ...result, composable: composable! }
|
||||
}
|
||||
|
||||
@@ -215,6 +215,10 @@ const resetStores = () => {
|
||||
totalPercent.value = 0
|
||||
currentNodePercent.value = 0
|
||||
|
||||
ensureLocaleMocks()
|
||||
localeRef.value = 'en-US'
|
||||
tMock.mockClear()
|
||||
|
||||
if (isJobInitializingMock) {
|
||||
vi.mocked(isJobInitializingMock).mockReset()
|
||||
vi.mocked(isJobInitializingMock).mockReturnValue(false)
|
||||
@@ -557,35 +561,6 @@ describe('useJobList', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups terminal jobs without an execution end timestamp by create time', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'failed-before-execution',
|
||||
job: { priority: 1 },
|
||||
mockState: 'failed',
|
||||
createTime: Date.now()
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'completed-without-end-time',
|
||||
job: { priority: 1 },
|
||||
mockState: 'completed',
|
||||
createTime: Date.now() - 1_000
|
||||
})
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
const groups = instance.groupedJobItems.value
|
||||
expect(groups.map((g) => g.label)).toEqual(['Today'])
|
||||
expect(groups[0].items.map((item) => item.id)).toEqual([
|
||||
'failed-before-execution',
|
||||
'completed-without-end-time'
|
||||
])
|
||||
})
|
||||
|
||||
it('groups job items by date label and sorts by total generation time when requested', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
|
||||
@@ -341,7 +341,7 @@ export function useJobList() {
|
||||
for (const { task, state } of searchableTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp ?? task.createTime
|
||||
ts = task.executionEndTimestamp
|
||||
} else {
|
||||
ts = task.createTime
|
||||
}
|
||||
|
||||
@@ -1,94 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCopy } from './useCopy'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const copyMocks = vi.hoisted(() => ({
|
||||
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
|
||||
canvas: {
|
||||
selectedItems: new Set<object>([{}]),
|
||||
copyToClipboard: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn(
|
||||
(
|
||||
_target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: ClipboardEvent) => unknown
|
||||
) => {
|
||||
if (event === 'copy') copyMocks.copyHandler = handler
|
||||
return vi.fn()
|
||||
}
|
||||
/**
|
||||
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
|
||||
*/
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(
|
||||
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: copyMocks.canvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const multiChunkPayloadLength = 0x8000 * 6 + 123
|
||||
|
||||
function copySerializedData(serializedData: string): DataTransfer {
|
||||
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
|
||||
|
||||
useCopy()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
const event = new ClipboardEvent('copy', {
|
||||
clipboardData: dataTransfer
|
||||
})
|
||||
const copyHandler = copyMocks.copyHandler
|
||||
expect(copyHandler).toBeDefined()
|
||||
if (!copyHandler) throw new Error('Expected copy handler to be registered')
|
||||
|
||||
expect(() => copyHandler(event)).not.toThrow()
|
||||
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
|
||||
const match = dataTransfer
|
||||
.getData('text/html')
|
||||
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
expect(match).toBeDefined()
|
||||
if (!match) throw new Error('Expected clipboard metadata to be written')
|
||||
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
|
||||
/**
|
||||
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
|
||||
*/
|
||||
function decodeClipboardData(base64: string): string {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('useCopy', () => {
|
||||
beforeEach(() => {
|
||||
copyMocks.copyHandler = undefined
|
||||
copyMocks.canvas.copyToClipboard.mockReset()
|
||||
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
it('should handle ASCII-only strings', () => {
|
||||
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should write large serialized node data to clipboard metadata', () => {
|
||||
const serializedData = JSON.stringify({
|
||||
it('should handle Chinese characters in localized_name', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Japanese characters', () => {
|
||||
const original = '{"localized_name":"画像を読み込む"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Korean characters', () => {
|
||||
const original = '{"localized_name":"이미지 불러오기"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed ASCII and Unicode characters', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
const original = '{"title":"Test Node 🎨🖼️"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const original = ''
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex node data with multiple Unicode fields', () => {
|
||||
const original = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Subgraph',
|
||||
title: 'Large Subgraph',
|
||||
localized_name: '이미지 그룹 图像 🎨',
|
||||
payload: 'x'.repeat(multiChunkPayloadLength)
|
||||
type: 'LoadImage',
|
||||
localized_name: '图像',
|
||||
inputs: [{ localized_name: '图片', name: 'image' }],
|
||||
outputs: [{ localized_name: '输出', name: 'output' }]
|
||||
}
|
||||
],
|
||||
groups: [{ title: '预处理组 🔧' }],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: []
|
||||
links: []
|
||||
})
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
|
||||
})
|
||||
|
||||
const dataTransfer = copySerializedData(serializedData)
|
||||
it('should produce valid base64 output', () => {
|
||||
const original = '{"localized_name":"中文测试"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
// Base64 should only contain valid characters
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
|
||||
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
|
||||
it('should fail with plain btoa for non-Latin1 characters', () => {
|
||||
const original = '{"localized_name":"图像"}'
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,29 +7,6 @@ const clipboardHTMLWrapper = [
|
||||
'<meta charset="utf-8"><div><span data-metadata="',
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
]
|
||||
const clipboardByteChunkSize = 0x8000
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = []
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < bytes.length;
|
||||
offset += clipboardByteChunkSize
|
||||
) {
|
||||
chunks.push(
|
||||
String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + clipboardByteChunkSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
@@ -46,16 +23,17 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
try {
|
||||
const base64Data = encodeClipboardData(serializedData)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
return false
|
||||
|
||||
@@ -169,7 +169,6 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
|
||||
@@ -169,7 +169,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const sourceFormat = ref<string | null>(null)
|
||||
const canFitToViewer = ref(true)
|
||||
const canCenterCameraOnModel = ref(false)
|
||||
const canUseGizmo = ref(true)
|
||||
@@ -906,7 +905,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
sourceFormat.value = load3d?.getSourceFormat() ?? null
|
||||
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
@@ -1072,7 +1070,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
|
||||
@@ -130,7 +130,6 @@ describe('useLoad3dViewer', () => {
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
@@ -619,7 +618,7 @@ describe('useLoad3dViewer', () => {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: true,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
})
|
||||
@@ -631,7 +630,7 @@ describe('useLoad3dViewer', () => {
|
||||
expect.stringContaining('dropped.splat')
|
||||
)
|
||||
expect(viewer.canUseLighting.value).toBe(false)
|
||||
expect(viewer.canExport.value).toBe(true)
|
||||
expect(viewer.canExport.value).toBe(false)
|
||||
expect(viewer.isSplatModel.value).toBe(true)
|
||||
expect([...viewer.materialModes.value]).toEqual([])
|
||||
})
|
||||
|
||||
@@ -83,7 +83,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const isStandaloneMode = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const sourceFormat = ref<string | null>(null)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
@@ -97,7 +96,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const captureAdapterFlags = (source: Load3d) => {
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
sourceFormat.value = source.getSourceFormat()
|
||||
const caps = source.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps.fitToViewer
|
||||
canUseGizmo.value = caps.gizmoTransform
|
||||
@@ -841,7 +839,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return {
|
||||
name,
|
||||
type: 'INPUT',
|
||||
value,
|
||||
nodeId: '1' as NodeId,
|
||||
options: {},
|
||||
y: 0
|
||||
}
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
@@ -2,9 +2,9 @@ import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -20,8 +21,8 @@ import {
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
@@ -54,15 +55,39 @@ function addInnerNode(
|
||||
return node
|
||||
}
|
||||
|
||||
function getPromotedInputValue(
|
||||
function addPromotedHostInput(
|
||||
host: SubgraphNode,
|
||||
name: string
|
||||
): TWidgetValue | undefined {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) return undefined
|
||||
return useWidgetValueStore().getWidget(input.widgetId)?.value as
|
||||
| TWidgetValue
|
||||
| undefined
|
||||
args: {
|
||||
inputName: string
|
||||
promotedName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
initialValue?: TWidgetValue
|
||||
}
|
||||
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
|
||||
let widgetValue: TWidgetValue = args.initialValue ?? 0
|
||||
const slot = host.addInput(args.inputName, '*')
|
||||
slot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: args.promotedName,
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
},
|
||||
hydrateHostValue(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
return {
|
||||
setValue: (v) => {
|
||||
widgetValue = v
|
||||
},
|
||||
getValue: () => widgetValue
|
||||
}
|
||||
}
|
||||
|
||||
function addPrimitiveWithTargets(
|
||||
@@ -116,6 +141,29 @@ describe('flushProxyWidgetMigration', () => {
|
||||
})
|
||||
|
||||
describe('value-widget repair', () => {
|
||||
it('alreadyLinked: applies host value to the matching promoted widget', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 0
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(99)
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -135,61 +183,23 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(99)
|
||||
expect(host.widgets[0].value).toBe(99)
|
||||
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
|
||||
expect(innerWidget.value).toBe(0)
|
||||
})
|
||||
|
||||
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const firstText = new LGraphNode('CLIPTextEncode')
|
||||
const firstSlot = firstText.addInput('text', 'STRING')
|
||||
firstSlot.widget = { name: 'text' }
|
||||
firstText.addWidget('text', 'text', '11111111111', () => {})
|
||||
innerSubgraph.add(firstText)
|
||||
|
||||
const secondText = new LGraphNode('CLIPTextEncode')
|
||||
const secondSlot = secondText.addInput('text', 'STRING')
|
||||
secondSlot.widget = { name: 'text' }
|
||||
secondText.addWidget('text', 'text', '22222222222', () => {})
|
||||
innerSubgraph.add(secondText)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [
|
||||
[String(firstText.id), 'text'],
|
||||
[String(secondText.id), 'text']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [
|
||||
[String(nestedHost.id), 'text', String(secondText.id)]
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
|
||||
slot.widget = { name: innerWidget.name }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 7
|
||||
})
|
||||
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
const sparse: unknown[] = []
|
||||
@@ -198,7 +208,43 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(7)
|
||||
expect(handle.getValue()).toBe(7)
|
||||
})
|
||||
|
||||
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const a = addPromotedHostInput(host, {
|
||||
inputName: 'first_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 1
|
||||
})
|
||||
const b = addPromotedHostInput(host, {
|
||||
inputName: 'second_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 2
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(a.getValue()).toBe(1)
|
||||
expect(b.getValue()).toBe(2)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'seed'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
@@ -218,25 +264,29 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(created?._widget).toBeDefined()
|
||||
})
|
||||
|
||||
it('createSubgraphInput: preserves the source slot label', () => {
|
||||
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('text', 'STRING')
|
||||
slot.label = 'renamed_from_sidepanel'
|
||||
slot.widget = { name: 'text' }
|
||||
n.addWidget('text', 'text', '', () => {})
|
||||
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
|
||||
const slot1 = n.addInput('text', 'STRING')
|
||||
slot1.widget = { name: 'text' }
|
||||
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
|
||||
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
|
||||
|
||||
const slot2 = n.addInput('text_1', 'STRING')
|
||||
slot2.widget = { name: 'text_1' }
|
||||
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
|
||||
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text']]
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const promotedInput = host.inputs.find((input) => input.name === 'text')
|
||||
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
|
||||
expect(
|
||||
promotedInput?.widgetId
|
||||
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
|
||||
: undefined
|
||||
).toBe('renamed_from_sidepanel')
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
const linkedSlot = inner.inputs.find(
|
||||
(slot) => slot.link === created?.linkIds[0]
|
||||
)
|
||||
expect(linkedSlot?.name).toBe('text_1')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
@@ -311,7 +361,8 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [123]
|
||||
})
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(123)
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(123)
|
||||
})
|
||||
|
||||
it('seeds value from the primitive widget when no host value is supplied', () => {
|
||||
@@ -324,7 +375,8 @@ describe('flushProxyWidgetMigration', () => {
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(11)
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
@@ -422,8 +474,10 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
|
||||
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
|
||||
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
|
||||
const widgetA = hostA.inputs.at(-1)?._widget
|
||||
const widgetB = hostB.inputs.at(-1)?._widget
|
||||
expect(widgetA?.value).toBe(11)
|
||||
expect(widgetB?.value).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
findHostInputForPromotion,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
@@ -28,7 +27,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
@@ -95,24 +93,23 @@ function resolveSourceWidget(
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const input = sourceNode.inputs.find((input) => {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, input.name)
|
||||
if (disambiguatingSourceNodeId) {
|
||||
return (
|
||||
target?.widgetName === sourceWidgetName &&
|
||||
target.nodeId === disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
if (input.name === sourceWidgetName) return true
|
||||
return target?.widgetName === sourceWidgetName
|
||||
})
|
||||
// Store-backed projection for a promoted input on a nested subgraph node:
|
||||
// getSlotFromWidget locates the backing slot by widgetId.
|
||||
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
|
||||
const widgets = sourceNode.widgets
|
||||
if (widgets && disambiguatingSourceNodeId !== undefined) {
|
||||
const byDisambiguator = widgets.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === disambiguatingSourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
if (byDisambiguator) return byDisambiguator
|
||||
// Disambiguator missed: fall back only to non-promoted same-name widgets.
|
||||
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
|
||||
const byName = widgets.find(
|
||||
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
|
||||
)
|
||||
if (byName) return byName
|
||||
}
|
||||
|
||||
const widgets = sourceNode.widgets
|
||||
return (
|
||||
widgets?.find((w) => w.name === sourceWidgetName) ??
|
||||
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
|
||||
@@ -303,6 +300,19 @@ function classify(
|
||||
normalized.sourceWidgetName
|
||||
)
|
||||
if (linkedInput) {
|
||||
const ambiguous =
|
||||
hostNode.inputs.filter((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
!!w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === normalized.sourceNodeId &&
|
||||
w.sourceWidgetName === normalized.sourceWidgetName
|
||||
)
|
||||
}).length > 1
|
||||
if (ambiguous) {
|
||||
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
|
||||
}
|
||||
|
||||
@@ -363,23 +373,19 @@ function classify(
|
||||
}
|
||||
}
|
||||
|
||||
function applyHostValueToInput(
|
||||
input: INodeInputSlot,
|
||||
entry: PendingEntry
|
||||
): boolean {
|
||||
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
|
||||
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
|
||||
}
|
||||
|
||||
function applyHostLabelToInput(
|
||||
input: INodeInputSlot,
|
||||
label: string | undefined
|
||||
): void {
|
||||
if (label === undefined) return
|
||||
input.label = label
|
||||
if (!input.widgetId) return
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (state) state.label = label
|
||||
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
|
||||
if (entry.isHole) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(entry.hostValue)
|
||||
return
|
||||
}
|
||||
console.error(
|
||||
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
|
||||
{ widgetName: widget.name, type: widget.type }
|
||||
)
|
||||
}
|
||||
|
||||
function addUniqueSubgraphInput(
|
||||
@@ -416,9 +422,10 @@ function repairAlreadyLinked(
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
const hostInput = matches[0]
|
||||
if (!applyHostValueToInput(hostInput, entry)) {
|
||||
if (!hostInput._widget) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: hostInput.name }
|
||||
}
|
||||
|
||||
@@ -473,10 +480,11 @@ function repairCreateSubgraphInput(
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (hostInput) {
|
||||
applyHostLabelToInput(hostInput, slot.label)
|
||||
applyHostValueToInput(hostInput, entry)
|
||||
if (!hostInput?._widget) {
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
@@ -641,19 +649,22 @@ function repairPrimitive(
|
||||
return failPrimitive('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply through the host's input mirror (PromotedWidgetView), NOT
|
||||
// `newSubgraphInput._widget`: the interior is shared across hosts.
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (hostInput) {
|
||||
const hostInputWidget = hostInput?._widget
|
||||
if (hostInputWidget) {
|
||||
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
|
||||
if (valueEntry) {
|
||||
applyHostValueToInput(hostInput, valueEntry)
|
||||
applyHostValue(hostInputWidget, valueEntry)
|
||||
} else {
|
||||
const primitiveValue = primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined
|
||||
if (primitiveValue !== undefined) {
|
||||
applyHostValueToInput(hostInput, {
|
||||
applyHostValue(hostInputWidget, {
|
||||
...validated.uniqueEntries[0],
|
||||
hostValue: primitiveValue,
|
||||
isHole: false
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
/**
|
||||
* Where a promoted subgraph input is sourced from inside the subgraph. The
|
||||
* interior node id + widget name that the host input slot forwards to. Resolved
|
||||
* by walking the live link, so it is authoritative derived data — never stored
|
||||
* on the projected widget.
|
||||
*/
|
||||
export interface PromotedSource {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a host input slot, or undefined when the slot is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputSource(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot
|
||||
): PromotedSource | undefined {
|
||||
if (!input.widgetId) return undefined
|
||||
return resolveSubgraphInputTarget(node, input.name)
|
||||
}
|
||||
|
||||
/** The host input slot backing a projected widget, matched by widgetId. */
|
||||
export function inputForWidget(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): INodeInputSlot | undefined {
|
||||
return node.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a widget when it is a promoted subgraph input.
|
||||
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
|
||||
* host node is a subgraph node and its backing input slot has an interior
|
||||
* source.
|
||||
*/
|
||||
export function widgetPromotedSource(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedSource | undefined {
|
||||
if (!node.isSubgraphNode()) return undefined
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input) return undefined
|
||||
return promotedInputSource(node, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a promoted subgraph input into an ordinary widget descriptor. The
|
||||
* descriptor is store-backed: type/value/options read live from
|
||||
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
|
||||
* list does not reactively rebuild — and re-key — on every value edit.
|
||||
*
|
||||
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
|
||||
* `label` is the mutable display label. Returns null when the input is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
|
||||
const id = input.widgetId
|
||||
if (!id) return null
|
||||
const store = useWidgetValueStore()
|
||||
return {
|
||||
get name() {
|
||||
return store.getWidget(id)?.name ?? input.name
|
||||
},
|
||||
get label() {
|
||||
return store.getWidget(id)?.label ?? input.label ?? input.name
|
||||
},
|
||||
set label(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.label = next
|
||||
},
|
||||
get y() {
|
||||
return store.getWidget(id)?.y ?? 0
|
||||
},
|
||||
set y(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.y = next
|
||||
},
|
||||
widgetId: id,
|
||||
get type() {
|
||||
return store.getWidget(id)?.type ?? 'text'
|
||||
},
|
||||
get options() {
|
||||
return store.getWidget(id)?.options ?? {}
|
||||
},
|
||||
get value() {
|
||||
const value = store.getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
},
|
||||
set value(next) {
|
||||
store.setValue(id, next)
|
||||
},
|
||||
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
|
||||
// so the value setter above is never invoked; BaseWidget.setValue writes its
|
||||
// own local state and then calls this callback, which is the only bridge
|
||||
// back to the store.
|
||||
callback(next) {
|
||||
store.setValue(id, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
|
||||
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
return node.inputs.flatMap((input) => {
|
||||
const widget = promotedInputWidget(input)
|
||||
return widget ? [widget] : []
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted promotion's source identity: the interior node + widget a host
|
||||
* subgraph input was promoted from. Used by the migration/schema layer, where
|
||||
* the source is a stored tuple rather than something link-derivable.
|
||||
*/
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly entityId: WidgetEntityId
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
|
||||
ensureHostWidgetState(): void
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
widget: IBaseWidget
|
||||
): widget is PromotedWidgetView {
|
||||
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
|
||||
}
|
||||
|
||||
100
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
100
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function createNumericInteriorNode(initialValue: number) {
|
||||
const node = new LGraphNode('Interior')
|
||||
const input = node.addInput('value', 'number')
|
||||
node.addOutput('out', 'number')
|
||||
|
||||
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
describe('PromotedWidgetView — host-wins semantics', () => {
|
||||
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior, widget: interiorWidget } =
|
||||
createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
|
||||
const viewA = hostA.widgets.find(isPromotedWidgetView)
|
||||
const viewB = hostB.widgets.find(isPromotedWidgetView)
|
||||
if (!viewA || !viewB)
|
||||
throw new Error('Expected promoted views on both hosts')
|
||||
|
||||
viewA.value = 7
|
||||
|
||||
expect(viewA.value).toBe(7)
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
expect(viewB.value).toBe(42)
|
||||
})
|
||||
|
||||
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior } = createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(subgraph.rootGraph.id, {
|
||||
nodeId: String(interior.id),
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 200 })
|
||||
const view = host.widgets.find(isPromotedWidgetView)
|
||||
if (!view) throw new Error('Expected promoted view on host')
|
||||
|
||||
view.value = 99
|
||||
|
||||
const interiorState = widgetStore.getWidget(
|
||||
subgraph.rootGraph.id,
|
||||
String(interior.id),
|
||||
'widget'
|
||||
)
|
||||
expect(interiorState?.value).toBe(42)
|
||||
})
|
||||
})
|
||||
614
src/core/graph/subgraph/promotedWidgetView.ts
Normal file
614
src/core/graph/subgraph/promotedWidgetView.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
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'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
|
||||
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
return 'mouse' in widget && typeof widget.mouse === 'function'
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
private readonly graphId: string
|
||||
private yValue = 0
|
||||
private _computedDisabled = false
|
||||
|
||||
private projectedSourceNode?: LGraphNode
|
||||
private projectedSourceWidget?: IBaseWidget
|
||||
private projectedSourceWidgetType?: IBaseWidget['type']
|
||||
private projectedWidget?: BaseWidget
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
private _lastAutoSeededValue?: IBaseWidget['value']
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
return this.subgraphNode
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId {
|
||||
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this.yValue = value
|
||||
this.syncDomOverride()
|
||||
}
|
||||
|
||||
get computedDisabled(): boolean {
|
||||
return this._computedDisabled
|
||||
}
|
||||
|
||||
set computedDisabled(value: boolean | undefined) {
|
||||
this._computedDisabled = value ?? false
|
||||
}
|
||||
|
||||
get type(): IBaseWidget['type'] {
|
||||
return this.resolveDeepest()?.widget.type ?? 'button'
|
||||
}
|
||||
|
||||
get options(): IBaseWidget['options'] {
|
||||
return this.resolveDeepest()?.widget.options ?? {}
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this.resolveDeepest()?.widget.tooltip
|
||||
}
|
||||
|
||||
get linkedWidgets(): IBaseWidget[] | undefined {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const hostState = this.getHostWidgetState()
|
||||
if (hostState && isWidgetValue(hostState.value)) return hostState.value
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return getWidgetState(this.entityId)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
this._lastAutoSeededValue = undefined
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(value)
|
||||
this._lastAutoSeededValue = undefined
|
||||
}
|
||||
|
||||
ensureHostWidgetState(): void {
|
||||
const fallback = this.fallbackEffectiveValue()
|
||||
const existing = this.getHostWidgetState()
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
this._lastAutoSeededValue !== undefined &&
|
||||
existing.value === this._lastAutoSeededValue &&
|
||||
isWidgetValue(fallback) &&
|
||||
fallback !== existing.value
|
||||
) {
|
||||
existing.value = fallback
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(fallback)
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
|
||||
private fallbackEffectiveValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private registerHostWidgetState(value: IBaseWidget['value']): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
ensureWidgetState(this.entityId, {
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
|
||||
this._boundSlot = this.findBoundSubgraphSlot()
|
||||
this._boundSlotVersion = version
|
||||
return this._boundSlot
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
|
||||
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeLayoutSize = resolved?.widget.computeLayoutSize
|
||||
if (!computeLayoutSize) return undefined
|
||||
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
|
||||
}
|
||||
|
||||
get computeSize(): IBaseWidget['computeSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeSize = resolved?.widget.computeSize
|
||||
if (!computeSize) return undefined
|
||||
return (width?: number) => computeSize.call(resolved.widget, width)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
H: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) {
|
||||
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
|
||||
return
|
||||
}
|
||||
|
||||
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
|
||||
|
||||
const projected = this.getProjectedWidget(resolved)
|
||||
if (!projected || typeof projected.drawWidget !== 'function') return
|
||||
|
||||
const originalY = projected.y
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
const originalLabel = projected.label
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
projected.label = this.label
|
||||
|
||||
try {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
projected.label = originalLabel
|
||||
}
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
pointer: CanvasPointer,
|
||||
_node: LGraphNode,
|
||||
canvas: LGraphCanvas
|
||||
): boolean {
|
||||
const resolved = this.resolveAtHost()
|
||||
if (!resolved) return false
|
||||
|
||||
const interior = resolved.widget
|
||||
if (typeof interior.onPointerDown === 'function') {
|
||||
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
|
||||
if (handled) return true
|
||||
}
|
||||
|
||||
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
|
||||
if (concrete)
|
||||
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
|
||||
|
||||
if (hasLegacyMouse(interior))
|
||||
return this.handleLegacyMouse(pointer, interior)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
callback(
|
||||
value: unknown,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent
|
||||
) {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
afterQueued({
|
||||
isPartialExecution
|
||||
}: { isPartialExecution?: boolean } = {}): void {
|
||||
this.applyValueControlToHost(isPartialExecution)
|
||||
}
|
||||
|
||||
private applyValueControlToHost(isPartialExecution?: boolean): void {
|
||||
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
const next = nextValueForLinkedTarget({
|
||||
target: this,
|
||||
linkedWidgets: resolved?.widget.linkedWidgets,
|
||||
nodeId: this.subgraphNode.id,
|
||||
isPartialExecution
|
||||
})
|
||||
if (next === undefined) return
|
||||
|
||||
this.hydrateHostValue(next)
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
private resolveDeepest():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
|
||||
if (frame !== undefined && this.cachedDeepestFrame === frame)
|
||||
return this.cachedDeepestByFrame
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
if (frame !== undefined) {
|
||||
this.cachedDeepestFrame = frame
|
||||
this.cachedDeepestByFrame = resolved
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}): BaseWidget | undefined {
|
||||
const shouldRebuild =
|
||||
!this.projectedWidget ||
|
||||
this.projectedSourceNode !== resolved.node ||
|
||||
this.projectedSourceWidget !== resolved.widget ||
|
||||
this.projectedSourceWidgetType !== resolved.widget.type
|
||||
|
||||
if (!shouldRebuild) return this.projectedWidget
|
||||
|
||||
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
if (!concrete) {
|
||||
this.projectedWidget = undefined
|
||||
this.projectedSourceNode = undefined
|
||||
this.projectedSourceWidget = undefined
|
||||
this.projectedSourceWidgetType = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
|
||||
this.projectedSourceNode = resolved.node
|
||||
this.projectedSourceWidget = resolved.widget
|
||||
this.projectedSourceWidgetType = resolved.widget.type
|
||||
return this.projectedWidget
|
||||
}
|
||||
|
||||
private bindConcretePointerHandlers(
|
||||
pointer: CanvasPointer,
|
||||
canvas: LGraphCanvas,
|
||||
concrete: BaseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
pointer.onClick = () =>
|
||||
concrete.onClick({
|
||||
e: downEvent,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
pointer.onDrag = (eMove) =>
|
||||
concrete.onDrag?.({
|
||||
e: eMove,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private handleLegacyMouse(
|
||||
pointer: CanvasPointer,
|
||||
interior: LegacyMouseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
const downPosition: Point = [
|
||||
downEvent.canvasX - this.subgraphNode.pos[0],
|
||||
downEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(downEvent, downPosition, this.subgraphNode)
|
||||
|
||||
pointer.finally = () => {
|
||||
const upEvent = pointer.eUp
|
||||
if (!upEvent) return
|
||||
|
||||
const upPosition: Point = [
|
||||
upEvent.canvasX - this.subgraphNode.pos[0],
|
||||
upEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(upEvent, upPosition, this.subgraphNode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private syncDomOverride(
|
||||
resolved:
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined = this.resolveAtHost()
|
||||
) {
|
||||
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
|
||||
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
|
||||
node: this.subgraphNode,
|
||||
widget: this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isBaseDOMWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { id: string } {
|
||||
return 'id' in widget && ('element' in widget || 'component' in widget)
|
||||
}
|
||||
|
||||
function drawDisconnectedPlaceholder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
H: number
|
||||
) {
|
||||
const backgroundColor = readDesignToken(
|
||||
'--color-secondary-background',
|
||||
'#333'
|
||||
)
|
||||
const textColor = readDesignToken('--color-text-secondary', '#999')
|
||||
const fontSize = readDesignToken('--text-2xs', '11px')
|
||||
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(15, y, width - 30, H)
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = `${fontSize} ${fontFamily}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function readDesignToken(token: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
|
||||
const cachedValue = designTokenCache.get(token)
|
||||
if (cachedValue) return cachedValue
|
||||
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
const resolvedValue = value || fallback
|
||||
designTokenCache.set(token, resolvedValue)
|
||||
return resolvedValue
|
||||
}
|
||||
@@ -3,46 +3,22 @@ import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
}) {
|
||||
return host.inputs
|
||||
.filter((input) => input.widgetId)
|
||||
.map((input) => input.name)
|
||||
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
|
||||
}
|
||||
|
||||
function promotedHostWidgetNames(host: { widgets?: IBaseWidget[] }) {
|
||||
return host.widgets?.map((widget) => widget.name) ?? []
|
||||
}
|
||||
|
||||
function writePromotedInputValue(
|
||||
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
name: string,
|
||||
value: IBaseWidget['value']
|
||||
) {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
|
||||
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) throw new Error(`Missing promoted input ${name}`)
|
||||
return widget
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
@@ -55,9 +31,11 @@ import {
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getWidgetName,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
@@ -190,18 +168,15 @@ describe('pruneDisconnected', () => {
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
|
||||
|
||||
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
|
||||
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
})
|
||||
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
|
||||
const keptWidgetId = subgraphNode.inputs.find(
|
||||
(input) => input.name === 'kept'
|
||||
)?.widgetId
|
||||
if (!keptWidgetId) throw new Error('Missing kept widgetId')
|
||||
for (const input of [missingWidgetInput, missingNodeInput]) {
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
)
|
||||
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
|
||||
hostInput.widgetId = keptWidgetId
|
||||
}
|
||||
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: '9999',
|
||||
sourceWidgetName: 'missing-node'
|
||||
})
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -326,25 +301,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the source slot label when promoting a value widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
input.label = 'renamed_from_sidepanel'
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
|
||||
const hostInput = subgraphNode.inputs.find((input) => input.name === 'text')
|
||||
expect(hostInput?.label).toBe('renamed_from_sidepanel')
|
||||
expect(promotedWidgetRef(subgraphNode, 'text').label).toBe(
|
||||
'renamed_from_sidepanel'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -529,45 +485,79 @@ describe('isLinkedPromotion', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
|
||||
const node = new LGraphNode('Source')
|
||||
const input = node.addInput(widgetName, 'STRING')
|
||||
const widget = node.addWidget('text', widgetName, '', () => {})
|
||||
input.widget = { name: widget.name }
|
||||
host.subgraph.add(node)
|
||||
promoteValueWidgetViaSubgraphInput(host, node, widget)
|
||||
return node
|
||||
function linkedWidget(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...extra
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
it('returns true for a linked promotion', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
function createSubgraphWithInputs(count = 1) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: Array.from({ length: count }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: 'STRING' as const
|
||||
}))
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
|
||||
it('returns true when an input has a matching _widget', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no promotion exists', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
it('returns false when no inputs exist or none match', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when sourceWidgetName does not match', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies linked widgets across different inputs', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const nodeA = promoteSource(host, 'string_a')
|
||||
const nodeB = promoteSource(host, 'value')
|
||||
it('returns false when _widget is undefined on input', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies multiple linked widgets across different inputs', () => {
|
||||
const subgraphNode = createSubgraphWithInputs(2)
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
|
||||
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -617,13 +607,17 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
expect(promotedInputNames(host)).toEqual(['first', 'second'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['first', 'second'])
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
|
||||
@@ -643,13 +637,15 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
subgraph.addInput('plain', 'STRING')
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
writePromotedInputValue(host, 'first', 'first value')
|
||||
writePromotedInputValue(host, 'second', 'second value')
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
|
||||
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -731,21 +727,15 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
writePromotedInputValue(host, 'text', 'first value')
|
||||
writePromotedInputValue(host, 'text_1', 'second value')
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
const firstPromotedWidget = promotedWidgetRef(host, 'text')
|
||||
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
secondPromotedWidget,
|
||||
firstPromotedWidget
|
||||
])
|
||||
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'text_1',
|
||||
'text'
|
||||
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['text_1', 'text'])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -785,10 +775,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = 9999
|
||||
const promotedInputId = hostInput.widgetId
|
||||
const promotedViewsBefore = host.widgets.length
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(promotedInputId).toBeDefined()
|
||||
expect(promotedViewsBefore).toBeGreaterThan(0)
|
||||
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
@@ -798,9 +788,13 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
expect(
|
||||
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
|
||||
).toBe(false)
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
|
||||
expect(
|
||||
host.widgets.some(
|
||||
(widget) =>
|
||||
widgetSourceNodeId(widget) === String(interiorNode.id) &&
|
||||
widget.name === interiorWidget.name
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the slot entirely when host slot has no external link', () => {
|
||||
@@ -818,7 +812,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, nodeA, widgetA, nodeB, widgetB } =
|
||||
buildDuplicateNamePromotion()
|
||||
|
||||
demoteWidget(nodeB, widgetB, [host])
|
||||
const promotedViewForB = host.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(promotedViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(nodeB, promotedViewForB!, [host])
|
||||
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
|
||||
@@ -826,19 +825,15 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
})
|
||||
|
||||
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const input of innerHost.inputs) {
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
).toBe(true)
|
||||
}
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
|
||||
@@ -846,7 +841,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
'text_1'
|
||||
])
|
||||
|
||||
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
|
||||
const innerViewForB = innerHost.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(innerViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(innerHost, innerViewForB!, [outerHost])
|
||||
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
|
||||
@@ -863,19 +863,66 @@ describe('disambiguated nested promotion identity', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
subgraph.add(innerHost)
|
||||
function linkedView(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: sourceWidgetName,
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
host,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text_1')
|
||||
).ok
|
||||
).toBe(true)
|
||||
function createSubgraphHost() {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
|
||||
const host = createSubgraphHost()
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
|
||||
const interiorNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner'
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const source = {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(interiorWidget)
|
||||
}
|
||||
|
||||
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
const nestedSubgraphNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner',
|
||||
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
|
||||
} as unknown as LGraphNode
|
||||
subgraph.add(nestedSubgraphNode)
|
||||
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
pruneDisconnected(host)
|
||||
|
||||
@@ -909,13 +956,9 @@ describe('disambiguated nested promotion identity', () => {
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const input of innerHost.inputs) {
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -18,9 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
@@ -48,47 +46,16 @@ export function findHostInputForPromotion(
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
const source = input._subgraphSlot
|
||||
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
|
||||
: undefined
|
||||
const w = input._widget
|
||||
return (
|
||||
source?.sourceNodeId === sourceNodeId &&
|
||||
source.sourceWidgetName === sourceWidgetName
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === sourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePromotionSource(
|
||||
subgraphNode: SubgraphNode,
|
||||
subgraphInput: { linkIds: readonly number[] }
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(subgraphNode.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByName(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedInputNames: readonly string[]
|
||||
@@ -111,12 +78,13 @@ export function reorderSubgraphInputsByName(
|
||||
|
||||
export function reorderSubgraphInputsByWidgetOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
|
||||
orderedWidgets: readonly IBaseWidget[]
|
||||
): void {
|
||||
const remainingIndices = new Set(subgraphNode.inputs.keys())
|
||||
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
|
||||
for (const index of remainingIndices) {
|
||||
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
|
||||
const widget = subgraphNode.inputs[index]?._widget
|
||||
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
|
||||
remainingIndices.delete(index)
|
||||
return [index]
|
||||
}
|
||||
@@ -133,48 +101,37 @@ function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const widgetValues = subgraphNode.inputs.map((input) => {
|
||||
const id = input?.widgetId
|
||||
if (!id) return undefined
|
||||
const value = useWidgetValueStore().getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
})
|
||||
const widgetValues = subgraphNode.inputs.map((input) =>
|
||||
getExplicitHostWidgetValue(input?._widget)
|
||||
)
|
||||
|
||||
reorderSubgraphInputs(subgraphNode, orderedIndices)
|
||||
|
||||
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
|
||||
const value = widgetValues[oldIndex]
|
||||
const id = subgraphNode.inputs[newIndex]?.widgetId
|
||||
if (value === undefined || !id) continue
|
||||
useWidgetValueStore().setValue(id, value)
|
||||
if (value === undefined) continue
|
||||
const widget = subgraphNode.inputs[newIndex]?._widget
|
||||
if (widget) widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function isSamePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputIndex: number,
|
||||
orderedWidget: Pick<IBaseWidget, 'widgetId'>
|
||||
): boolean {
|
||||
const input = subgraphNode.inputs[inputIndex]
|
||||
const linkedInput = input?._subgraphSlot
|
||||
if (!input || !linkedInput) return false
|
||||
function getExplicitHostWidgetValue(
|
||||
widget: IBaseWidget | undefined
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
|
||||
for (const linkId of linkedInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
}
|
||||
|
||||
const { inputNode, input: targetInput } = link.resolve(
|
||||
subgraphNode.subgraph
|
||||
)
|
||||
if (!inputNode || !targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (targetWidget === orderedWidget) return true
|
||||
|
||||
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
|
||||
}
|
||||
|
||||
return false
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewExposed(
|
||||
@@ -211,9 +168,13 @@ function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
const widgetIsParentLevelView =
|
||||
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
sourceWidgetName: widgetIsParentLevelView
|
||||
? widget.sourceWidgetName
|
||||
: getWidgetName(widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,53 +211,15 @@ export function promoteValueWidgetViaSubgraphInput(
|
||||
inputName,
|
||||
String(sourceSlot.type ?? sourceWidget.type ?? '*')
|
||||
)
|
||||
subgraphInput.label = sourceSlot.label
|
||||
const link = subgraphInput.connect(sourceSlot, sourceNode)
|
||||
if (!link) {
|
||||
subgraphNode.subgraph.removeInput(subgraphInput)
|
||||
return { ok: false, reason: 'connectFailed' }
|
||||
}
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot === subgraphInput
|
||||
)
|
||||
if (hostInput) hostInput.label = sourceSlot.label
|
||||
|
||||
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function seedNestedPromotedInputState(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputName: string,
|
||||
sourceSlot: { widgetId?: WidgetId; label?: string }
|
||||
): void {
|
||||
if (!sourceSlot.widgetId) return
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot?.name === inputName
|
||||
)
|
||||
if (!hostInput || hostInput.widgetId) return
|
||||
|
||||
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
|
||||
if (!sourceState) return
|
||||
|
||||
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
|
||||
hostInput.widget ??= { name: inputName }
|
||||
hostInput.widget.name = inputName
|
||||
hostInput.widgetId = id
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: sourceState.type,
|
||||
value: sourceState.value,
|
||||
options: cloneDeep(sourceState.options ?? {}),
|
||||
label: hostInput.label ?? sourceSlot.label ?? inputName,
|
||||
serialize: sourceState.serialize,
|
||||
disabled: sourceState.disabled,
|
||||
isDOMWidget: sourceState.isDOMWidget
|
||||
})
|
||||
}
|
||||
|
||||
function promotePreviewViaExposure(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
@@ -360,32 +283,6 @@ export function promoteWidget(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the host input projecting a linked promotion identified by source.
|
||||
* Returns true when an input was found and demoted.
|
||||
*/
|
||||
export function demotePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
if (!subgraphNode.subgraph) return false
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (!linkedInput) return false
|
||||
|
||||
if (hostInput.link != null) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
subgraphNode.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
@@ -395,7 +292,21 @@ export function demoteWidget(
|
||||
for (const parent of parents) {
|
||||
if (!parent.subgraph) continue
|
||||
|
||||
if (demotePromotedInput(parent, source)) continue
|
||||
const hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
const hasExternalLink = hostInput.link != null
|
||||
if (hasExternalLink) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
parent.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
const previewStore = usePreviewExposureStore()
|
||||
@@ -594,19 +505,37 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
const staleInputs = subgraph.inputs.filter((input) => {
|
||||
const source = resolvePromotionSource(subgraphNode, input)
|
||||
if (source) return false
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
// If the SubgraphInput has any live link to an interior target slot that
|
||||
// still has a widget, the promotion is alive — even when the widget's
|
||||
// sourceNodeId points at a deeply-nested interior node that does not exist
|
||||
// directly in `subgraph` (nested SubgraphNode promotions).
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode) continue
|
||||
const targetInputSlot = inputNode.inputs?.find(
|
||||
(slot) => slot.link === linkId
|
||||
)
|
||||
if (!targetInputSlot) continue
|
||||
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
|
||||
}
|
||||
|
||||
const node = subgraph.getNodeById(widget.sourceNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(widget)
|
||||
return true
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === widget.sourceWidgetName
|
||||
)
|
||||
if (!hostInput?.widgetId && !hostInput?._widget) return false
|
||||
|
||||
removedEntries.push({
|
||||
sourceNodeId: String(subgraphNode.id),
|
||||
sourceWidgetName: input.name
|
||||
})
|
||||
return true
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(widget)
|
||||
}
|
||||
return !hasWidget
|
||||
})
|
||||
|
||||
for (const input of staleInputs) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
@@ -22,6 +24,15 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
type PromotedWidgetStub = Pick<
|
||||
IBaseWidget,
|
||||
'name' | 'type' | 'options' | 'value' | 'y'
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
function createHostNode(id: number): SubgraphNode {
|
||||
return createTestSubgraphNode(createTestSubgraph(), { id })
|
||||
}
|
||||
@@ -36,10 +47,55 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
|
||||
return node.addWidget('text', name, `${name}-value`, () => undefined)
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
type: 'button',
|
||||
options: {},
|
||||
y: 0,
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('resolvePromotedWidgetAtHost', () => {
|
||||
test('resolves a direct concrete widget on the host subgraph node', () => {
|
||||
const host = createHostNode(100)
|
||||
const concreteNode = addNodeToHost(host, 'leaf')
|
||||
addConcreteWidget(concreteNode, 'seed')
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(concreteNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node.id).toBe(concreteNode.id)
|
||||
expect(resolved?.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns undefined when host does not contain the target node', () => {
|
||||
const host = createHostNode(100)
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
test('resolves a direct concrete source widget', () => {
|
||||
const host = createHostNode(100)
|
||||
@@ -58,86 +114,102 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'x', type: '*' }]
|
||||
})
|
||||
const leaf = new LGraphNode('Leaf')
|
||||
const leafInput = leaf.addInput('x', '*')
|
||||
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
leafInput.widget = { name: 'seed' }
|
||||
innerSubgraph.add(leaf)
|
||||
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'y', type: '*' }]
|
||||
})
|
||||
outerSubgraph.add(innerNode)
|
||||
innerNode._internalConfigureAfterSlots()
|
||||
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
|
||||
test('descends through nested promoted widgets to resolve concrete source', () => {
|
||||
const rootHost = createHostNode(100)
|
||||
const nestedHost = createHostNode(101)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'seed')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
outerNode,
|
||||
String(innerNode.id),
|
||||
'x'
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'outer'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(leaf.id)
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
expect(result.resolved.widget.type).toBe('combo')
|
||||
})
|
||||
|
||||
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
|
||||
const recursiveInput = { name: 'x', link: 1 }
|
||||
const recursiveNode = fromAny<LGraphNode, unknown>({
|
||||
id: 11,
|
||||
inputs: [recursiveInput],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
|
||||
getLink: () => ({
|
||||
resolve: () => ({ inputNode: recursiveNode })
|
||||
}),
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
})
|
||||
const host = fromAny<SubgraphNode, unknown>({
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
})
|
||||
test('returns cycle failure when promoted widgets form a loop', () => {
|
||||
const hostA = createHostNode(200)
|
||||
const hostB = createHostNode(201)
|
||||
const relayA = addNodeToHost(hostA, 'relayA')
|
||||
const relayB = addNodeToHost(hostB, 'relayB')
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, '11', 'x')
|
||||
relayA.widgets = [
|
||||
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
|
||||
]
|
||||
relayB.widgets = [
|
||||
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
|
||||
]
|
||||
|
||||
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
|
||||
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'cycle'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for a chain over the traversal limit', () => {
|
||||
const subgraphs = Array.from({ length: 102 }, () =>
|
||||
createTestSubgraph({ inputs: [{ name: 'x', type: '*' }] })
|
||||
test('does not report a cycle when different host objects share an id', () => {
|
||||
const rootHost = createHostNode(41)
|
||||
const nestedHost = createHostNode(41)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'w')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'w'
|
||||
)
|
||||
|
||||
for (let index = 0; index < subgraphs.length - 1; index++) {
|
||||
const current = subgraphs[index]
|
||||
const next = subgraphs[index + 1]
|
||||
const nextNode = createTestSubgraphNode(next, { id: index + 1 })
|
||||
current.add(nextNode)
|
||||
nextNode._internalConfigureAfterSlots()
|
||||
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('w')
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
|
||||
const hosts = Array.from({ length: 102 }, (_, index) =>
|
||||
createHostNode(index + 1)
|
||||
)
|
||||
const relayNodes = hosts.map((host, index) =>
|
||||
addNodeToHost(host, `relay-${index}`)
|
||||
)
|
||||
|
||||
for (let index = 0; index < relayNodes.length - 1; index += 1) {
|
||||
relayNodes[index].widgets = [
|
||||
createPromotedWidget(
|
||||
`w-${index}`,
|
||||
String(relayNodes[index + 1].id),
|
||||
`w-${index + 1}`,
|
||||
hosts[index + 1]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const host = createTestSubgraphNode(subgraphs[0], { id: 200 })
|
||||
addConcreteWidget(
|
||||
relayNodes[relayNodes.length - 1],
|
||||
`w-${relayNodes.length - 1}`
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hosts[0],
|
||||
String(relayNodes[0].id),
|
||||
'w-0'
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, '1', 'x')
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'max-depth-exceeded'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedWidgetResolutionFailure =
|
||||
| 'invalid-host'
|
||||
@@ -40,17 +41,6 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
|
||||
if (!target) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
currentHost = sourceNode
|
||||
currentNodeId = target.nodeId
|
||||
currentWidgetName = target.widgetName
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
@@ -58,15 +48,39 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
if (!isPromotedWidgetView(sourceWidget)) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceWidget.node?.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = node.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
@@ -77,3 +91,20 @@ export function resolveConcretePromotedWidget(
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
@@ -7,7 +9,9 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const key = `widget:${nextWidgetRenderKeyId++}`
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user