Compare commits
25 Commits
cloud/1.45
...
jaewon/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b91a570db | ||
|
|
64d099f6cf | ||
|
|
d05eadba97 | ||
|
|
0157b47024 | ||
|
|
7599c6a1ca | ||
|
|
876ed502c9 | ||
|
|
ff8a19f233 | ||
|
|
c638ad194b | ||
|
|
b166532b24 | ||
|
|
5a7b1d6a90 | ||
|
|
682bd14061 | ||
|
|
5b48bf67a9 | ||
|
|
bbaaa82125 | ||
|
|
601cec68b9 | ||
|
|
8d1a170136 | ||
|
|
08ee925811 | ||
|
|
fb5b4a62ba | ||
|
|
cb62604d21 | ||
|
|
d02c5d374f | ||
|
|
c0ef283a05 | ||
|
|
d405002127 | ||
|
|
abd233d10d | ||
|
|
e1049a99a3 | ||
|
|
3da6e1766e | ||
|
|
52830a9e73 |
@@ -32,12 +32,12 @@
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
pnpm test:unit "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit -- <test-file>
|
||||
pnpm test:unit <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
|
||||
@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit -- --update
|
||||
pnpm test:unit --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
14
AGENTS.md
@@ -307,6 +307,20 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
4
apps/website/public/favicon-dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
apps/website/public/favicon-light.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 615 B |
@@ -3,7 +3,6 @@ const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'EA',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
|
||||
@@ -71,8 +71,18 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["first-host", 11]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [900, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["second-host", 22]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["9999", "missing_widget"]]
|
||||
},
|
||||
"widgets_values": ["quarantined-host-value"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -213,7 +213,8 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment)
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment),
|
||||
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ export class SubgraphEditor {
|
||||
)
|
||||
}
|
||||
|
||||
async open(subgraphNode: Locator) {
|
||||
async ensureOpen(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
if (await this.root.isVisible()) return
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
@@ -69,7 +70,7 @@ export class SubgraphEditor {
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.open(subgraphNode)
|
||||
await this.ensureOpen(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
|
||||
@@ -6,8 +6,9 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
* Widgets are located by `nodeId:widgetName` suffix against the
|
||||
* `data-widget-key` attribute, which carries the canonical
|
||||
* `graphId:nodeId:widgetName` WidgetEntityId.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -20,9 +21,9 @@ export class AppModeWidgetHelper {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
||||
getWidgetItem(key: string): Locator {
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
return this.container.locator(`[data-widget-key$=":${key}"]`)
|
||||
}
|
||||
|
||||
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
||||
|
||||
@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -216,16 +216,6 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
const node = await this.getFirstNodeRef()
|
||||
if (!node) {
|
||||
throw new Error('No nodes found to convert')
|
||||
}
|
||||
await node.clickContextMenuOption('Convert to Group Node')
|
||||
await this.fillPromptDialog(groupNodeName)
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
await this.promptDialogInput.fill(value)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -14,6 +14,8 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
export class SubgraphHelper {
|
||||
public readonly editor: SubgraphEditor
|
||||
@@ -423,39 +425,9 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
return getAllHostPromotedWidgets(this.comfyPage)
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
|
||||
@@ -152,6 +152,7 @@ export const TestIds = {
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
valueControl: 'value-control',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
selectDefaultSearchInput: 'widget-select-default-search-input',
|
||||
|
||||
@@ -511,19 +511,7 @@ export class NodeReference {
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||
}
|
||||
return nodes[0]
|
||||
await this.comfyPage.contextMenu.clickMenuItem(optionText)
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
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'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function previewExposureToEntry(
|
||||
exposure: PreviewExposure
|
||||
): PromotedWidgetEntry {
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
export 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'
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
@@ -78,12 +107,29 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.filter(([, name]) => name === widgetName).length
|
||||
}
|
||||
|
||||
export async function getAllHostPromotedWidgets(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
|
||||
const hostNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => String(node.id))
|
||||
})
|
||||
|
||||
const entries = await Promise.all(
|
||||
hostNodeIds.map(async (hostNodeId) => ({
|
||||
hostNodeId,
|
||||
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
|
||||
}))
|
||||
)
|
||||
|
||||
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-off.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,6 +157,13 @@ test.describe('Signin dialog', () => {
|
||||
})
|
||||
|
||||
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/customers', (route) =>
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'test-user-e2e', email: 'test@example.com' })
|
||||
})
|
||||
)
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
const { result: dialogResult } = await dialog.openWithResult()
|
||||
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_NAME = 'group_node'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
|
||||
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -18,22 +23,19 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
const groupNodeCategory = 'group nodes>workflow'
|
||||
const groupNodeBookmarkName = `workflow>${groupNodeName}`
|
||||
let libraryTab: NodeLibrarySidebarTab
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
libraryTab = comfyPage.menu.nodeLibraryTab
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
@@ -41,9 +43,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Add group node from node library sidebar
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
@@ -52,9 +53,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
@@ -63,13 +64,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([groupNodeBookmarkName])
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
|
||||
// Unbookmark the node
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
@@ -96,72 +96,57 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(groupNodeName)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${groupNodeName}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const pos = await groupNode.getPosition()
|
||||
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const makeGroup = async (name: string, type1: string, type2: string) => {
|
||||
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
|
||||
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
|
||||
await node1.click('title')
|
||||
await node2.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
return await node2.convertToGroupNode(name)
|
||||
}
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
|
||||
const group1 = await makeGroup(
|
||||
'g1',
|
||||
'CLIPTextEncode',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
|
||||
|
||||
const manage1 = await group1.manageGroupNode()
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
|
||||
await manage1.close()
|
||||
await expect(manage1.root).toBeHidden()
|
||||
|
||||
const manage2 = await group2.manageGroupNode()
|
||||
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
@@ -201,42 +186,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
|
||||
expect(nodes).toHaveLength(1)
|
||||
return nodes[0]
|
||||
}
|
||||
const latent = await expectSingleNode('EmptyLatentImage')
|
||||
const sampler = await expectSingleNode('KSampler')
|
||||
// Remove existing link
|
||||
const samplerInput = await sampler.getInput(0)
|
||||
await samplerInput.removeLinks()
|
||||
// Group latent + sampler
|
||||
await latent.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
await sampler.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
const groupNode = await sampler.convertToGroupNode()
|
||||
// Connect node to group
|
||||
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
||||
const input = await ckpt.connectOutput(0, groupNode, 0)
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
// Modify the group node via manage dialog
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await manage.selectNode('KSampler')
|
||||
await manage.changeTab('Inputs')
|
||||
await manage.setLabel('model', 'test')
|
||||
await manage.save()
|
||||
await manage.close()
|
||||
// Ensure the link is still present
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -249,11 +198,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.describe('Copy and paste', () => {
|
||||
let groupNode: NodeReference | null
|
||||
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_PREFIX = 'workflow>'
|
||||
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
|
||||
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
|
||||
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
@@ -282,10 +226,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
@@ -299,10 +243,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Copies and pastes group node after clearing workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set setting
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
// Clear workflow
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.clipboard.paste()
|
||||
@@ -342,24 +283,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Convert to group node, no selection', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
wstest(
|
||||
'Will not use stale litegraph previews',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
)
|
||||
}
|
||||
|
||||
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
await expect.poll(getNodeOutput).toBe('test1.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const resolvableFile = { filename: 'example.png', type: 'input' }
|
||||
executionHelper.executed('', '1', { images: [resolvableFile] })
|
||||
await expect.poll(getNodeOutput).toBe('example.png')
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
await node.imagePreview.hover()
|
||||
await node.imagePreview
|
||||
.getByRole('button', { name: 'Edit or mask image' })
|
||||
.click()
|
||||
|
||||
// On previous versions, attempting to open the mask editor here would
|
||||
// incorrectly reference the non-existant test1.png
|
||||
// This causes the mask editor to throw in setup and not display
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe(
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
@@ -46,15 +46,8 @@ test.describe(
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
|
||||
@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -3,36 +3,40 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
test(
|
||||
'Price badge displays on subgraphs',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 44 KiB |
@@ -35,23 +35,6 @@ test.describe(
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.canvasOps.rightClick()
|
||||
await comfyPage.contextMenu.clickMenuItem(
|
||||
'Convert to Group Node (Deprecated)'
|
||||
)
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
@@ -40,49 +40,19 @@ test.describe(
|
||||
)
|
||||
const [nodeId1, nodeId2] = nodeIds
|
||||
|
||||
// Enter first subgraph, set text widget value
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea1.fill('subgraph1_value')
|
||||
await expect(textarea1).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
const promotedTextarea = (nodeId: string) =>
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
|
||||
// Enter second subgraph, set text widget value
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea2.fill('subgraph2_value')
|
||||
await expect(textarea2).toHaveValue('subgraph2_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await promotedTextarea(nodeId1).fill('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
|
||||
// Re-enter first subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea1Again).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await promotedTextarea(nodeId2).fill('subgraph2_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
|
||||
// Re-enter second subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea2Again).toHaveValue('subgraph2_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test('Deleting the promoted source removes the exterior DOM widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe(
|
||||
'Cleanup Behavior After Promoted Source Removal',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Deleting the promoted source removes the exterior promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
|
||||
|
||||
@@ -34,49 +34,43 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget'] },
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
const OUTER_NODE_ID = '4'
|
||||
const INNER_SUBGRAPH_NODE_ID = '3'
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await comfyExpect(async () => {
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(outerWidgets).toHaveCount(1)
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
const exposedTextWidget = outerNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
|
||||
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
}).toPass({ timeout: 5_000 })
|
||||
const innerNode = comfyPage.vueNodes.getNodeLocator(
|
||||
INNER_SUBGRAPH_NODE_ID
|
||||
)
|
||||
await comfyExpect(innerNode).toBeVisible()
|
||||
|
||||
const innerTextboxes = innerNode.getByRole('textbox')
|
||||
await comfyExpect(innerTextboxes).toHaveCount(2)
|
||||
const innerValues = await innerTextboxes.evaluateAll<
|
||||
string[],
|
||||
HTMLInputElement
|
||||
>((boxes) => boxes.map((b) => b.value))
|
||||
comfyExpect(innerValues).toContain('11111111111')
|
||||
comfyExpect(innerValues).toContain('22222222222')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -96,7 +90,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
@@ -129,7 +122,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeAfter).toBeVisible()
|
||||
@@ -176,7 +168,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -210,7 +201,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -231,7 +221,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -250,7 +239,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -268,7 +256,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
@@ -279,7 +266,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const initialCount = await widgets.count()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
@@ -61,15 +61,12 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select the positive CLIPTextEncode node (id 6)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = String(subgraphNode.id)
|
||||
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||
// should be promoted
|
||||
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
|
||||
})
|
||||
|
||||
@@ -78,7 +75,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -86,7 +82,6 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
String(subgraphNode.id),
|
||||
@@ -95,88 +90,73 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe(
|
||||
'Promoted Widget Visibility in Vue Mode',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
const enterButton = subgraphVueNode.getByTestId(
|
||||
'subgraph-enter-button'
|
||||
)
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
|
||||
test.fail(
|
||||
'Promoted and interior widgets stay in sync across navigation',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
})
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await promotedTextarea.fill(testContent)
|
||||
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Promoted and interior widgets stay in sync across navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
const interiorTextarea = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
.first()
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveValue(updatedInteriorContent)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
@@ -195,7 +175,6 @@ test.describe(
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
@@ -204,10 +183,8 @@ test.describe(
|
||||
await promoteEntry.click()
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
||||
})
|
||||
|
||||
@@ -216,7 +193,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// First promote a canvas-rendered widget (KSampler "steps")
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
@@ -232,7 +208,6 @@ test.describe(
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
// Wait for the context menu to close, confirming the action completed.
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
@@ -280,11 +255,9 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(clipNode).toBeVisible()
|
||||
@@ -317,8 +290,6 @@ test.describe(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
'5',
|
||||
@@ -331,7 +302,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -339,7 +309,6 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||
const nodeId = String(subgraphNode.id)
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
@@ -356,7 +325,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
@@ -393,56 +361,48 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const disabledState = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||
return (node?.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
disabled: !!w.computedDisabled
|
||||
}))
|
||||
})
|
||||
return disabledState.find((w) => w.name === 'string_a')?.disabled
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
const wrapper = textarea.locator('..')
|
||||
const opacity = await wrapper.evaluate(
|
||||
(el) => getComputedStyle(el).opacity
|
||||
test.describe(
|
||||
'Nested Promoted Widget Disabled State',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const linkedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'string_a',
|
||||
exact: true
|
||||
})
|
||||
await expect(linkedTextarea).toBeVisible()
|
||||
await expect(linkedTextarea).toBeDisabled()
|
||||
|
||||
const allTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(allTextareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await allTextareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = allTextareas.nth(i)
|
||||
if (await textarea.isEditable()) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
})
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
@@ -452,16 +412,13 @@ test.describe(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Verify promotions exist
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
|
||||
.toEqual(expect.arrayContaining([expect.anything()]))
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
await expect.poll(() => subgraphNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -520,17 +477,13 @@ test.describe(
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Widget count should be reduced
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
@@ -588,7 +541,6 @@ test.describe(
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
@@ -608,216 +560,198 @@ test.describe(
|
||||
}
|
||||
)
|
||||
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
test(
|
||||
'Promote/Demote by Context Menu',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
})
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
test(
|
||||
'Properties panel operations',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: false
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
})
|
||||
|
||||
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'linked widgets are first by default'
|
||||
).toHaveText('steps')
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'Swap widget order'
|
||||
).toContainText('cfg')
|
||||
|
||||
// FIXME: solve actual bug and remove the not
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'Linked widget is first on node'
|
||||
).not.toHaveText('cfg')
|
||||
})
|
||||
|
||||
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
test(
|
||||
'Link already promoted widget',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Can promote multiple previews',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Linked widgets can not be demoted',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.ensureOpen(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,8 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
@@ -31,133 +29,125 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
|
||||
test.describe(
|
||||
'Subgraph Promotion DOM',
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
await parentTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget preserves content through subgraph enter/exit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
|
||||
})
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const backToPromoted = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(backToPromoted).toBeVisible()
|
||||
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
test('Promoted text widget is removed when subgraph node is deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNodeRef.delete()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
})
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
await expect(visibleWidgets).toHaveCount(2)
|
||||
const parentCount = await visibleWidgets.count()
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(promotedTextareas).toHaveCount(2)
|
||||
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
const interiorTextareas = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox')
|
||||
await expect(interiorTextareas).toHaveCount(2)
|
||||
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,87 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
|
||||
'subgraphs/subgraph-primitive-fanout-multi-host'
|
||||
const UNRESOLVABLE_PROXY_WORKFLOW =
|
||||
'subgraphs/subgraph-unresolvable-proxy-widget'
|
||||
|
||||
interface HostWidgetSnapshot {
|
||||
name: string
|
||||
sourceNodeId: string | null
|
||||
sourceWidgetName: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PrimitiveFanoutSnapshot {
|
||||
hostWidgetNames: string[]
|
||||
hostWidgetValues: HostWidgetSnapshot[]
|
||||
interiorWidgetValues: unknown[]
|
||||
primitiveOutputLinks: unknown
|
||||
primitiveOriginLinkCount: number
|
||||
serializedProperties: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
|
||||
const [primitiveNode] = hostNode.subgraph.findNodesByType(
|
||||
'PrimitiveNode',
|
||||
[]
|
||||
)
|
||||
const primitiveOriginLinkCount = [
|
||||
...hostNode.subgraph._links.values()
|
||||
].filter((link) => link.origin_id === primitiveNode?.id).length
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
|
||||
return {
|
||||
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
|
||||
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
|
||||
name: widget.name,
|
||||
sourceNodeId:
|
||||
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
|
||||
? widget.sourceNodeId
|
||||
: null,
|
||||
sourceWidgetName:
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
? widget.sourceWidgetName
|
||||
: null,
|
||||
value: widget.value
|
||||
})),
|
||||
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
|
||||
(node.widgets ?? []).map((widget) => widget.value)
|
||||
),
|
||||
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const node = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
return node?.properties ?? {}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -41,23 +122,173 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test(
|
||||
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
await expect(beforeReload).toBeVisible()
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
|
||||
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(afterReload).toHaveCount(1)
|
||||
await expect(afterReload).toBeVisible()
|
||||
})
|
||||
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(beforeReload.hostWidgetNames).toContain('value')
|
||||
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgets'
|
||||
)
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgetErrorQuarantine'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
|
||||
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(afterReload.interiorWidgetValues).toEqual(
|
||||
beforeReload.interiorWidgetValues
|
||||
)
|
||||
expect(
|
||||
afterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe(
|
||||
beforeReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
)
|
||||
expect(afterReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(afterReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgets'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Multiple SubgraphNode hosts keep independent migrated widget values',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
|
||||
)
|
||||
|
||||
const expectHostHasIndependentValues = async (
|
||||
hostId: string,
|
||||
stringValue: string,
|
||||
intValue: string
|
||||
) => {
|
||||
const host = comfyPage.vueNodes.getNodeLocator(hostId)
|
||||
const widgets = host.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
|
||||
stringValue
|
||||
)
|
||||
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
|
||||
intValue
|
||||
)
|
||||
}
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Nested preview exposures render through serialized chain resolution',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
test.setTimeout(45_000)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
|
||||
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'8'
|
||||
)
|
||||
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(nestedHostProperties.previewExposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '6',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
|
||||
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
await expect(nestedSubgraphNode).toBeVisible()
|
||||
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
|
||||
.toContain('$$canvas-image-preview')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy unresolvable proxy entry is omitted and quarantined on save',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
|
||||
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host).toBeVisible()
|
||||
await expect(host.getByText('missing_widget')).toHaveCount(0)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
|
||||
.not.toContain('missing_widget')
|
||||
|
||||
const serializedProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'2'
|
||||
)
|
||||
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'missing_widget'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'quarantined-host-value'
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Promoted widget remains usable after serialize and reload',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const beforeReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(afterReload).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
|
||||
comfyPage
|
||||
@@ -413,39 +644,10 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -466,7 +668,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
@@ -482,19 +683,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
await expect(widgetRows.first()).not.toContainText('6: 3:')
|
||||
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -507,25 +507,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
.toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('should convert to group node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'workflow>TestGroupNode'
|
||||
)
|
||||
return groupNodes.length
|
||||
})
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
test('should convert selected nodes to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -111,12 +111,10 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
|
||||
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
|
||||
await controls.decrementButton.click()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
})
|
||||
|
||||
test('displays control widgets with default state', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBoxV2.addNode('Int')
|
||||
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
|
||||
await expect(widget).toBeVisible()
|
||||
|
||||
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
|
||||
await expect(valueControl).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,22 +9,23 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
await expect(loadCheckpointNode).toHaveCount(1)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push(node.widgets![0])
|
||||
node.addWidget('text', 'extra_widget_a', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = node.widgets![0]
|
||||
node.addWidget('text', 'extra_widget_b', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
node.addWidget('text', 'extra_widget_c', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
@@ -52,4 +53,24 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('Can load dynamic combos', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
|
||||
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
|
||||
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
|
||||
|
||||
await test.step('Update value of the dynamic combo widget', async () => {
|
||||
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
|
||||
await expect(widget).toHaveText('scale width')
|
||||
})
|
||||
|
||||
await test.step('Swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await expect(widget).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(widget).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,11 +285,11 @@ quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
`PromotionStore` has been removed. Canonical value-widget exposure is
|
||||
represented by linked `SubgraphInput`s. Canonical preview exposure is
|
||||
represented by host-scoped `properties.previewExposures` /
|
||||
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
|
||||
and must not be reintroduced as runtime authority.
|
||||
|
||||
## Considered options
|
||||
|
||||
@@ -325,4 +325,5 @@ for existing workflow consumers that still assume array order.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
- `PromotionStore` is removed; callers query linked inputs or preview exposures
|
||||
directly.
|
||||
|
||||
@@ -6,16 +6,17 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Six stores extract entity state out of class instances into centralized, queryable registries:
|
||||
Five stores extract entity state out of class instances into centralized,
|
||||
queryable registries. Promoted value-widget topology is no longer a store; ADR
|
||||
0009 represents it as ordinary linked `SubgraphInput` state.
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -99,62 +100,39 @@ graph LR
|
||||
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
|
||||
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
|
||||
|
||||
## 3. PromotionStore
|
||||
## 3. Linked promoted widgets and preview exposures
|
||||
|
||||
**File:** `src/stores/promotionStore.ts`
|
||||
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
|
||||
by linked `SubgraphInput`s, and display-only previews are represented by
|
||||
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
|
||||
Legacy `properties.proxyWidgets` is load-time migration input only.
|
||||
|
||||
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
|
||||
### Runtime shape
|
||||
|
||||
### State Shape
|
||||
```diagram
|
||||
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
|
||||
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
|
||||
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
|
||||
|
||||
```
|
||||
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
|
||||
│ │ │
|
||||
graphId subgraphNodeId ordered promotion entries
|
||||
|
||||
graphRefCounts: Map<UUID, Map<string, number>>
|
||||
│ │ │
|
||||
graphId entryKey count of nodes promoting this widget
|
||||
╭────────────────╮ ╭──────────────────────╮
|
||||
│ Subgraph host │────▶│ PreviewExposureStore │
|
||||
╰────────────────╯ ╰──────────────────────╯
|
||||
```
|
||||
|
||||
### Ref-Counting for O(1) Queries
|
||||
|
||||
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
|
||||
|
||||
```ts
|
||||
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
|
||||
// O(1) lookup: refCounts.get(key) > 0
|
||||
```
|
||||
|
||||
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
|
||||
|
||||
### View Reconciliation Layer
|
||||
|
||||
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
PS["PromotionStore
|
||||
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
|
||||
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
|
||||
(proxy widget)"]
|
||||
PV -->|"resolveDeepest()"| CW["Concrete Widget
|
||||
(leaf node)"]
|
||||
PV -->|"reads value"| WVS["WidgetValueStore"]
|
||||
```
|
||||
|
||||
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
|
||||
`PromotedWidgetViewManager`
|
||||
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
|
||||
synthetic widget views derived from linked subgraph inputs. It does not sit on
|
||||
top of a promotion registry.
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
|
||||
| Ref-counted queries | Yes | Efficient global state queries without scanning |
|
||||
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
|
||||
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
|
||||
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
|
||||
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ----------------------------- | --------- | ------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
|
||||
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
@@ -208,8 +186,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
|
||||
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
|
||||
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
|
||||
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
|
||||
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
@@ -222,7 +200,7 @@ graph TD
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, isPromotedByAny)"]
|
||||
(getWidget, preview exposures)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
(LayoutStore)"]
|
||||
@@ -249,13 +227,12 @@ graph TD
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
@@ -289,7 +266,6 @@ graph TD
|
||||
- value → WidgetValueStore
|
||||
- label → WidgetValueStore
|
||||
- disabled → WidgetValueStore
|
||||
- promotion status → PromotionStore
|
||||
- DOM pos/vis → DomWidgetStore"]
|
||||
W_rem["Remains on class:
|
||||
- _node back-ref
|
||||
@@ -333,7 +309,8 @@ graph TD
|
||||
|
||||
subgraph Subgraph["Subgraph (node component)"]
|
||||
S_ext["Extracted:
|
||||
- promotions → PromotionStore"]
|
||||
- value exposure → linked inputs
|
||||
- preview exposure → PreviewExposureStore"]
|
||||
S_rem["Remains on class:
|
||||
- name, description
|
||||
- inputs[], outputs[]
|
||||
@@ -360,15 +337,15 @@ graph TD
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
|
||||
@@ -250,5 +250,5 @@ interactions (e.g., `comfyPage.settings.setSetting`, `comfyPage.nodeOps`,
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local # Run all E2E tests
|
||||
pnpm test:browser:local -- --ui # Interactive UI mode
|
||||
pnpm test:browser:local --ui # Interactive UI mode
|
||||
```
|
||||
|
||||
@@ -30,7 +30,9 @@ See `docs/testing/*.md` for detailed patterns.
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test:unit # Run all unit tests
|
||||
pnpm test:unit -- path/to/file # Run specific test
|
||||
pnpm test:unit -- --watch # Watch mode
|
||||
pnpm test:unit # Run all unit tests
|
||||
pnpm test:unit path/to/file # Filter by substring of test file path
|
||||
pnpm test:unit foo.test.ts -t "name" # Filter by test name (regex; it()/test() only, not describe())
|
||||
```
|
||||
|
||||
Do not use the `--` separator before vitest args; pnpm forwards extra args automatically, and `--` mangles quoted args (e.g. `-t "two words"`) on Windows PowerShell.
|
||||
|
||||
@@ -43,10 +43,10 @@ To run the tests locally:
|
||||
pnpm test:unit
|
||||
|
||||
# Run a specific test file
|
||||
pnpm test:unit -- src/path/to/file.test.ts
|
||||
pnpm test:unit src/path/to/file.test.ts
|
||||
|
||||
# Run unit tests in watch mode
|
||||
pnpm test:unit -- --watch
|
||||
pnpm test:unit --watch
|
||||
```
|
||||
|
||||
Refer to the specific guides for more detailed information on each testing type.
|
||||
|
||||
@@ -373,7 +373,8 @@ export default defineConfig([
|
||||
files: [
|
||||
'src/base/**/*.{ts,vue}',
|
||||
'src/platform/**/*.{ts,vue}',
|
||||
'src/workbench/**/*.{ts,vue}'
|
||||
'src/workbench/**/*.{ts,vue}',
|
||||
'src/world/**/*.{ts,vue}'
|
||||
],
|
||||
rules: {
|
||||
'import-x/no-restricted-paths': [
|
||||
@@ -401,6 +402,12 @@ export default defineConfig([
|
||||
from: './src/renderer/**',
|
||||
message:
|
||||
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
|
||||
},
|
||||
{
|
||||
target: './src/world/**',
|
||||
from: './src/lib/litegraph/**',
|
||||
message:
|
||||
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.14",
|
||||
"version": "1.46.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -206,8 +206,8 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x",
|
||||
"pnpm": ">=11"
|
||||
"node": ">=25",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.1"
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
}
|
||||
|
||||
@@ -406,9 +406,7 @@
|
||||
--secondary-background-selected
|
||||
);
|
||||
--component-node-widget-background-selected: var(--color-charcoal-100);
|
||||
--component-node-widget-background-disabled: var(
|
||||
--color-alpha-charcoal-600-30
|
||||
);
|
||||
--component-node-widget-background-disabled: var(--color-charcoal-800);
|
||||
--component-node-widget-background-highlighted: var(--color-smoke-800);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-600);
|
||||
|
||||
@@ -8,7 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
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'
|
||||
@@ -28,7 +30,6 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -47,26 +48,8 @@ const hoveringSelectable = ref(false)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
subLabel: t('linearMode.builder.unknownWidget')
|
||||
}
|
||||
}
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
@@ -74,16 +57,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
newLabel: string
|
||||
) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) return
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -102,22 +75,26 @@ function getHovered(
|
||||
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||
}
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
function getNodeBounding(nodeId: NodeId) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
if (!widgetName)
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
if (!widget) return
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
if (entry.status !== 'resolved') return undefined
|
||||
const { node, widget } = entry
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
@@ -133,6 +110,11 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
@@ -157,14 +139,11 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -173,7 +152,7 @@ function nodeToDisplayTuple(
|
||||
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||
return [
|
||||
n.id,
|
||||
getBounding(n.id),
|
||||
getNodeBounding(n.id),
|
||||
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||
]
|
||||
}
|
||||
@@ -191,10 +170,13 @@ const renderedOutputs = computed(() => {
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||
`${nodeId}: ${widgetName}`,
|
||||
getBounding(nodeId, widgetName)
|
||||
])
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
)
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
@@ -238,30 +220,28 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
canRename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.widget.label ?? entry.displayName"
|
||||
:sub-title="entry.node.title"
|
||||
can-rename
|
||||
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
|
||||
@rename="renameWidget(entry.widget, entry.node, $event)"
|
||||
/>
|
||||
<IoItem
|
||||
v-else
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
@@ -23,15 +22,12 @@ import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
persistedHeight: number | undefined
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
@@ -49,31 +45,25 @@ const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
const { onPointerDown } = useAppModeWidgetResizing(
|
||||
(nodeId, widgetName, config) =>
|
||||
appModeStore.updateInputConfig(nodeId, widgetName, config)
|
||||
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
appModeStore.updateInputConfig(widget, config)
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
@@ -82,15 +72,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
return vueWidget.entityId === entityId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -99,9 +81,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeId,
|
||||
widgetName,
|
||||
key: entityId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
@@ -168,14 +148,7 @@ defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{
|
||||
key,
|
||||
nodeId,
|
||||
widgetName,
|
||||
persistedHeight,
|
||||
nodeData,
|
||||
action
|
||||
} in mappedSelections"
|
||||
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
@@ -223,8 +196,7 @@ defineExpose({ handleDragDrop })
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
command: () => appModeStore.removeSelectedInput(action.widget)
|
||||
}
|
||||
]"
|
||||
>
|
||||
@@ -253,7 +225,7 @@ defineExpose({ handleDragDrop })
|
||||
)
|
||||
"
|
||||
:inert="builderMode || undefined"
|
||||
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
|
||||
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
|
||||
|
||||
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
|
||||
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
|
||||
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
|
||||
|
||||
function setHeight(el: HTMLElement, height: number) {
|
||||
Object.defineProperty(el, 'offsetHeight', {
|
||||
value: height,
|
||||
@@ -28,15 +32,13 @@ function wrapWithTextarea(initialHeight = 100): {
|
||||
describe('useAppModeWidgetResizing', () => {
|
||||
function setup() {
|
||||
const onResize =
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
const { onPointerDown } = useAppModeWidgetResizing(onResize)
|
||||
|
||||
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
|
||||
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
|
||||
(e) => onPointerDown(widget, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
}
|
||||
@@ -47,19 +49,19 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists height when textarea is resized via drag', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('does not persist when no height change occurs (e.g. a click)', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -70,7 +72,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists once per drag gesture; stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -86,7 +88,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const button = document.createElement('button')
|
||||
wrapper.appendChild(button)
|
||||
document.body.appendChild(wrapper)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -104,21 +106,21 @@ describe('useAppModeWidgetResizing', () => {
|
||||
wrapper.appendChild(indicator)
|
||||
document.body.appendChild(wrapper)
|
||||
setHeight(indicator, 100)
|
||||
bind(wrapper, 1 as NodeId, 'image')
|
||||
bind(wrapper, WIDGET_IMAGE)
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(indicator, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
|
||||
})
|
||||
|
||||
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const first = wrapWithTextarea()
|
||||
const second = wrapWithTextarea()
|
||||
bind(first.wrapper, 1 as NodeId, 'prompt')
|
||||
bind(second.wrapper, 2 as NodeId, 'other')
|
||||
bind(first.wrapper, WIDGET_PROMPT)
|
||||
bind(second.wrapper, WIDGET_OTHER)
|
||||
|
||||
first.textarea.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
@@ -132,25 +134,25 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
|
||||
})
|
||||
|
||||
it('treats pointercancel as the end of a gesture and persists the new height', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -159,14 +161,12 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
|
||||
const onResize =
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
const scope = effectScope()
|
||||
const { onPointerDown } = scope.run(() =>
|
||||
useAppModeWidgetResizing(onResize)
|
||||
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
|
||||
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
outerIndicator.appendChild(wrapper)
|
||||
document.body.appendChild(outerIndicator)
|
||||
setHeight(outerIndicator, 100)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(outerIndicator, 250)
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
|
||||
|
||||
export function useAppModeWidgetResizing(
|
||||
onResize: (
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
config: InputWidgetConfig
|
||||
) => void
|
||||
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
|
||||
) {
|
||||
let pendingHandler: (() => void) | null = null
|
||||
|
||||
@@ -23,11 +19,7 @@ export function useAppModeWidgetResizing(
|
||||
|
||||
onScopeDispose(clearPendingHandler)
|
||||
|
||||
function onPointerDown(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
event: PointerEvent
|
||||
) {
|
||||
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
|
||||
const wrapper = event.currentTarget
|
||||
const target = event.target
|
||||
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
|
||||
@@ -44,7 +36,7 @@ export function useAppModeWidgetResizing(
|
||||
pendingHandler = null
|
||||
const height = resizable.offsetHeight
|
||||
if (height === startHeight) return
|
||||
onResize(nodeId, widgetName, { height })
|
||||
onResize(widget, { height })
|
||||
}
|
||||
pendingHandler = handler
|
||||
window.addEventListener('pointerup', handler)
|
||||
|
||||
91
src/components/builder/useResolvedSelectedInputs.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
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 { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
nodes: [] as LGraphNode[],
|
||||
events: new EventTarget(),
|
||||
getNodeById: vi.fn() as (id: number) => LGraphNode | null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id) => nodes.find((n) => String(n.id) === String(id)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function dispatchRootGraphEvent(type: string) {
|
||||
;(app.rootGraph!.events as unknown as EventTarget).dispatchEvent(
|
||||
new Event(type)
|
||||
)
|
||||
}
|
||||
|
||||
describe('useResolvedSelectedInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
setRootGraphNodes([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('re-resolves selections after a convert-to-subgraph event removes nodes from the root graph', () => {
|
||||
const node = makeNode(1, ['seed'])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
expect(resolved.value[0]?.status).toBe('resolved')
|
||||
|
||||
setRootGraphNodes([])
|
||||
dispatchRootGraphEvent('convert-to-subgraph')
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('re-resolves selections after a subgraph-created event removes nodes from the root graph', () => {
|
||||
const node = makeNode(1, ['seed'])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
expect(resolved.value[0]?.status).toBe('resolved')
|
||||
|
||||
setRootGraphNodes([])
|
||||
dispatchRootGraphEvent('subgraph-created')
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
})
|
||||
71
src/components/builder/useResolvedSelectedInputs.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
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 { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
|
||||
export function useResolvedSelectedInputs() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>([...(app.rootGraph?.nodes ?? [])])
|
||||
const refreshGraphNodes = () =>
|
||||
(graphNodes.value = [...(app.rootGraph?.nodes ?? [])])
|
||||
useEventListener(() => app.rootGraph?.events, 'configured', refreshGraphNodes)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'convert-to-subgraph',
|
||||
refreshGraphNodes
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'subgraph-created',
|
||||
refreshGraphNodes
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'node:slot-label:changed',
|
||||
() => triggerRef(graphNodes)
|
||||
)
|
||||
|
||||
return computed<ResolvedSelection[]>(() => {
|
||||
void graphNodes.value
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -11,17 +11,39 @@
|
||||
:aria-label="$t('load3d.switchCamera')"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
|
||||
<i class="pi pi-camera text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
|
||||
@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
te: vi.fn(() => false),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback),
|
||||
t: vi.fn((key: string, params?: { count?: number }) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = params?.count ?? 0
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
return key
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'errorCatalog.validationErrors.required_input_missing.title':
|
||||
'Missing connection',
|
||||
'errorCatalog.validationErrors.required_input_missing.message':
|
||||
'Required input slots have no connection feeding them.',
|
||||
'errorCatalog.validationErrors.required_input_missing.details':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.itemLabel':
|
||||
'{nodeName} - {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastTitle':
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
|
||||
const interpolate = (
|
||||
message: string,
|
||||
params?: Record<string, string | number>
|
||||
) =>
|
||||
message.replace(/\{(\w+)\}/g, (match, paramName) =>
|
||||
params?.[paramName] === undefined ? match : String(params[paramName])
|
||||
)
|
||||
|
||||
return {
|
||||
te: vi.fn((key: string) => key in messages),
|
||||
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
|
||||
t: vi.fn((key: string, params?: Record<string, string | number>) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = Number(params?.count ?? 0)
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
|
||||
return interpolate(messages[key] ?? key, params)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
@@ -412,10 +442,16 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
if (execGroups[0].type !== 'execution') return
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
|
||||
'KSampler failed'
|
||||
)
|
||||
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
details: 'line 1\nline 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
})
|
||||
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
|
||||
// bypass catalog display fields until targeted runtime handling lands.
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
@@ -428,7 +464,8 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const resolvedDisplay = resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: e.node_type,
|
||||
isCloud
|
||||
})
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
@@ -433,8 +427,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolvedDisplay
|
||||
exceptionType: e.exception_type
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
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 { usePromotionStore } from '@/stores/promotionStore'
|
||||
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 { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -54,12 +64,74 @@ const {
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
reorder: [event: { fromIndex: number; toIndex: number }]
|
||||
}>()
|
||||
|
||||
const widgetsContainer = ref<HTMLElement>()
|
||||
const rootElement = ref<HTMLElement>()
|
||||
|
||||
const widgets = shallowRef(widgetsProp)
|
||||
watchEffect(() => (widgets.value = widgetsProp))
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>()
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
draggableList.value?.dispose()
|
||||
draggableList.value = undefined
|
||||
|
||||
if (!isMounted.value || !isDraggable || collapse.value) return
|
||||
const container = widgetsContainer.value
|
||||
if (!container?.children?.length) return
|
||||
|
||||
const list = new DraggableList(container, '.draggable-item')
|
||||
|
||||
list.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[SectionWidgets] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
if (typeof reorderedItems[index] === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
emit('reorder', { fromIndex: oldPosition, toIndex: newPosition })
|
||||
}
|
||||
|
||||
draggableList.value = list
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
[widgets, () => isDraggable, collapse],
|
||||
() => setDraggableState(),
|
||||
{ debounce: 100, immediate: true }
|
||||
)
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -70,8 +142,6 @@ const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
@@ -83,13 +153,12 @@ function isWidgetShownOnParents(
|
||||
? widget.sourceNodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
})
|
||||
}
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: String(widgetNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef
|
||||
} from 'vue'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -25,8 +16,6 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
|
||||
const isSearching = ref(false)
|
||||
|
||||
const favoritedWidgets = computed(
|
||||
@@ -48,70 +37,20 @@ async function searcher(query: string) {
|
||||
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
function handleReorder({
|
||||
fromIndex,
|
||||
toIndex
|
||||
}: {
|
||||
fromIndex: number
|
||||
toIndex: number
|
||||
}) {
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [moved] = widgets.splice(fromIndex, 1)
|
||||
if (!moved) return
|
||||
widgets.splice(toIndex, 0, moved)
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [widget] = widgets.splice(oldPosition, 1)
|
||||
widgets.splice(newPosition, 0, widget)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchedFavoritedWidgets,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
|
||||
function onCollapseUpdate() {
|
||||
// Rebuild draggable list after the section header is toggled
|
||||
nextTick(setDraggableState)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -127,7 +66,6 @@ function onCollapseUpdate() {
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:label
|
||||
:widgets="searchedFavoritedWidgets"
|
||||
:is-draggable="!isSearching"
|
||||
@@ -135,7 +73,7 @@ function onCollapseUpdate() {
|
||||
show-node-name
|
||||
enable-empty-state
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="onCollapseUpdate"
|
||||
@reorder="handleReorder"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
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'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
@@ -33,7 +26,6 @@ const { node } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -51,13 +43,33 @@ const isAllCollapsed = computed({
|
||||
advancedInputsCollapsed.value = collapse
|
||||
}
|
||||
})
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
const promotionEntries = computed(() =>
|
||||
promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
)
|
||||
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,
|
||||
@@ -81,37 +93,7 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const entries = promotionEntries.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
const result: NodeWidgetsList = []
|
||||
for (const {
|
||||
sourceNodeId: entryNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
} of entries) {
|
||||
const widget = widgets.find((w) => {
|
||||
if (isPromotedWidgetView(w)) {
|
||||
if (
|
||||
String(w.sourceNodeId) !== entryNodeId ||
|
||||
w.sourceWidgetName !== sourceWidgetName
|
||||
)
|
||||
return false
|
||||
|
||||
if (!disambiguatingSourceNodeId) return true
|
||||
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
return w.name === sourceWidgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
}
|
||||
}
|
||||
return result
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
@@ -126,12 +108,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
!isWidgetPromotedOnSubgraphNode(node, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -146,66 +125,22 @@ async function searcher(query: string) {
|
||||
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
function handleReorder({
|
||||
fromIndex,
|
||||
toIndex
|
||||
}: {
|
||||
fromIndex: number
|
||||
toIndex: number
|
||||
}) {
|
||||
const widgets = searchedWidgetsList.value.map((row) => row.widget)
|
||||
const [moved] = widgets.splice(fromIndex, 1)
|
||||
if (!moved) return
|
||||
widgets.splice(toIndex, 0, moved)
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[TabSubgraphInputs] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
reorderSubgraphInputsByWidgetOrder(node, widgets)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
|
||||
debounce: 100
|
||||
})
|
||||
onMounted(() => setDraggableState())
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
const label = computed(() => {
|
||||
return searchedWidgetsList.value.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
@@ -229,7 +164,6 @@ const label = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:collapse="firstSectionCollapsed && !isSearching"
|
||||
:node
|
||||
:label
|
||||
@@ -243,12 +177,8 @@ const label = computed(() => {
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="
|
||||
(v) => {
|
||||
firstSectionCollapsed = v
|
||||
nextTick(setDraggableState)
|
||||
}
|
||||
"
|
||||
@update:collapse="(v) => (firstSectionCollapsed = v)"
|
||||
@reorder="handleReorder"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -9,15 +9,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
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 { usePromotionStore } from '@/stores/promotionStore'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
|
||||
demoteWidget: vi.fn(),
|
||||
promoteWidget: vi.fn(),
|
||||
isLinkedPromotion: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
getInputSpecForWidget: mockGetInputSpecForWidget
|
||||
@@ -201,64 +205,4 @@ describe('WidgetActions', () => {
|
||||
|
||||
expect(onResetToDefault).toHaveBeenCalledWith('option1')
|
||||
})
|
||||
|
||||
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
})
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
value: 'value',
|
||||
label: 'Text',
|
||||
options: {},
|
||||
y: 0,
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
} as IBaseWidget
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Text',
|
||||
parents: [parentSubgraphNode],
|
||||
isShownOnParents: true
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Hide input/ }))
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
@@ -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 { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -43,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -82,16 +79,19 @@ function handleHideInput() {
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
for (const parent of parents) {
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId:
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id),
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
}
|
||||
promotionStore.demote(parent.rootGraph.id, parent.id, source)
|
||||
parent.computeSize(parent.size)
|
||||
const sourceNodeId =
|
||||
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 {
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
|
||||
@@ -4,7 +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/resolvePromotedWidgetSource'
|
||||
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'
|
||||
|
||||
265
src/components/rightSidePanel/subgraph/SubgraphEditor.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
|
||||
type DraggableListProps = ComponentProps<typeof DraggableList>
|
||||
type PromotedRow =
|
||||
DraggableListProps['modelValue'] extends Array<infer T> ? T : never
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subgraphStore: {
|
||||
shown: 'Shown',
|
||||
hidden: 'Hidden',
|
||||
hideAll: 'Hide all',
|
||||
showAll: 'Show all',
|
||||
addRecommended: 'Add recommended'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noneSearchDesc: 'No results'
|
||||
},
|
||||
g: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Search'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SubgraphEditor', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders preview exposures after promoted inputs without drag handles', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.add(previewNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
usePreviewExposureStore().addExposure(
|
||||
subgraph.rootGraph.id,
|
||||
String(host.id),
|
||||
{
|
||||
sourceNodeId: String(previewNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
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 shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second', '$$canvas-image-preview'])
|
||||
expect(
|
||||
within(screen.getByTestId('draggable-list'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
expect(
|
||||
within(shown).getAllByTestId('subgraph-widget-drag-handle')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('updates rendered order when promoted widgets are reordered', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
let listSetter: ((value: PromotedRow[]) => void) | undefined
|
||||
const draggableListStub = {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
_: unknown,
|
||||
{
|
||||
emit,
|
||||
slots
|
||||
}: {
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
slots: { default?: (props: { dragClass: string }) => unknown }
|
||||
}
|
||||
) {
|
||||
listSetter = (value) => emit('update:modelValue', value)
|
||||
return () => slots.default?.({ dragClass: 'draggable-item' })
|
||||
}
|
||||
}
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { DraggableList: draggableListStub }
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
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()
|
||||
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
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>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const orphanedSourceNode = new LGraphNode('OrphanedNode')
|
||||
orphanedSourceNode.type = 'OrphanedNode'
|
||||
subgraph.add(orphanedSourceNode)
|
||||
|
||||
const previewStore = usePreviewExposureStore()
|
||||
previewStore.addExposure(subgraph.rootGraph.id, String(host.id), {
|
||||
sourceNodeId: String(orphanedSourceNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
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>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
|
||||
).toHaveLength(1)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const toggleButton = within(shown).getByTestId('subgraph-widget-toggle')
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
expect(
|
||||
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
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'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
widget: PromotedWidgetView
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
node: LGraphNode
|
||||
exposure: PreviewExposure
|
||||
realWidget?: IBaseWidget
|
||||
}
|
||||
type ActiveRow = PromotedRow | PreviewRow
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const promotionEntries = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
})
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
if (node instanceof SubgraphNode) return node
|
||||
return undefined
|
||||
})
|
||||
|
||||
const activeWidgets = computed<WidgetItem[]>({
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
}
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
'widget-promoted',
|
||||
'widget-demoted',
|
||||
'input-added',
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedWidgets
|
||||
)
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
|
||||
return promotionEntries.value.flatMap(
|
||||
({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}): WidgetItem[] => {
|
||||
if (sourceNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
|
||||
if (!widget) return []
|
||||
return [
|
||||
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
|
||||
]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
|
||||
if (!wNode) return []
|
||||
const widget = getPromotableWidgets(wNode).find((w) => {
|
||||
if (w.name !== sourceWidgetName) return false
|
||||
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
return true
|
||||
})
|
||||
if (!widget) return []
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
)
|
||||
return node ? getActivePromotedRows(node) : []
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => ({
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
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
|
||||
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
|
||||
return exposures.flatMap((exposure): PreviewRow[] => {
|
||||
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
const realWidget = getPromotableWidgets(sourceNode).find(
|
||||
(candidate) => candidate.name === exposure.sourcePreviewName
|
||||
)
|
||||
return [{ kind: 'preview', node: sourceNode, exposure, realWidget }]
|
||||
})
|
||||
}
|
||||
|
||||
function updateActivePromotedRows(
|
||||
value: PromotedRow[],
|
||||
currentItems: PromotedRow[]
|
||||
) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
const currentKeys = new Set(currentItems.map(promotedRowKey))
|
||||
const nextKeys = new Set(value.map(promotedRowKey))
|
||||
for (const item of value) {
|
||||
if (!currentKeys.has(promotedRowKey(item))) promotePromotedRow(item)
|
||||
}
|
||||
for (const item of currentItems) {
|
||||
if (!nextKeys.has(promotedRowKey(item))) demoteRow(item)
|
||||
}
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => row.widget)
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
|
||||
const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
@@ -115,18 +150,18 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
|
||||
return interiorWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
@@ -145,16 +180,31 @@ const recommendedWidgets = computed(() => {
|
||||
return filteredCandidates.value.filter(isRecommendedWidget)
|
||||
})
|
||||
|
||||
const filteredActive = computed<WidgetItem[]>(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
if (!query) return activeWidgets.value
|
||||
return activeWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
n.title.toLowerCase().includes(query) ||
|
||||
w.name.toLowerCase().includes(query)
|
||||
function rowMatchesQuery(row: ActiveRow, query: string): boolean {
|
||||
return (
|
||||
row.node.title.toLowerCase().includes(query) ||
|
||||
rowDisplayName(row).toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const filteredActive = computed<ActiveRow[]>(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
if (!query) return activeRows.value
|
||||
return activeRows.value.filter((row) => rowMatchesQuery(row, query))
|
||||
})
|
||||
|
||||
const filteredActivePromoted = computed<PromotedRow[]>(() =>
|
||||
filteredActive.value.filter(
|
||||
(row): row is PromotedRow => row.kind === 'promoted'
|
||||
)
|
||||
)
|
||||
|
||||
const filteredActivePreviews = computed<PreviewRow[]>(() =>
|
||||
filteredActive.value.filter(
|
||||
(row): row is PreviewRow => row.kind === 'preview'
|
||||
)
|
||||
)
|
||||
|
||||
function refreshPromotedWidgetRendering() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
@@ -164,57 +214,89 @@ function refreshPromotedWidgetRendering() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function isItemLinked([node, widget]: WidgetItem): boolean {
|
||||
function rowDisplayName(row: ActiveRow): string {
|
||||
if (row.kind === 'promoted') {
|
||||
return row.widget.label || row.widget.name
|
||||
}
|
||||
return (
|
||||
node.id === -1 ||
|
||||
(!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
))
|
||||
row.realWidget?.label ||
|
||||
row.realWidget?.name ||
|
||||
row.exposure.sourcePreviewName
|
||||
)
|
||||
}
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
const sid = getSourceNodeId(item[1])
|
||||
return sid
|
||||
? `${item[0].id}: ${item[1].name}:${sid}`
|
||||
: `${item[0].id}: ${item[1].name}`
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? promotedRowKey(row)
|
||||
: `${row.node.id}: ${row.exposure.name}`
|
||||
}
|
||||
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w) => [n, w])
|
||||
}
|
||||
function demote([node, widget]: WidgetItem) {
|
||||
|
||||
function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
demoteWidget(node, widget, [subgraphNode])
|
||||
if (row.kind === 'promoted') {
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
demoteWidget(row.node, row.realWidget, [subgraphNode])
|
||||
return
|
||||
}
|
||||
previewExposureStore.removeExposure(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id),
|
||||
row.exposure.name
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
function promote([node, widget]: WidgetItem) {
|
||||
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
for (const item of filteredCandidates.value) {
|
||||
promote(item)
|
||||
promoteCandidate(item)
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
for (const item of filteredActive.value) {
|
||||
if (String(item[0].id) === '-1') continue
|
||||
if (
|
||||
node &&
|
||||
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
|
||||
)
|
||||
continue
|
||||
demote(item)
|
||||
for (const row of filteredActive.value) {
|
||||
if (String(row.node.id) === '-1') continue
|
||||
demoteRow(row)
|
||||
}
|
||||
}
|
||||
function showRecommended() {
|
||||
for (const item of recommendedWidgets.value) {
|
||||
promote(item)
|
||||
promoteCandidate(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,19 +341,34 @@ onMounted(() => {
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activePromotedRows">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
:data-nodeid="node.id"
|
||||
v-for="row in filteredActivePromoted"
|
||||
:key="rowKey(row)"
|
||||
:data-nodeid="row.node.id"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.label || widget.name"
|
||||
:is-physical="isItemLinked([node, widget])"
|
||||
:node-title="row.node.title"
|
||||
:widget-name="rowDisplayName(row)"
|
||||
:is-physical="isRowLinked(row)"
|
||||
:is-draggable="!searchQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
is-shown
|
||||
@toggle-visibility="demoteRow(row)"
|
||||
/>
|
||||
</DraggableList>
|
||||
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
|
||||
<SubgraphNodeWidget
|
||||
v-for="row in filteredActivePreviews"
|
||||
:key="rowKey(row)"
|
||||
:data-nodeid="row.node.id"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="row.node.title"
|
||||
:widget-name="rowDisplayName(row)"
|
||||
:is-physical="isRowLinked(row)"
|
||||
:is-draggable="false"
|
||||
is-shown
|
||||
@toggle-visibility="demoteRow(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -295,12 +392,12 @@ onMounted(() => {
|
||||
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
:key="`${node.id}:${widget.name}`"
|
||||
:data-nodeid="node.id"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
@toggle-visibility="promoteCandidate([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,22 +10,22 @@ const {
|
||||
widgetName,
|
||||
isDraggable = false,
|
||||
isPhysical = false,
|
||||
isShown = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
nodeTitle: string
|
||||
widgetName: string
|
||||
isDraggable?: boolean
|
||||
isPhysical?: boolean
|
||||
isShown?: boolean
|
||||
class?: ClassValue
|
||||
}>()
|
||||
defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
}>()
|
||||
defineEmits<{ toggleVisibility: [] }>()
|
||||
|
||||
const icon = computed(() =>
|
||||
isPhysical
|
||||
? 'icon-[lucide--link]'
|
||||
: isDraggable
|
||||
: isShown
|
||||
? 'icon-[lucide--eye]'
|
||||
: 'icon-[lucide--eye-off]'
|
||||
)
|
||||
@@ -65,6 +65,7 @@ const icon = computed(() =>
|
||||
</Button>
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
data-testid="subgraph-widget-drag-handle"
|
||||
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
filters: { type: Array, default: () => [] },
|
||||
defaultRootFilter: { type: String, default: null }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
template:
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
@@ -276,4 +281,75 @@ describe('NodeSearchBoxPopover', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
}
|
||||
|
||||
async function openSearch() {
|
||||
useSearchBoxStore().visible = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('defaults to Essentials when the graph is empty', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to Essentials when the canvas is not yet available', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to null when the graph has nodes', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-evaluates each time the dialog opens', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
|
||||
useSearchBoxStore().visible = false
|
||||
await nextTick()
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -76,6 +77,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
@@ -87,6 +90,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
@@ -102,6 +106,13 @@ const enableNodePreview = computed(
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
? RootCategory.Essentials
|
||||
: null
|
||||
})
|
||||
function getNewNodeLocation(): Point {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
@@ -126,7 +137,6 @@ function clearFilters() {
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply defaultRootFilter when provided and category is available', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'FrequentNode',
|
||||
display_name: 'Frequent Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
|
||||
@@ -141,8 +141,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -193,8 +194,12 @@ function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(
|
||||
defaultRootFilter === RootCategory.Essentials &&
|
||||
!nodeAvailability.value.essential
|
||||
? null
|
||||
: defaultRootFilter
|
||||
)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -245,9 +245,7 @@ const MENU_ORDER: string[] = [
|
||||
'Paste Image',
|
||||
'Save Image',
|
||||
'Copy (Clipspace)',
|
||||
'Paste (Clipspace)',
|
||||
// Fallback for other core items
|
||||
'Convert to Group Node (Deprecated)'
|
||||
'Paste (Clipspace)'
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -16,7 +17,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
@@ -102,12 +102,15 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
// Associate the input slot with the widget (as widgetInputs extension does)
|
||||
input.widget = { name: 'prompt' }
|
||||
|
||||
// Start with a connected link
|
||||
input.link = 42
|
||||
|
||||
graph.add(node)
|
||||
return { graph, node }
|
||||
|
||||
const upstream = new LGraphNode('upstream')
|
||||
upstream.addOutput('out', 'STRING')
|
||||
graph.add(upstream)
|
||||
const link = upstream.connect(0, node, 0)
|
||||
if (!link) throw new Error('Expected upstream.connect to produce a link')
|
||||
|
||||
return { graph, node, upstream, linkId: link.id }
|
||||
}
|
||||
|
||||
it('sets slotMetadata.linked to true when input has a link', () => {
|
||||
@@ -187,7 +190,28 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
|
||||
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'prompt', type: 'STRING' }]
|
||||
})
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(input, node)
|
||||
if (!link)
|
||||
throw new Error('Expected SubgraphInput.connect to produce a link')
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(subgraph)
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -207,7 +231,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
undefined,
|
||||
'value'
|
||||
)
|
||||
|
||||
@@ -218,7 +241,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
input.link = 42
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
@@ -229,21 +251,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
|
||||
// Disconnect
|
||||
hostNode.inputs[0].link = null
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: hostNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
@@ -403,37 +411,6 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = vueNode?.widgets?.find(
|
||||
(widget) => widget.name === '$$canvas-image-preview'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.options?.canvasOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nested promoted widget mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -471,122 +448,49 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.storeName).toBe('picker')
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
inputs: [
|
||||
{ name: 'first_seed', type: '*' },
|
||||
{ name: 'second_seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined)
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
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[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
|
||||
const graph = outerSubgraphNode.graph as LGraph
|
||||
graph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'text'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ 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 { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
@@ -23,9 +25,12 @@ import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
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 type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -55,10 +60,9 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
entityId?: WidgetEntityId
|
||||
nodeId?: NodeId
|
||||
storeNodeId?: NodeId
|
||||
name: string
|
||||
storeName?: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
@@ -154,9 +158,7 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
|
||||
if (!cagWidget) return
|
||||
return {
|
||||
value: normalizeControlOption(cagWidget.value),
|
||||
@@ -229,18 +231,15 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(inputName: string): {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
} | null {
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName,
|
||||
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,10 +257,9 @@ function safeWidgetMapper(
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource = {
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
@@ -308,8 +306,7 @@ function safeWidgetMapper(
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName,
|
||||
promotedSource.disambiguatingSourceNodeId
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
@@ -322,24 +319,21 @@ function safeWidgetMapper(
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(
|
||||
sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId
|
||||
)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = storeName ?? displayName
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
storeNodeId: nodeId,
|
||||
name,
|
||||
storeName,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
@@ -387,10 +381,10 @@ function buildSlotMetadata(
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
if (link) {
|
||||
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
|
||||
if (link && originNode) {
|
||||
originNodeId = String(link.origin_id)
|
||||
const originNode = graphRef.getNodeById(link.origin_id)
|
||||
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
originOutputName = originNode.outputs?.[link.origin_slot]?.name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ export interface SubMenuOption {
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
NEW = 'new',
|
||||
DEPRECATED = 'deprecated'
|
||||
NEW = 'new'
|
||||
}
|
||||
|
||||
// Global singleton for NodeOptions component reference
|
||||
|
||||
@@ -72,14 +72,14 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
|
||||
expect(mocks.frameNodes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
|
||||
it('does not include a Convert to Group Node option', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const groupNodeOption = options.find(
|
||||
(opt) => opt.label === 'contextMenu.Convert to Group Node'
|
||||
)
|
||||
expect(groupNodeOption).toBeDefined()
|
||||
expect(groupNodeOption).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import { useFrameNodes } from './useFrameNodes'
|
||||
import { BadgeVariant } from './useMoreOptionsMenu'
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
@@ -102,28 +100,13 @@ export function useSelectionMenuOptions() {
|
||||
return options
|
||||
}
|
||||
|
||||
const getMultipleNodesOptions = (): MenuOption[] => {
|
||||
const convertToGroupNodes = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute(
|
||||
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
|
||||
)
|
||||
const getMultipleNodesOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('g.frameNodes'),
|
||||
icon: 'icon-[lucide--frame]',
|
||||
action: frameNodes
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Group Node'),
|
||||
icon: 'icon-[lucide--group]',
|
||||
action: convertToGroupNodes,
|
||||
badge: BadgeVariant.DEPRECATED
|
||||
},
|
||||
{
|
||||
label: t('g.frameNodes'),
|
||||
icon: 'icon-[lucide--frame]',
|
||||
action: frameNodes
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const getAlignmentOptions = (): MenuOption[] => [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
|
||||
'KSampler',
|
||||
'KSamplerAdvanced',
|
||||
'PreviewImage',
|
||||
'SaveImage',
|
||||
'GLSLShader'
|
||||
|
||||
@@ -237,12 +237,15 @@ const normalizeWidgetValue = (
|
||||
|
||||
const buildJsonataContext = (
|
||||
node: LGraphNode,
|
||||
rule: JsonataPricingRule
|
||||
rule: JsonataPricingRule,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): JsonataEvalContext => {
|
||||
const widgets: Record<string, NormalizedWidgetValue> = {}
|
||||
for (const dep of rule.depends_on.widgets) {
|
||||
const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name)
|
||||
widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type)
|
||||
const raw = widgetOverrides?.has(dep.name)
|
||||
? widgetOverrides.get(dep.name)
|
||||
: node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value
|
||||
widgets[dep.name] = normalizeWidgetValue(raw, dep.type)
|
||||
}
|
||||
|
||||
const inputs: Record<string, { connected: boolean }> = {}
|
||||
@@ -552,7 +555,10 @@ export const useNodePricing = () => {
|
||||
* - schedules async evaluation when needed
|
||||
* - remains non-fatal on errors (returns safe fallback '')
|
||||
*/
|
||||
const getNodeDisplayPrice = (node: LGraphNode): string => {
|
||||
const getNodeDisplayPrice = (
|
||||
node: LGraphNode,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): string => {
|
||||
// Make this function reactive: when async evaluation completes, we bump pricingTick,
|
||||
// which causes this getter to recompute in Vue render/computed contexts.
|
||||
void pricingTick.value
|
||||
@@ -565,7 +571,7 @@ export const useNodePricing = () => {
|
||||
if (rule.engine !== 'jsonata') return ''
|
||||
if (!rule._compiled) return ''
|
||||
|
||||
const ctx = buildJsonataContext(node, rule)
|
||||
const ctx = buildJsonataContext(node, rule, widgetOverrides)
|
||||
const sig = buildSignature(ctx, rule)
|
||||
|
||||
const cached = cache.get(node)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
@@ -9,14 +11,25 @@ componentIconSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
|
||||
|
||||
export const usePriceBadge = () => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
function updateSubgraphCredits(node: LGraphNode) {
|
||||
if (!node.isSubgraphNode()) return
|
||||
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
|
||||
const newBadges = collectCreditsBadges(node.subgraph)
|
||||
if (newBadges.length > 1) {
|
||||
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
|
||||
} else {
|
||||
node.badges.push(...newBadges)
|
||||
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
|
||||
if (innerCreditsBadges.length > 1) {
|
||||
node.badges.push(
|
||||
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
|
||||
)
|
||||
} else if (innerCreditsBadges.length === 1) {
|
||||
const innerApiNodes = collectInnerApiNodes(node.subgraph)
|
||||
// When a single inner api node is the price source, swap its static
|
||||
// getter for a wrapper-aware one that resolves promoted widget values.
|
||||
if (innerApiNodes.length === 1) {
|
||||
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
|
||||
} else {
|
||||
node.badges.push(...innerCreditsBadges)
|
||||
}
|
||||
}
|
||||
const graph = node.graph
|
||||
if (!graph) return
|
||||
@@ -28,13 +41,14 @@ export const usePriceBadge = () => {
|
||||
newValue: node.badges
|
||||
})
|
||||
}
|
||||
|
||||
function collectCreditsBadges(
|
||||
graph: LGraph,
|
||||
visited: Set<string> = new Set()
|
||||
): (LGraphBadge | (() => LGraphBadge))[] {
|
||||
if (visited.has(graph.id)) return []
|
||||
visited.add(graph.id)
|
||||
const badges = []
|
||||
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
|
||||
for (const node of graph.nodes) {
|
||||
badges.push(
|
||||
...(node.isSubgraphNode()
|
||||
@@ -45,6 +59,51 @@ export const usePriceBadge = () => {
|
||||
return badges
|
||||
}
|
||||
|
||||
function collectInnerApiNodes(
|
||||
graph: LGraph,
|
||||
visited: Set<string> = new Set()
|
||||
): LGraphNode[] {
|
||||
if (visited.has(graph.id)) return []
|
||||
visited.add(graph.id)
|
||||
const apiNodes: LGraphNode[] = []
|
||||
for (const node of graph.nodes) {
|
||||
if (node.isSubgraphNode()) {
|
||||
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
|
||||
} else if (node.constructor?.nodeData?.api_node) {
|
||||
apiNodes.push(node)
|
||||
}
|
||||
}
|
||||
return apiNodes
|
||||
}
|
||||
|
||||
function buildWrapperAwarePriceBadge(
|
||||
wrapper: LGraphNode,
|
||||
innerNode: LGraphNode
|
||||
): () => LGraphBadge {
|
||||
return () =>
|
||||
getCreditsBadge(
|
||||
nodePricing.getNodeDisplayPrice(
|
||||
innerNode,
|
||||
collectPromotedOverrides(wrapper, innerNode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function collectPromotedOverrides(
|
||||
wrapper: LGraphNode,
|
||||
innerNode: LGraphNode
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
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
|
||||
}
|
||||
|
||||
function isCreditsBadge(
|
||||
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
|
||||
@@ -9,35 +9,39 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
|
||||
import { usePromotedPreviews } from './usePromotedPreviews'
|
||||
|
||||
type MockNodeOutputStore = Pick<
|
||||
ReturnType<typeof useNodeOutputStore>,
|
||||
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
|
||||
| 'nodeOutputs'
|
||||
| 'nodePreviewImages'
|
||||
| 'getNodeImageUrls'
|
||||
| 'getNodeImageUrlsByExecutionId'
|
||||
| 'getNodeOutputByExecutionId'
|
||||
| 'getNodePreviewImagesByExecutionId'
|
||||
>
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
|
||||
)
|
||||
const useNodeOutputStoreMock = vi.hoisted(() =>
|
||||
vi.fn<() => MockNodeOutputStore>()
|
||||
)
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
return {
|
||||
useNodeOutputStore: useNodeOutputStoreMock
|
||||
}
|
||||
})
|
||||
|
||||
function createMockNodeOutputStore(): MockNodeOutputStore {
|
||||
return {
|
||||
const store: MockNodeOutputStore = {
|
||||
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
||||
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
|
||||
getNodeImageUrls
|
||||
getNodeImageUrls: vi.fn(),
|
||||
getNodeImageUrlsByExecutionId: vi.fn(),
|
||||
getNodeOutputByExecutionId: vi.fn(),
|
||||
getNodePreviewImagesByExecutionId: vi.fn()
|
||||
}
|
||||
return { useNodeOutputStore: () => store }
|
||||
})
|
||||
|
||||
function clearMockNodeOutputStore() {
|
||||
const { nodeOutputs, nodePreviewImages } = useNodeOutputStore()
|
||||
for (const key of Object.keys(nodeOutputs)) delete nodeOutputs[key]
|
||||
for (const key of Object.keys(nodePreviewImages))
|
||||
delete nodePreviewImages[key]
|
||||
}
|
||||
|
||||
function createSetup() {
|
||||
@@ -83,16 +87,43 @@ function seedPreviewImages(
|
||||
}
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
let nodeOutputStore: MockNodeOutputStore
|
||||
function exposePreview(
|
||||
setup: ReturnType<typeof createSetup>,
|
||||
sourceNodeId: string,
|
||||
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
) {
|
||||
usePreviewExposureStore().addExposure(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
String(setup.subgraphNode.id),
|
||||
{ sourceNodeId, sourcePreviewName }
|
||||
)
|
||||
}
|
||||
|
||||
interface ArrangeOptions {
|
||||
id?: number
|
||||
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
function arrangePromotedPreview(options: ArrangeOptions = {}) {
|
||||
const {
|
||||
id = 10,
|
||||
previewMediaType,
|
||||
urls = ['/view?filename=output.png']
|
||||
} = options
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id, previewMediaType })
|
||||
exposePreview(setup, String(id))
|
||||
seedOutputs(setup.subgraph.id, [id])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(urls)
|
||||
return { setup, urls }
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
getNodeImageUrls.mockReset()
|
||||
|
||||
nodeOutputStore = createMockNodeOutputStore()
|
||||
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
||||
vi.resetAllMocks()
|
||||
clearMockNodeOutputStore()
|
||||
})
|
||||
|
||||
it('returns empty array for non-SubgraphNode', () => {
|
||||
@@ -109,70 +140,50 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns image preview for promoted $$ widget with outputs', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
const { setup, urls } = arrangePromotedPreview({
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
urls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns video type when interior node has video previewMediaType', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
it.for([
|
||||
['video', '/view?filename=output.webm'],
|
||||
['audio', '/view?filename=output.mp3']
|
||||
] as const)(
|
||||
'returns %s type when interior node has %s previewMediaType',
|
||||
([mediaType, url]) => {
|
||||
const { setup } = arrangePromotedPreview({
|
||||
previewMediaType: mediaType,
|
||||
urls: [url]
|
||||
})
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe(mediaType)
|
||||
}
|
||||
)
|
||||
|
||||
it('defaults preview type to image when previewMediaType is unset', () => {
|
||||
const { setup, urls } = arrangePromotedPreview()
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('video')
|
||||
})
|
||||
|
||||
it('returns audio type when interior node has audio previewMediaType', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('audio')
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
expect.objectContaining({ type: 'image', urls })
|
||||
])
|
||||
})
|
||||
|
||||
it('returns separate entries for multiple promoted $$ widgets', () => {
|
||||
@@ -185,23 +196,17 @@ describe(usePromotedPreviews, () => {
|
||||
id: 20,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
exposePreview(setup, '10')
|
||||
exposePreview(setup, '20')
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10, 20])
|
||||
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
})
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
|
||||
(node: LGraphNode) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(2)
|
||||
@@ -212,21 +217,17 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
exposePreview(setup, '10')
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
getNodeImageUrls.mockReturnValue([blobUrl])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -236,23 +237,19 @@ describe(usePromotedPreviews, () => {
|
||||
it('recomputes when preview images are populated after first evaluation', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
exposePreview(setup, '10')
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
getNodeImageUrls.mockReturnValue([blobUrl])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
|
||||
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -262,11 +259,7 @@ describe(usePromotedPreviews, () => {
|
||||
it('skips interior nodes with no image output', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
exposePreview(setup, '10')
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
@@ -274,36 +267,132 @@ describe(usePromotedPreviews, () => {
|
||||
|
||||
it('skips missing interior nodes', () => {
|
||||
const setup = createSetup()
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
exposePreview(setup, '99')
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores non-$$ promoted widgets', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
it('renders leaf media exposed through a nested subgraph host', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
String(innerHost.id),
|
||||
{
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
String(outerSetup.subgraphNode.id),
|
||||
{
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
const mockUrls = ['/view?filename=leaf.png']
|
||||
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
|
||||
(node: LGraphNode) => (node === leafNode ? mockUrls : [])
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
|
||||
const { promotedPreviews } = usePromotedPreviews(
|
||||
() => outerSetup.subgraphNode
|
||||
)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
|
||||
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
|
||||
const firstHostLocator = String(firstHost.id)
|
||||
const secondHostLocator = String(secondHost.id)
|
||||
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
|
||||
const secondNestedLocator = `${secondHostLocator}:${innerHost.id}`
|
||||
const firstLeafExecutionId = `${firstNestedLocator}:${leafNode.id}`
|
||||
const secondLeafExecutionId = `${secondNestedLocator}:${leafNode.id}`
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
|
||||
const outputStore = useNodeOutputStore()
|
||||
vi.mocked(outputStore.getNodePreviewImagesByExecutionId).mockImplementation(
|
||||
(executionId) => {
|
||||
if (executionId === firstLeafExecutionId) return ['blob:first']
|
||||
if (executionId === secondLeafExecutionId) return ['blob:second']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
vi.mocked(outputStore.getNodeImageUrlsByExecutionId).mockImplementation(
|
||||
(executionId) => {
|
||||
if (executionId === firstLeafExecutionId) return ['blob:first']
|
||||
if (executionId === secondLeafExecutionId) return ['blob:second']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
|
||||
[
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: ['blob:first']
|
||||
}
|
||||
]
|
||||
)
|
||||
expect(
|
||||
usePromotedPreviews(() => secondHost).promotedPreviews.value
|
||||
).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: ['blob:second']
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
@@ -14,65 +15,126 @@ interface PromotedPreview {
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns reactive preview media from promoted `$$` pseudo-widgets
|
||||
* on a SubgraphNode. Each promoted preview interior node produces
|
||||
* a separate entry so they render independently.
|
||||
*/
|
||||
const PREVIEW_TYPES_BY_MEDIA = {
|
||||
video: 'video',
|
||||
audio: 'audio'
|
||||
} as const satisfies Partial<Record<string, PromotedPreview['type']>>
|
||||
|
||||
function getPreviewMediaType(node: LGraphNode): PromotedPreview['type'] {
|
||||
const media = node.previewMediaType
|
||||
if (media && media in PREVIEW_TYPES_BY_MEDIA) {
|
||||
return PREVIEW_TYPES_BY_MEDIA[media as keyof typeof PREVIEW_TYPES_BY_MEDIA]
|
||||
}
|
||||
return 'image'
|
||||
}
|
||||
|
||||
export function usePromotedPreviews(
|
||||
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
|
||||
) {
|
||||
const promotionStore = usePromotionStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
|
||||
function readReactivePreviewUrls(
|
||||
leafHost: SubgraphNode,
|
||||
leafSourceNodeId: string,
|
||||
leafExecutionId: string,
|
||||
interiorNode: LGraphNode
|
||||
): string[] | undefined {
|
||||
const locatorId = createNodeLocatorId(
|
||||
leafHost.subgraph.id,
|
||||
leafSourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
const reactiveExecutionOutputs =
|
||||
nodeOutputStore.getNodeOutputByExecutionId(leafExecutionId)
|
||||
const reactiveExecutionPreviews =
|
||||
nodeOutputStore.getNodePreviewImagesByExecutionId(leafExecutionId)
|
||||
const hasAnySource =
|
||||
reactiveOutputs?.images?.length ||
|
||||
reactivePreviews?.length ||
|
||||
reactiveExecutionOutputs?.images?.length ||
|
||||
reactiveExecutionPreviews?.length
|
||||
if (!hasAnySource) return undefined
|
||||
return (
|
||||
nodeOutputStore.getNodeImageUrlsByExecutionId(
|
||||
leafExecutionId,
|
||||
interiorNode
|
||||
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
)
|
||||
}
|
||||
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) =>
|
||||
e.sourceWidgetName.startsWith('$$')
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const hostLocator = String(node.id)
|
||||
const exposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
hostLocator
|
||||
)
|
||||
if (!pseudoEntries.length) return []
|
||||
if (!exposures.length) return []
|
||||
|
||||
const previews: PromotedPreview[] = []
|
||||
const hostNodesByLocator = new Map<string, SubgraphNode>([
|
||||
[hostLocator, node]
|
||||
])
|
||||
|
||||
for (const entry of pseudoEntries) {
|
||||
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!interiorNode) continue
|
||||
function resolveNestedHost(
|
||||
rootGraphId: UUID,
|
||||
currentHostLocator: string,
|
||||
sourceNodeId: string
|
||||
) {
|
||||
const currentHost = hostNodesByLocator.get(currentHostLocator)
|
||||
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
|
||||
if (!(sourceNode instanceof SubgraphNode)) return undefined
|
||||
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
// tracking. getNodeImageUrls reads from non-reactive
|
||||
// app.nodeOutputs / app.nodePreviewImages, so without this
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.sourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
|
||||
continue
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
if (!urls?.length) continue
|
||||
|
||||
const type =
|
||||
interiorNode.previewMediaType === 'video'
|
||||
? 'video'
|
||||
: interiorNode.previewMediaType === 'audio'
|
||||
? 'audio'
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
const pathLocator = `${currentHostLocator}:${sourceNode.id}`
|
||||
const definitionLocator = String(sourceNode.id)
|
||||
const hasPathExposures =
|
||||
previewExposureStore.getExposures(rootGraphId, pathLocator).length > 0
|
||||
const nestedHostLocator = hasPathExposures
|
||||
? pathLocator
|
||||
: definitionLocator
|
||||
hostNodesByLocator.set(nestedHostLocator, sourceNode)
|
||||
return { rootGraphId, hostNodeLocator: nestedHostLocator }
|
||||
}
|
||||
|
||||
return previews
|
||||
return exposures.flatMap((exposure): PromotedPreview[] => {
|
||||
const resolved = previewExposureStore.resolveChain(
|
||||
rootGraphId,
|
||||
hostLocator,
|
||||
exposure.name,
|
||||
resolveNestedHost
|
||||
)
|
||||
const leaf = resolved?.leaf ?? {
|
||||
sourceNodeId: exposure.sourceNodeId,
|
||||
sourcePreviewName: exposure.sourcePreviewName
|
||||
}
|
||||
const leafHostLocator =
|
||||
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
|
||||
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
|
||||
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
|
||||
if (!interiorNode) return []
|
||||
|
||||
const urls = readReactivePreviewUrls(
|
||||
leafHost,
|
||||
leaf.sourceNodeId,
|
||||
`${leafHostLocator}:${leaf.sourceNodeId}`,
|
||||
interiorNode
|
||||
)
|
||||
if (!urls?.length) return []
|
||||
|
||||
return [
|
||||
{
|
||||
sourceNodeId: leaf.sourceNodeId,
|
||||
sourceWidgetName: leaf.sourcePreviewName,
|
||||
type: getPreviewMediaType(interiorNode),
|
||||
urls
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
return { promotedPreviews }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -568,17 +569,21 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -483,6 +483,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
function createHostWithInnerWidget(widgetName: string) {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const input = innerNode.addInput('value', 'number')
|
||||
innerNode.addWidget('number', widgetName, 0, () => {})
|
||||
input.widget = { name: widgetName }
|
||||
innerSubgraph.add(innerNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
return { rootGraph, innerSubgraph, innerNode, hostNode }
|
||||
}
|
||||
|
||||
describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
it('returns entry unchanged when it already resolves', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns entry unchanged with disambiguator when it already resolves', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'seed',
|
||||
String(innerNode.id)
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: String(innerNode.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a single legacy prefix from widget name', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: 'number' }]
|
||||
})
|
||||
|
||||
const samplerNode = new LGraphNode('Sampler')
|
||||
const samplerInput = samplerNode.addInput('seed', 'number')
|
||||
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
|
||||
samplerInput.widget = { name: 'noise_seed' }
|
||||
innerSubgraph.add(samplerNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const nestedNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(nestedNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
const prefixedName = `${nestedNode.id}: ${samplerNode.id}: noise_seed`
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(nestedNode.id),
|
||||
prefixedName
|
||||
)
|
||||
|
||||
expect(result.sourceWidgetName).toBe('noise_seed')
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('returns original entry when prefix cannot be resolved', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'999: nonexistent_widget'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: '999: nonexistent_widget'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
|
||||
|
||||
function canResolve(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
).status === 'resolved'
|
||||
)
|
||||
}
|
||||
|
||||
function tryResolveCandidate(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
|
||||
return undefined
|
||||
|
||||
return {
|
||||
sourceWidgetName: widgetName,
|
||||
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegacyPrefixedEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
let remaining = sourceWidgetName
|
||||
|
||||
while (true) {
|
||||
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
|
||||
if (!match) return undefined
|
||||
|
||||
const [, legacySourceNodeId, unprefixed] = match
|
||||
remaining = unprefixed
|
||||
|
||||
const disambiguators = [
|
||||
legacySourceNodeId,
|
||||
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
|
||||
undefined
|
||||
]
|
||||
|
||||
for (const disambiguator of disambiguators) {
|
||||
const resolved = tryResolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
remaining,
|
||||
disambiguator
|
||||
)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLegacyProxyWidgetEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetSource {
|
||||
if (
|
||||
canResolve(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
}
|
||||
}
|
||||
|
||||
const patch = resolveLegacyPrefixedEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||