mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
44 Commits
cloud/1.43
...
v1.43.17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f754141537 | ||
|
|
9e4ce35fb1 | ||
|
|
900c168b69 | ||
|
|
bbb76f8b38 | ||
|
|
ddab5234b9 | ||
|
|
a596c60c33 | ||
|
|
380d4c9002 | ||
|
|
870871e2fa | ||
|
|
e047b12556 | ||
|
|
e1bdaa5778 | ||
|
|
e8b5e92c48 | ||
|
|
92596a0a01 | ||
|
|
649857c4e2 | ||
|
|
df0b670c77 | ||
|
|
a035ea913b | ||
|
|
45283226ef | ||
|
|
7de0e2c4c1 | ||
|
|
7b152a680f | ||
|
|
3a701c8307 | ||
|
|
b2eedab53e | ||
|
|
3c60f3ab6f | ||
|
|
f7619013c1 | ||
|
|
bbea4cee69 | ||
|
|
9fb4cf3da4 | ||
|
|
d1fa816934 | ||
|
|
4d91b745c7 | ||
|
|
ed2df9b280 | ||
|
|
608b151a4b | ||
|
|
b348a53dd5 | ||
|
|
9a95dd4aa5 | ||
|
|
d37f77cf94 | ||
|
|
cf2cf7f888 | ||
|
|
a53f352ff0 | ||
|
|
3ea6c83391 | ||
|
|
b2a1108340 | ||
|
|
dae5301f4d | ||
|
|
0ce0c679ef | ||
|
|
50ddd904e7 | ||
|
|
e6a59dcdc2 | ||
|
|
924da682ab | ||
|
|
0722a39ba3 | ||
|
|
ae0cf28fc3 | ||
|
|
addd369d85 | ||
|
|
8e08877530 |
BIN
.github/pr-images/fe-237-before-after.png
vendored
BIN
.github/pr-images/fe-237-before-after.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
1
.github/workflows/ci-tests-e2e-forks.yaml
vendored
1
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -66,7 +66,6 @@ jobs:
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: playwright-report-.*
|
||||
name_is_regexp: true
|
||||
|
||||
76
.github/workflows/ci-tests-e2e.yaml
vendored
76
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -14,26 +14,36 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Detect whether e2e-relevant files changed. Required checks see "skipped"
|
||||
# (which counts as passing) when only docs/apps/storybook files are touched,
|
||||
# avoiding the stall that paths-ignore would cause.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: read
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
- name: Check for e2e-relevant changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
predicate-quantifier: 'every'
|
||||
filters: |
|
||||
should_run:
|
||||
- '!(**.md)'
|
||||
e2e:
|
||||
- '**'
|
||||
- '!apps/**'
|
||||
- '!docs/**'
|
||||
- '!.storybook/**'
|
||||
- '!**/*.md'
|
||||
|
||||
setup:
|
||||
needs: changes
|
||||
if: github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true'
|
||||
if: ${{ needs.changes.outputs.should_run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -173,7 +183,7 @@ jobs:
|
||||
merge-reports:
|
||||
needs: [changes, playwright-tests-chromium-sharded]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true') }}
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
@@ -202,6 +212,21 @@ jobs:
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
e2e-status:
|
||||
if: ${{ always() }}
|
||||
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check E2E results
|
||||
env:
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
|
||||
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
|
||||
BROWSERS: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
|
||||
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
|
||||
echo "E2E passed"
|
||||
|
||||
#### BEGIN Deployment and commenting (non-forked PRs only)
|
||||
# when using pull_request event, we have permission to comment directly
|
||||
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
|
||||
@@ -210,7 +235,12 @@ jobs:
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
|
||||
if: >-
|
||||
${{
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -231,7 +261,13 @@ jobs:
|
||||
deploy-and-comment:
|
||||
needs: [changes, playwright-tests, merge-reports]
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
@@ -256,24 +292,4 @@ jobs:
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
e2e-status:
|
||||
if: always()
|
||||
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Determine e2e outcome
|
||||
run: |
|
||||
if [[ "${{ needs.changes.outputs.should_run }}" != "true" && "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "E2E tests skipped (no relevant changes)"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${{ needs.playwright-tests-chromium-sharded.result }}" == "success" && "${{ needs.playwright-tests.result }}" == "success" ]]; then
|
||||
echo "All E2E tests passed"
|
||||
exit 0
|
||||
fi
|
||||
echo "E2E tests failed or were cancelled"
|
||||
echo " chromium-sharded: ${{ needs.playwright-tests-chromium-sharded.result }}"
|
||||
echo " playwright-tests: ${{ needs.playwright-tests.result }}"
|
||||
exit 1
|
||||
#### END Deployment and commenting (non-forked PRs only)
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-700);
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-600);
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-500);
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
197
browser_tests/assets/subgraphs/subgraph-with-collapsed-node.json
Normal file
197
browser_tests/assets/subgraphs/subgraph-with-collapsed-node.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": { "collapsed": true },
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -154,17 +154,12 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly activeWorkflowLabel: Locator
|
||||
public readonly searchInput: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
this.root = page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.activeWorkflowLabel = this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
this.searchInput = this.root.getByRole('combobox').first()
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
|
||||
// Add a new CheckpointLoaderSimple — should use first cloud asset,
|
||||
// not the server's object_info default.
|
||||
const widgetValue = await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
|
||||
window.app!.graph.add(node!)
|
||||
const widget = node!.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
return String(widget?.value ?? '')
|
||||
})
|
||||
|
||||
// Production resolves via getAssetFilename (user_metadata.filename →
|
||||
// metadata.filename → asset.name). Test fixtures have no metadata
|
||||
// filename, so asset.name is the resolved value.
|
||||
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
})
|
||||
@@ -66,3 +66,30 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Collapsed node links inside subgraph on first entry',
|
||||
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
|
||||
() => {
|
||||
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-collapsed-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// fitView runs on first entry and re-syncs slot layouts for the
|
||||
// pre-collapsed KSampler. Screenshot captures the rendered canvas
|
||||
// links to guard against regressing the stale-coordinate bug.
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'subgraph-entry-collapsed-node-links.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -62,30 +62,4 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode toggle preserves properties panel width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open the properties panel
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
|
||||
|
||||
// Record the initial panel width
|
||||
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
|
||||
expect(initialBox).not.toBeNull()
|
||||
const initialWidth = initialBox!.width
|
||||
|
||||
// Toggle focus mode on then off
|
||||
await comfyPage.setFocusMode(true)
|
||||
await comfyPage.setFocusMode(false)
|
||||
|
||||
// Properties panel should be visible again with the same width
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
|
||||
return box ? Math.abs(box.width - initialWidth) : Infinity
|
||||
})
|
||||
.toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,33 +95,3 @@ test.describe('Load3D', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Load3D initialization failure', () => {
|
||||
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Force `new THREE.WebGLRenderer(...)` inside Load3d to throw by making
|
||||
// WebGL getContext() calls return null.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const proto = HTMLCanvasElement.prototype as {
|
||||
getContext: (
|
||||
this: HTMLCanvasElement,
|
||||
type: string,
|
||||
options?: unknown
|
||||
) => unknown
|
||||
}
|
||||
const original = proto.getContext
|
||||
proto.getContext = function (type, options) {
|
||||
if (type === 'webgl' || type === 'webgl2') return null
|
||||
return original.call(this, type, options)
|
||||
}
|
||||
})
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
|
||||
|
||||
await expect(
|
||||
comfyPage.toast.visibleToasts.filter({
|
||||
hasText: 'Failed to initialize 3D Viewer'
|
||||
})
|
||||
).not.toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
230
browser_tests/tests/nodeGhostPlacement.spec.ts
Normal file
230
browser_tests/tests/nodeGhostPlacement.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
|
||||
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
|
||||
if (enabled) {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
const centerY = Math.round(viewport.height / 2)
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
test.describe(`Ghost node placement (${mode} mode)`, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await setVueMode(comfyPage, mode === 'vue')
|
||||
})
|
||||
|
||||
test('positions ghost node at cursor', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
const centerY = Math.round(viewport.height / 2)
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
|
||||
const canvas = window.app!.canvas
|
||||
const rect = canvas.canvas.getBoundingClientRect()
|
||||
const cursorCanvasX =
|
||||
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
|
||||
const cursorCanvasY =
|
||||
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
|
||||
|
||||
return {
|
||||
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
|
||||
diffY: node.pos[1] - 10 - cursorCanvasY
|
||||
}
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
})
|
||||
|
||||
test('left-click confirms ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.click(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.ghost).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('Delete cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Backspace')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('right-click cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test(
|
||||
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
if (mode === 'vue') {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
} else {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
}
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// `subgraph.getActiveGraphId()` is not on core/1.43; inline via app state
|
||||
const getActiveGraphId = () =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas?.graph?.id ?? null)
|
||||
|
||||
const subgraphId = await getActiveGraphId()
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// `searchBoxV2.open()` is not on core/1.43; the canvas double-click
|
||||
// opens the v2 search box when Comfy.NodeSearchBoxImpl is set to default
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.canvas.dblclick({ position: { x: 400, y: 300 } })
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.state.ghostNodeId != null
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), {
|
||||
message:
|
||||
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => getActiveGraphId()).toBe(subgraphId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
|
||||
)
|
||||
.toBeNull()
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -17,13 +17,14 @@ test.describe('Workflow sidebar - search', () => {
|
||||
'alpha-workflow.json': 'default.json',
|
||||
'beta-workflow.json': 'default.json'
|
||||
})
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Search filters saved workflows by name', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('alpha')
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
await expect(
|
||||
@@ -33,11 +34,15 @@ test.describe('Workflow sidebar - search', () => {
|
||||
|
||||
test('Clearing search restores all workflows', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('alpha')
|
||||
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeHidden()
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await searchInput.fill('')
|
||||
|
||||
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
|
||||
@@ -45,8 +50,10 @@ test.describe('Workflow sidebar - search', () => {
|
||||
|
||||
test('Search with no matches shows empty results', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_xyz')
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('nonexistent_xyz')
|
||||
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'alpha-workflow')
|
||||
@@ -55,72 +62,4 @@ test.describe('Workflow sidebar - search', () => {
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('deletion', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
'alpha-workflow.json': 'default.json',
|
||||
'beta-workflow.json': 'default.json',
|
||||
'gamma-workflow.json': 'default.json'
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Deleting a workflow while search is active removes it from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
|
||||
await tab.searchInput.fill('alpha')
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
|
||||
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting during search does not affect other matched results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
|
||||
await tab.searchInput.fill('workflow')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeVisible()
|
||||
await expect(findWorkflow(comfyPage.page, 'gamma-workflow')).toBeVisible()
|
||||
|
||||
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
|
||||
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeVisible()
|
||||
await expect(findWorkflow(comfyPage.page, 'gamma-workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clearing search after deleting during search shows correct workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
|
||||
await tab.searchInput.fill('alpha')
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
|
||||
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('gamma-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('alpha-workflow')).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
@@ -9,31 +8,6 @@ const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -109,35 +83,5 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.16",
|
||||
"version": "1.43.17",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -81,7 +81,6 @@
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
@@ -113,7 +112,6 @@
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
--color-coral-red-600: #973a40;
|
||||
--color-coral-red-500: #c53f49;
|
||||
--color-coral-red-400: #dd424e;
|
||||
|
||||
--color-bypass: #6a246a;
|
||||
--color-error: #962a2a;
|
||||
|
||||
|
||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -159,9 +159,6 @@ catalogs:
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -354,9 +351,6 @@ catalogs:
|
||||
unplugin-vue-components:
|
||||
specifier: ^30.0.0
|
||||
version: 30.0.0
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
vite-plugin-dts:
|
||||
specifier: ^4.5.4
|
||||
version: 4.5.4
|
||||
@@ -494,9 +488,6 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2
|
||||
'@vee-validate/zod':
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -590,9 +581,6 @@ importers:
|
||||
typegpu:
|
||||
specifier: 'catalog:'
|
||||
version: 0.8.2
|
||||
vee-validate:
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4652,11 +4640,6 @@ packages:
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/analytics@2.0.1':
|
||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
||||
peerDependencies:
|
||||
@@ -9444,11 +9427,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -13732,14 +13710,6 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
@@ -19644,12 +19614,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -54,7 +54,6 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
@@ -119,7 +118,6 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const MIN_DELTA = 0.05
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
percentage: number
|
||||
totalLines: number
|
||||
coveredLines: number
|
||||
}
|
||||
|
||||
interface SlackBlock {
|
||||
type: 'section'
|
||||
text: {
|
||||
type: 'mrkdwn'
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
function parseLcovContent(content: string): CoverageData | null {
|
||||
const perFile = new Map<string, { lf: number; lh: number }>()
|
||||
let currentFile = ''
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = line.slice(3)
|
||||
} else if (line.startsWith('LF:')) {
|
||||
const n = parseInt(line.slice(3), 10) || 0
|
||||
const entry = perFile.get(currentFile) ?? { lf: 0, lh: 0 }
|
||||
entry.lf = n
|
||||
perFile.set(currentFile, entry)
|
||||
} else if (line.startsWith('LH:')) {
|
||||
const n = parseInt(line.slice(3), 10) || 0
|
||||
const entry = perFile.get(currentFile) ?? { lf: 0, lh: 0 }
|
||||
entry.lh = n
|
||||
perFile.set(currentFile, entry)
|
||||
}
|
||||
}
|
||||
|
||||
let totalLines = 0
|
||||
let coveredLines = 0
|
||||
for (const { lf, lh } of perFile.values()) {
|
||||
totalLines += lf
|
||||
coveredLines += lh
|
||||
}
|
||||
|
||||
if (totalLines === 0) return null
|
||||
|
||||
return {
|
||||
percentage: (coveredLines / totalLines) * 100,
|
||||
totalLines,
|
||||
coveredLines
|
||||
}
|
||||
}
|
||||
|
||||
function parseLcov(filePath: string): CoverageData | null {
|
||||
if (!existsSync(filePath)) return null
|
||||
return parseLcovContent(readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function progressBar(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage))
|
||||
const filled = Math.round((clamped / 100) * BAR_WIDTH)
|
||||
const empty = BAR_WIDTH - filled
|
||||
return '█'.repeat(filled) + '░'.repeat(empty)
|
||||
}
|
||||
|
||||
function formatPct(value: number): string {
|
||||
return value.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
|
||||
const sign = rounded >= 0 ? '+' : ''
|
||||
return sign + rounded.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
const prevBucket = Math.floor(prev / MILESTONE_STEP)
|
||||
const currBucket = Math.floor(curr / MILESTONE_STEP)
|
||||
|
||||
if (currBucket > prevBucket) {
|
||||
return currBucket * MILESTONE_STEP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildMilestoneBlock(label: string, milestone: number): SlackBlock {
|
||||
if (milestone >= TARGET) {
|
||||
return {
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: [
|
||||
`🏆 *GOAL REACHED: ${label} coverage hit ${milestone}%!* 🏆`,
|
||||
`\`${progressBar(milestone)}\` ${milestone}% ✅`,
|
||||
'The team did it! 🎊🥳🎉'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = TARGET - milestone
|
||||
return {
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: [
|
||||
`🎉🎉🎉 *MILESTONE: ${label} coverage hit ${milestone}%!*`,
|
||||
`\`${progressBar(milestone)}\` ${milestone}% → ${TARGET}% target`,
|
||||
`${remaining} percentage point${remaining !== 1 ? 's' : ''} to go!`
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
prUrl: string
|
||||
prNumber: string
|
||||
author: string
|
||||
} {
|
||||
let prUrl = ''
|
||||
let prNumber = ''
|
||||
let author = ''
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--pr-url=')) prUrl = arg.slice('--pr-url='.length)
|
||||
else if (arg.startsWith('--pr-number='))
|
||||
prNumber = arg.slice('--pr-number='.length)
|
||||
else if (arg.startsWith('--author=')) author = arg.slice('--author='.length)
|
||||
}
|
||||
|
||||
return { prUrl, prNumber, author }
|
||||
}
|
||||
|
||||
function formatCoverageRow(
|
||||
label: string,
|
||||
current: CoverageData,
|
||||
baseline: CoverageData
|
||||
): string {
|
||||
const delta = current.percentage - baseline.percentage
|
||||
return `*${label}:* ${formatPct(baseline.percentage)} → ${formatPct(current.percentage)} (${formatDelta(delta)})`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { prUrl, prNumber, author } = parseArgs(process.argv.slice(2))
|
||||
|
||||
const unitCurrent = parseLcov('coverage/lcov.info')
|
||||
const unitBaseline = parseLcov('temp/coverage-baseline/lcov.info')
|
||||
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
|
||||
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
|
||||
|
||||
const unitDelta =
|
||||
unitCurrent !== null && unitBaseline !== null
|
||||
? unitCurrent.percentage - unitBaseline.percentage
|
||||
: 0
|
||||
|
||||
const e2eDelta =
|
||||
e2eCurrent !== null && e2eBaseline !== null
|
||||
? e2eCurrent.percentage - e2eBaseline.percentage
|
||||
: 0
|
||||
|
||||
const unitImproved = unitDelta >= MIN_DELTA
|
||||
const e2eImproved = e2eDelta >= MIN_DELTA
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const blocks: SlackBlock[] = []
|
||||
|
||||
const summaryLines: string[] = []
|
||||
summaryLines.push(
|
||||
`✅ *Coverage improved!* — <${prUrl}|PR #${prNumber}> by <https://github.com/${author}|${author}>`
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitImproved) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
|
||||
}
|
||||
|
||||
if (e2eImproved) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent) {
|
||||
summaryLines.push(
|
||||
`\`${progressBar(unitCurrent.percentage)}\` ${formatPct(unitCurrent.percentage)} unit → ${TARGET}% target`
|
||||
)
|
||||
}
|
||||
if (e2eCurrent) {
|
||||
summaryLines.push(
|
||||
`\`${progressBar(e2eCurrent.percentage)}\` ${formatPct(e2eCurrent.percentage)} e2e → ${TARGET}% target`
|
||||
)
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: summaryLines.join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
const milestone = crossedMilestone(
|
||||
unitBaseline.percentage,
|
||||
unitCurrent.percentage
|
||||
)
|
||||
if (milestone !== null) {
|
||||
blocks.push(buildMilestoneBlock('Unit test', milestone))
|
||||
}
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
const milestone = crossedMilestone(
|
||||
e2eBaseline.percentage,
|
||||
e2eCurrent.percentage
|
||||
)
|
||||
if (milestone !== null) {
|
||||
blocks.push(buildMilestoneBlock('E2E test', milestone))
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { text: 'Coverage improved!', blocks }
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -171,10 +171,14 @@ const sidebarPanelVisible = computed(
|
||||
)
|
||||
|
||||
const firstPanelVisible = computed(
|
||||
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
|
||||
)
|
||||
const lastPanelVisible = computed(
|
||||
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -264,7 +268,6 @@ const splitterRefreshKey = computed(() => {
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -272,7 +275,6 @@ const firstPanelStyle = computed(() => {
|
||||
})
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -291,13 +293,9 @@ const lastPanelStyle = computed(() => {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide gutter when adjacent panel is not visible */
|
||||
:deep(
|
||||
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
|
||||
),
|
||||
:deep(
|
||||
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
|
||||
) {
|
||||
/* Hide sidebar gutter when sidebar is not visible */
|
||||
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,7 @@
|
||||
class="icon-[lucide--triangle-alert] text-warning-background"
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag
|
||||
v-if="item.isBlueprint"
|
||||
data-testid="subgraph-breadcrumb-blueprint-tag"
|
||||
:value="t('breadcrumbsMenu.blueprint')"
|
||||
severity="primary"
|
||||
/>
|
||||
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
|
||||
</div>
|
||||
<Menu
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import SignUpForm from './SignUpForm.vue'
|
||||
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
const mockLoadingRef = ref(false)
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
get loading() {
|
||||
return mockLoadingRef.value
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(SignUpForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Password manager autofill attributes', () => {
|
||||
it('renders email input with attributes Chrome needs to recognize the field', () => {
|
||||
renderComponent()
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.signup.emailPlaceholder
|
||||
)
|
||||
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email')
|
||||
expect(emailInput).toHaveAttribute('name', 'email')
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(emailInput).toHaveAttribute('type', 'email')
|
||||
})
|
||||
|
||||
it('renders password input with new-password autofill attributes', () => {
|
||||
renderComponent()
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.signup.passwordPlaceholder
|
||||
)
|
||||
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password')
|
||||
expect(passwordInput).toHaveAttribute('name', 'password')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
|
||||
})
|
||||
|
||||
it('renders confirm-password input with distinct name and new-password autocomplete', () => {
|
||||
renderComponent()
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.login.confirmPasswordPlaceholder
|
||||
)
|
||||
expect(confirmPasswordInput).toHaveAttribute(
|
||||
'id',
|
||||
'comfy-org-sign-up-confirm-password'
|
||||
)
|
||||
expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword')
|
||||
expect(confirmPasswordInput).toHaveAttribute(
|
||||
'autocomplete',
|
||||
'new-password'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,9 @@
|
||||
</label>
|
||||
<InputText
|
||||
pt:root:id="comfy-org-sign-up-email"
|
||||
pt:root:name="email"
|
||||
pt:root:autocomplete="email"
|
||||
class="h-10"
|
||||
type="email"
|
||||
type="text"
|
||||
:placeholder="t('auth.signup.emailPlaceholder')"
|
||||
:invalid="$field.invalid"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
|
||||
>
|
||||
<!-- Native drag area for Electron -->
|
||||
<div
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
@@ -184,6 +189,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
@@ -299,16 +299,14 @@ function handleTitleCancel() {
|
||||
@cancel="handleTitleCancel"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
variant="link"
|
||||
size="unset"
|
||||
:aria-label="t('rightSidePanel.editTitle')"
|
||||
class="relative top-[2px] ml-2 shrink-0"
|
||||
class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
|
||||
@click="isEditing = true"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--pencil] size-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ panelTitle }}
|
||||
|
||||
@@ -109,10 +109,8 @@ const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
)
|
||||
const showIdName = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
|
||||
const showIdName = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
|
||||
)
|
||||
const showNodeFrequency = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick, reactive, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
|
||||
const {
|
||||
setSearchQuery,
|
||||
emitSearch,
|
||||
captureSearchRoot,
|
||||
getSearchRoot,
|
||||
resetCapturedSearchRoot,
|
||||
mockExpandNode,
|
||||
mockToggleNodeOnEvent,
|
||||
mockLoadBookmarks,
|
||||
mockWorkflowService,
|
||||
mockWorkflowStoreState,
|
||||
registerSearchHandlers
|
||||
} = vi.hoisted(() => {
|
||||
let updateQuery = (_query: string) => {}
|
||||
let triggerSearch = (_query: string) => {}
|
||||
let capturedSearchRoot: TreeExplorerNode<ComfyWorkflow> | null = null
|
||||
|
||||
const workflowStore = {
|
||||
workflows: [] as ComfyWorkflow[],
|
||||
persistedWorkflows: [] as ComfyWorkflow[],
|
||||
bookmarkedWorkflows: [] as ComfyWorkflow[],
|
||||
openWorkflows: [] as ComfyWorkflow[],
|
||||
activeWorkflow: null as ComfyWorkflow | null,
|
||||
syncWorkflows: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
setSearchQuery: (query: string) => {
|
||||
updateQuery(query)
|
||||
},
|
||||
emitSearch: (query: string) => {
|
||||
triggerSearch(query)
|
||||
},
|
||||
captureSearchRoot: (root: TreeExplorerNode<ComfyWorkflow>) => {
|
||||
capturedSearchRoot = root
|
||||
},
|
||||
getSearchRoot: () => capturedSearchRoot,
|
||||
resetCapturedSearchRoot: () => {
|
||||
capturedSearchRoot = null
|
||||
},
|
||||
mockExpandNode: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockLoadBookmarks: vi.fn().mockResolvedValue(undefined),
|
||||
mockWorkflowService: {
|
||||
openWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
closeWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
renameWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
insertWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
duplicateWorkflow: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
mockWorkflowStoreState: workflowStore,
|
||||
registerSearchHandlers: (
|
||||
updateHandler: (query: string) => void,
|
||||
searchHandler: (query: string) => void
|
||||
) => {
|
||||
updateQuery = updateHandler
|
||||
triggerSearch = searchHandler
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkflowStore = reactive(mockWorkflowStoreState)
|
||||
|
||||
vi.mock('@/components/common/NoResultsPlaceholder.vue', () => ({
|
||||
default: { name: 'NoResultsPlaceholder', template: '<div />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchInput',
|
||||
template: '<div data-testid="search-input" />',
|
||||
props: ['modelValue', 'placeholder'],
|
||||
setup(
|
||||
_props: { modelValue: string; placeholder?: string },
|
||||
{
|
||||
emit,
|
||||
expose
|
||||
}: {
|
||||
emit: (event: 'update:modelValue' | 'search', value: string) => void
|
||||
expose: (value: { focus: () => void }) => void
|
||||
}
|
||||
) {
|
||||
const focus = vi.fn()
|
||||
expose({ focus })
|
||||
registerSearchHandlers(
|
||||
(query: string) => emit('update:modelValue', query),
|
||||
(query: string) => emit('search', query)
|
||||
)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/SidebarTopArea.vue', () => ({
|
||||
default: { name: 'SidebarTopArea', template: '<div><slot /></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/TextDivider.vue', () => ({
|
||||
default: { name: 'TextDivider', template: '<div />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/TreeExplorer.vue', () => ({
|
||||
default: {
|
||||
name: 'TreeExplorer',
|
||||
template: '<div data-testid="tree-explorer" />',
|
||||
props: ['root', 'selectionKeys', 'expandedKeys'],
|
||||
setup(props: {
|
||||
root: TreeExplorerNode<ComfyWorkflow>
|
||||
selectionKeys?: Record<string, boolean>
|
||||
}) {
|
||||
watchEffect(() => {
|
||||
if (props.selectionKeys === undefined) {
|
||||
captureSearchRoot(props.root)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/TreeExplorerTreeNode.vue', () => ({
|
||||
default: {
|
||||
name: 'TreeExplorerTreeNode',
|
||||
template:
|
||||
'<div><slot name="before-label" :node="node" /><slot /><slot name="actions" :node="node" /></div>',
|
||||
props: ['node']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
|
||||
default: {
|
||||
name: 'SidebarTabTemplate',
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue', () => ({
|
||||
default: { name: 'WorkflowTreeLeaf', template: '<div />', props: ['node'] }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: { name: 'Button', template: '<button><slot /></button>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTreeExpansion', () => ({
|
||||
useTreeExpansion: () => ({
|
||||
expandNode: mockExpandNode,
|
||||
toggleNodeOnEvent: mockToggleNodeOnEvent
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Workflow.WorkflowTabsPosition') return 'Sidebar'
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => mockWorkflowService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore,
|
||||
useWorkflowBookmarkStore: () => ({ loadBookmarks: mockLoadBookmarks }),
|
||||
ComfyWorkflow: class {
|
||||
static basePath = 'workflows/'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/confirmdialog', () => ({
|
||||
default: { name: 'ConfirmDialog', template: '<div />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const createMockWorkflow = (path: string) =>
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path,
|
||||
key: path.replace('workflows/', ''),
|
||||
isModified: false,
|
||||
isPersisted: true,
|
||||
isTemporary: false,
|
||||
suffix: 'json',
|
||||
directory: 'workflows'
|
||||
})
|
||||
|
||||
const getLeafPaths = (
|
||||
root: TreeExplorerNode<ComfyWorkflow> | null
|
||||
): string[] => {
|
||||
if (!root) return []
|
||||
return flattenTree<ComfyWorkflow>(root)
|
||||
.map((w) => w.path)
|
||||
.sort()
|
||||
}
|
||||
|
||||
describe('BaseWorkflowsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetCapturedSearchRoot()
|
||||
|
||||
mockWorkflowStore.workflows = []
|
||||
mockWorkflowStore.persistedWorkflows = []
|
||||
mockWorkflowStore.bookmarkedWorkflows = []
|
||||
mockWorkflowStore.openWorkflows = []
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
})
|
||||
|
||||
const renderComponent = () =>
|
||||
render(BaseWorkflowsSidebarTab, {
|
||||
props: {
|
||||
title: 'Workflows',
|
||||
searchSubject: 'Workflow',
|
||||
dataTestid: 'workflows-sidebar'
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false }), i18n],
|
||||
stubs: { teleport: true }
|
||||
}
|
||||
})
|
||||
|
||||
it('returns an empty filtered workflow set when searchQuery is empty', async () => {
|
||||
mockWorkflowStore.workflows = [
|
||||
createMockWorkflow('workflows/test-alpha.json'),
|
||||
createMockWorkflow('workflows/test-beta.json')
|
||||
]
|
||||
|
||||
renderComponent()
|
||||
emitSearch('alpha')
|
||||
await nextTick()
|
||||
|
||||
expect(mockExpandNode).toHaveBeenCalledTimes(1)
|
||||
const expandedRoot = mockExpandNode.mock.calls[0]?.[0] as
|
||||
| TreeExplorerNode<ComfyWorkflow>
|
||||
| undefined
|
||||
expect(getLeafPaths(expandedRoot ?? null)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('filters workflows by case-insensitive path match', async () => {
|
||||
mockWorkflowStore.workflows = [
|
||||
createMockWorkflow('workflows/test-alpha.json'),
|
||||
createMockWorkflow('workflows/other-workflow.json'),
|
||||
createMockWorkflow('workflows/TEST-gamma.json')
|
||||
]
|
||||
|
||||
renderComponent()
|
||||
|
||||
setSearchQuery('ALPHA')
|
||||
await nextTick()
|
||||
|
||||
expect(getLeafPaths(getSearchRoot())).toEqual(['workflows/test-alpha.json'])
|
||||
})
|
||||
|
||||
it('reactively updates filtered workflows when a workflow is removed', async () => {
|
||||
mockWorkflowStore.workflows = [
|
||||
createMockWorkflow('workflows/test-alpha.json'),
|
||||
createMockWorkflow('workflows/TEST-alpha-2.json'),
|
||||
createMockWorkflow('workflows/test-beta.json')
|
||||
]
|
||||
|
||||
renderComponent()
|
||||
|
||||
setSearchQuery('alpha')
|
||||
await nextTick()
|
||||
expect(getLeafPaths(getSearchRoot())).toEqual([
|
||||
'workflows/TEST-alpha-2.json',
|
||||
'workflows/test-alpha.json'
|
||||
])
|
||||
|
||||
mockWorkflowStore.workflows = mockWorkflowStore.workflows.filter(
|
||||
(workflow) => workflow.path !== 'workflows/TEST-alpha-2.json'
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(getLeafPaths(getSearchRoot())).toEqual(['workflows/test-alpha.json'])
|
||||
})
|
||||
})
|
||||
@@ -194,21 +194,22 @@ const searchBoxRef = ref()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const isSearching = computed(() => searchQuery.value.length > 0)
|
||||
const filteredWorkflows = computed(() => {
|
||||
if (searchQuery.value.length === 0) return []
|
||||
const lowerQuery = searchQuery.value.toLocaleLowerCase()
|
||||
return applyFilter(workflowStore.workflows).filter((workflow) =>
|
||||
workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
)
|
||||
})
|
||||
const filteredWorkflows = ref<ComfyWorkflow[]>([])
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
const handleSearch = async (query: string) => {
|
||||
if (query.length === 0) {
|
||||
filteredWorkflows.value = []
|
||||
expandedKeys.value = {}
|
||||
return
|
||||
}
|
||||
const lowerQuery = query.toLocaleLowerCase()
|
||||
filteredWorkflows.value = applyFilter(workflowStore.workflows).filter(
|
||||
(workflow) => {
|
||||
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
}
|
||||
)
|
||||
await nextTick()
|
||||
expandNode(filteredRoot.value)
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
import ProgressToastItem from './ProgressToastItem.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
progressToast: {
|
||||
finished: 'Finished',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function completedJob(): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'controlnet-canny.safetensors',
|
||||
bytesTotal: 100,
|
||||
bytesDownloaded: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
lastUpdate: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProgressToastItem — completed state', () => {
|
||||
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
|
||||
render(ProgressToastItem, {
|
||||
props: { job: completedJob() },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
const badge = screen.getByText('Finished')
|
||||
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
|
||||
expect(badge.closest('.opacity-50')).toBeNull()
|
||||
|
||||
const assetName = screen.getByText('controlnet-canny.safetensors')
|
||||
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
|
||||
expect(assetName.closest('.opacity-50')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -22,9 +22,14 @@ const isPending = computed(() => job.status === 'created')
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
isCompleted && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-base-foreground">{{
|
||||
job.assetName
|
||||
}}</span>
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchAutocomplete from './SearchAutocomplete.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
|
||||
})
|
||||
|
||||
describe('SearchAutocomplete', () => {
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
return render(SearchAutocomplete, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: { template: '<div><slot /></div>' },
|
||||
ComboboxAnchor: { template: '<div><slot /></div>' },
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue']
|
||||
},
|
||||
ComboboxPortal: { template: '<div><slot /></div>' },
|
||||
ComboboxContent: { template: '<div><slot /></div>' },
|
||||
ComboboxItem: {
|
||||
template:
|
||||
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
|
||||
emits: ['select']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: { modelValue: '', ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('suggestions dropdown', () => {
|
||||
it('does not render items when suggestions list is empty', () => {
|
||||
renderComponent({ suggestions: [] })
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a button for each suggestion', () => {
|
||||
renderComponent({ suggestions: ['foo', 'bar'] })
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits select with the suggestion when an item is clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onSelect).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
|
||||
it('updates modelValue to the suggestion label on selection', async () => {
|
||||
const onUpdateModelValue = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({
|
||||
suggestions: ['foo', 'bar'],
|
||||
'onUpdate:modelValue': onUpdateModelValue
|
||||
})
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with optionLabel', () => {
|
||||
it('displays the optionLabel property as the suggestion text', () => {
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query' })
|
||||
expect(screen.getByText('my-extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits the full item object on selection when optionLabel is set', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query', onSelect })
|
||||
await user.click(screen.getByText('my-extension'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -65,36 +65,34 @@
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
@@ -107,7 +105,6 @@ import {
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile && !showLeftPanel"
|
||||
size="lg"
|
||||
|
||||
@@ -117,7 +117,7 @@ export const useAuthActions = () => {
|
||||
|
||||
const accessBillingPortal = wrapWithErrorHandlingAsync<
|
||||
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
|
||||
boolean
|
||||
void
|
||||
>(async (targetTier, openInNewTab = true) => {
|
||||
const response = await authStore.accessBillingPortal(targetTier)
|
||||
if (!response.billing_portal_url) {
|
||||
@@ -128,11 +128,10 @@ export const useAuthActions = () => {
|
||||
)
|
||||
}
|
||||
if (openInNewTab) {
|
||||
return window.open(response.billing_portal_url, '_blank') !== null
|
||||
window.open(response.billing_portal_url, '_blank')
|
||||
} else {
|
||||
globalThis.location.href = response.billing_portal_url
|
||||
}
|
||||
|
||||
globalThis.location.href = response.billing_portal_url
|
||||
return true
|
||||
}, reportError)
|
||||
|
||||
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
@@ -20,7 +20,27 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
|
||||
function seedSimpleError(
|
||||
store: ReturnType<typeof useExecutionErrorStore>,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
) {
|
||||
store.lastNodeErrors = {
|
||||
[executionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
@@ -43,7 +63,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -55,7 +75,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -74,7 +94,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.OUTPUT,
|
||||
@@ -96,7 +116,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
|
||||
seedSimpleError(store, String(node.id), 'model')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -241,11 +261,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
||||
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
||||
// with that name so the slot-name filter matches.
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
interiorExecId,
|
||||
promotedWidget!.name
|
||||
)
|
||||
seedSimpleError(store, interiorExecId, promotedWidget!.name)
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
@@ -284,7 +300,7 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
// Verify the hooks actually work
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
|
||||
seedSimpleError(store, String(lateNode.id), 'value')
|
||||
|
||||
lateNode.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
|
||||
@@ -86,129 +86,6 @@ describe('useNodeDragAndDrop', () => {
|
||||
expect(isDragging).toBe(false)
|
||||
})
|
||||
|
||||
describe('claimEvent flag', () => {
|
||||
function createClaimableEvent(
|
||||
options: Parameters<typeof createDragEvent>[0]
|
||||
) {
|
||||
const event = createDragEvent(options)
|
||||
const preventDefault = vi.fn()
|
||||
const stopPropagation = vi.fn()
|
||||
Object.assign(event, { preventDefault, stopPropagation })
|
||||
return { event, preventDefault, stopPropagation }
|
||||
}
|
||||
|
||||
it('claims the event synchronously before awaiting onDrop for valid file drops', async () => {
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
files: [createFile('a.png')]
|
||||
})
|
||||
|
||||
const onDrop = vi.fn().mockImplementation(async () => {
|
||||
// By the time onDrop runs, the event must already be claimed —
|
||||
// claiming after the await would let document fallback handlers fire.
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1)
|
||||
return []
|
||||
})
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop })
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onDrop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not claim the event when files are filtered out', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, {
|
||||
onDrop: vi.fn().mockResolvedValue([]),
|
||||
fileFilter: (file) => file.type === 'image/png'
|
||||
})
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
files: [createFile('a.jpg', 'image/jpeg')]
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the event for same-origin uri drops before fetching', async () => {
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
uri: `${location.origin}/api/file?filename=uri.png`,
|
||||
types: ['text/uri-list']
|
||||
})
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1)
|
||||
return fromAny<Response, unknown>({
|
||||
ok: true,
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
|
||||
})
|
||||
})
|
||||
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('does not claim the event for cross-origin uri drops', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
uri: 'https://example.com/api/file?filename=uri.png',
|
||||
types: ['text/uri-list']
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim the event when drop has no files and no uri', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent(
|
||||
{}
|
||||
)
|
||||
|
||||
const result = await node.onDragDrop?.(event, true)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim the event when claimEvent is omitted', async () => {
|
||||
const node = createNode()
|
||||
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
|
||||
|
||||
const { event, preventDefault, stopPropagation } = createClaimableEvent({
|
||||
files: [createFile('a.png')]
|
||||
})
|
||||
|
||||
const result = await node.onDragDrop?.(event)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('onDragDrop calls onDrop with filtered files', async () => {
|
||||
const onDrop = vi.fn().mockResolvedValue([])
|
||||
const node = createNode()
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToInitializeLoad3dViewer'
|
||||
'toastMessages.failedToInitializeLoad3d'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -346,39 +346,6 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserves existing node callbacks through initializeLoad3d', () => {
|
||||
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
|
||||
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
|
||||
// unregisters the component widget from the DOM widget store. If
|
||||
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
|
||||
// cleanup is lost and the interactive UI persists with a stale reference.
|
||||
it('chains node.onRemoved with a preexisting callback', async () => {
|
||||
const existingOnRemoved = vi.fn()
|
||||
mockNode.onRemoved = existingOnRemoved
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('chains node.onResize with a preexisting callback', async () => {
|
||||
const existingOnResize = vi.fn()
|
||||
mockNode.onResize = existingOnResize
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.onResize?.([512, 512] as Size)
|
||||
|
||||
expect(existingOnResize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForLoad3d', () => {
|
||||
it('should execute callback immediately if Load3d exists', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { MaybeRef } from 'vue'
|
||||
import { toRef } from '@vueuse/core'
|
||||
import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
@@ -115,32 +114,30 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
|
||||
node.onMouseEnter = function () {
|
||||
load3d?.refreshViewport()
|
||||
|
||||
load3d?.updateStatusMouseOnNode(true)
|
||||
})
|
||||
}
|
||||
|
||||
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
|
||||
node.onMouseLeave = function () {
|
||||
load3d?.updateStatusMouseOnNode(false)
|
||||
})
|
||||
}
|
||||
|
||||
node.onResize = useChainCallback(node.onResize, () => {
|
||||
node.onResize = function () {
|
||||
load3d?.handleResize()
|
||||
})
|
||||
}
|
||||
|
||||
node.onDrawBackground = useChainCallback(
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
node.onDrawBackground = function () {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
node.onRemoved = function () {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
})
|
||||
}
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
@@ -158,9 +155,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.failedToInitializeLoad3dViewer')
|
||||
)
|
||||
useToastStore().addAlert(t('toastMessages.failedToInitializeLoad3d'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -430,17 +430,6 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should sync hover state when mouseenter fires before init', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
viewer.handleMouseEnter()
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreInitialState', () => {
|
||||
@@ -478,23 +467,6 @@ describe('useLoad3dViewer', () => {
|
||||
.intensity
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve unknown fields on Model Config when restoring', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
;(
|
||||
mockNode.properties!['Model Config'] as Record<string, unknown>
|
||||
).futureField = 'preserve-me'
|
||||
|
||||
viewer.restoreInitialState()
|
||||
|
||||
expect(
|
||||
(mockNode.properties!['Model Config'] as Record<string, unknown>)
|
||||
.futureField
|
||||
).toBe('preserve-me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyChanges', () => {
|
||||
@@ -548,23 +520,6 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve unknown fields on Model Config when applying', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
;(
|
||||
mockNode.properties!['Model Config'] as Record<string, unknown>
|
||||
).futureField = 'preserve-me'
|
||||
|
||||
await viewer.applyChanges()
|
||||
|
||||
expect(
|
||||
(mockNode.properties!['Model Config'] as Record<string, unknown>)
|
||||
.futureField
|
||||
).toBe('preserve-me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshViewport', () => {
|
||||
|
||||
@@ -86,7 +86,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
let load3d: Load3d | null = null
|
||||
let sourceLoad3d: Load3d | null = null
|
||||
let currentModelUrl: string | null = null
|
||||
let mouseOnViewer = false
|
||||
|
||||
const initialState = ref<Load3dViewerState>({
|
||||
backgroundColor: '#282828',
|
||||
@@ -305,10 +304,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isViewerMode: hasTargetDimensions
|
||||
})
|
||||
|
||||
if (mouseOnViewer) {
|
||||
load3d.updateStatusMouseOnViewer(true)
|
||||
}
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, load3d)
|
||||
|
||||
const sourceCameraState = source.getCameraState()
|
||||
@@ -421,10 +416,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isViewerMode: true
|
||||
})
|
||||
|
||||
if (mouseOnViewer) {
|
||||
load3d.updateStatusMouseOnViewer(true)
|
||||
}
|
||||
|
||||
await load3d.loadModel(modelUrl)
|
||||
currentModelUrl = modelUrl
|
||||
restoreStandaloneConfig(modelUrl)
|
||||
@@ -531,7 +522,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
* Notifies the viewer that the mouse has entered the viewer area.
|
||||
*/
|
||||
const handleMouseEnter = () => {
|
||||
mouseOnViewer = true
|
||||
load3d?.updateStatusMouseOnViewer(true)
|
||||
}
|
||||
|
||||
@@ -539,7 +529,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
* Notifies the viewer that the mouse has left the viewer area.
|
||||
*/
|
||||
const handleMouseLeave = () => {
|
||||
mouseOnViewer = false
|
||||
load3d?.updateStatusMouseOnViewer(false)
|
||||
}
|
||||
|
||||
@@ -570,11 +559,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
intensity: initialState.value.lightIntensity
|
||||
}
|
||||
|
||||
const existingModelConfig = nodeValue.properties['Model Config'] as
|
||||
| ModelConfig
|
||||
| undefined
|
||||
nodeValue.properties['Model Config'] = {
|
||||
...existingModelConfig,
|
||||
upDirection: initialState.value.upDirection,
|
||||
materialMode: initialState.value.materialMode
|
||||
}
|
||||
@@ -618,11 +603,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
intensity: lightIntensity.value
|
||||
}
|
||||
|
||||
const existingModelConfig = nodeValue.properties['Model Config'] as
|
||||
| ModelConfig
|
||||
| undefined
|
||||
nodeValue.properties['Model Config'] = {
|
||||
...existingModelConfig,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
}
|
||||
@@ -746,7 +727,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (isStandaloneMode.value) {
|
||||
saveStandaloneConfig()
|
||||
}
|
||||
mouseOnViewer = false
|
||||
load3d?.remove()
|
||||
load3d = null
|
||||
sourceLoad3d = null
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
/** Whether the resolved source widget is workflow-persistent. */
|
||||
readonly sourceSerialize: boolean
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
@@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
const promotedSourceWriteMetaByGraph = new WeakMap<
|
||||
LGraph,
|
||||
Map<string, PromotedSourceWriteMeta>
|
||||
>()
|
||||
|
||||
interface PromotedSourceWriteMeta {
|
||||
value: IBaseWidget['value']
|
||||
writerInstanceId: string
|
||||
}
|
||||
|
||||
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
|
||||
value: TValue
|
||||
): TValue {
|
||||
return value != null && typeof value === 'object'
|
||||
? (JSON.parse(JSON.stringify(value)) as TValue)
|
||||
: value
|
||||
}
|
||||
|
||||
function getPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string
|
||||
): PromotedSourceWriteMeta | undefined {
|
||||
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
|
||||
}
|
||||
|
||||
function setPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string,
|
||||
meta: PromotedSourceWriteMeta
|
||||
): void {
|
||||
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
|
||||
if (!metaBySource) {
|
||||
metaBySource = new Map<string, PromotedSourceWriteMeta>()
|
||||
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
|
||||
}
|
||||
metaBySource.set(sourceKey, meta)
|
||||
}
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
@@ -117,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
/**
|
||||
* Whether the resolved source widget is workflow-persistent.
|
||||
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
|
||||
* whose source sets serialize = false.
|
||||
*/
|
||||
get sourceSerialize(): boolean {
|
||||
return this.resolveDeepest()?.widget.serialize !== false
|
||||
}
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
@@ -198,52 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
private get _instanceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
private get _sharedSourceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution-time serialization — returns the per-instance value stored
|
||||
* during configure, falling back to the regular value getter.
|
||||
*
|
||||
* The widget state store is shared across instances (keyed by inner node
|
||||
* ID), so the regular getter returns the last-configured value for all
|
||||
* instances. graphToPrompt already prefers serializeValue over .value,
|
||||
* so this is the hook that makes multi-instance execution correct.
|
||||
*/
|
||||
serializeValue(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.captureSiblingFallbackValues()
|
||||
|
||||
// Keep per-instance map in sync for execution (graphToPrompt)
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(value)
|
||||
)
|
||||
setPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey,
|
||||
{
|
||||
value: cloneWidgetValue(value),
|
||||
writerInstanceId: String(this.subgraphNode.id)
|
||||
}
|
||||
)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
@@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getTrackedValue(): IBaseWidget['value'] {
|
||||
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
|
||||
this._instanceKey
|
||||
)
|
||||
const sharedValue = this.getSharedValue()
|
||||
|
||||
if (instanceValue === undefined) return sharedValue
|
||||
|
||||
const sourceWriteMeta = getPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey
|
||||
)
|
||||
if (
|
||||
sharedValue !== undefined &&
|
||||
sourceWriteMeta &&
|
||||
!isEqual(sharedValue, sourceWriteMeta.value)
|
||||
) {
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(sharedValue)
|
||||
)
|
||||
return sharedValue
|
||||
}
|
||||
|
||||
return instanceValue as IBaseWidget['value']
|
||||
}
|
||||
|
||||
private getSharedValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
@@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private captureSiblingFallbackValues(): void {
|
||||
const { rootGraph } = this.subgraphNode
|
||||
|
||||
for (const node of rootGraph.nodes) {
|
||||
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
|
||||
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
|
||||
if (node._instanceWidgetValues.has(this._instanceKey)) continue
|
||||
|
||||
const siblingView = node.widgets.find(
|
||||
(widget): widget is IPromotedWidgetView =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId === this.sourceNodeId &&
|
||||
widget.sourceWidgetName === this.sourceWidgetName &&
|
||||
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!siblingView) continue
|
||||
|
||||
node._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(siblingView.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -9,6 +9,9 @@ import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
@@ -182,6 +185,45 @@ describe('Autogrow', () => {
|
||||
await nextTick()
|
||||
expect(node.inputs.length).toBe(5)
|
||||
})
|
||||
test('Removing a connection ignores stale autogrow callbacks after group removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode() as TestAutogrowNode
|
||||
const onConnectionsChange = vi.fn()
|
||||
node.onConnectionsChange = onConnectionsChange
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
|
||||
try {
|
||||
connectInput(node, 0, graph)
|
||||
expect(node.inputs.length).toBe(2)
|
||||
|
||||
rafCallbacks.shift()?.(0)
|
||||
|
||||
node.disconnectInput(0)
|
||||
|
||||
const staleDisconnectCallback = rafCallbacks.shift()
|
||||
expect(staleDisconnectCallback).toBeDefined()
|
||||
|
||||
delete node.comfyDynamic.autogrow['0']
|
||||
|
||||
const callbackCountBeforeFlush = onConnectionsChange.mock.calls.length
|
||||
staleDisconnectCallback?.(0)
|
||||
|
||||
expect(onConnectionsChange).toHaveBeenCalledTimes(
|
||||
callbackCountBeforeFlush
|
||||
)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
test('Multi-group autogrow shifts second group indices on first group growth', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
|
||||
@@ -464,7 +464,10 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
const autogrowGroup = node.comfyDynamic.autogrow[groupName]
|
||||
if (!autogrowGroup) return
|
||||
|
||||
const { min = 1, inputSpecs } = autogrowGroup
|
||||
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
|
||||
if (ordinal == undefined || ordinal + 1 < min) return
|
||||
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
// For each output link copy our value over the original widget value
|
||||
for (const linkInfo of links) {
|
||||
const node = this.graph?.getNodeById(linkInfo.target_id)
|
||||
const input = node?.inputs[linkInfo.target_slot]
|
||||
if (!input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) {
|
||||
console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`)
|
||||
continue
|
||||
}
|
||||
|
||||
widget.value = v
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
node,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
applyFirstWidgetValueToGraph(this, extraLinks)
|
||||
}
|
||||
|
||||
function onCustomComboCreated(this: LGraphNode) {
|
||||
|
||||
@@ -517,16 +517,15 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
model.position.set(-center.x, -box.min.y, -center.z)
|
||||
|
||||
this.scene.add(model)
|
||||
const pendingMaterialMode = this.materialMode
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
if (pendingMaterialMode !== 'original') {
|
||||
this.setMaterialMode(pendingMaterialMode)
|
||||
if (this.materialMode !== 'original') {
|
||||
this.setMaterialMode(this.materialMode)
|
||||
}
|
||||
|
||||
if (this.currentUpDirection !== 'original') {
|
||||
this.setUpDirection(this.currentUpDirection)
|
||||
}
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
this.setupCamera(size)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
|
||||
import { mergeInputSpec } from '@/utils/nodeDefUtil'
|
||||
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
const replacePropertyName = 'Run widget replace on values'
|
||||
export class PrimitiveNode extends LGraphNode {
|
||||
controlValues?: TWidgetValue[]
|
||||
@@ -43,49 +45,15 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
override applyToGraph(extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
const sourceWidget = this.widgets?.[0]
|
||||
const graph = this.graph
|
||||
if (!sourceWidget || !graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
let v = sourceWidget.value
|
||||
if (v && this.properties[replacePropertyName]) {
|
||||
v = applyTextReplacements(this.graph, v as string)
|
||||
}
|
||||
|
||||
// For each output link copy our value over the original widget value
|
||||
for (const linkInfo of links) {
|
||||
const node = this.graph?.getNodeById(linkInfo.target_id)
|
||||
const input = node?.inputs[linkInfo.target_slot]
|
||||
if (!input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) {
|
||||
console.warn(
|
||||
`Unable to find widget "${widgetName}" on node [${node.id}]`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
widget.value = v
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
node,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
v = applyTextReplacements(graph, v as string)
|
||||
}
|
||||
applyFirstWidgetValueToGraph(this, extraLinks, () => v)
|
||||
}
|
||||
|
||||
override refreshComboInNode() {
|
||||
@@ -98,7 +66,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
if (!widget.options.values.includes(widget.value as string)) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
widget.value = widget.options.values[0]
|
||||
;(widget.callback as Function)(widget.value)
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +241,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
)
|
||||
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
|
||||
|
||||
let filter = this.widgets_values?.[2]
|
||||
const filter = this.widgets_values?.[2]
|
||||
if (filter && this.widgets && this.widgets.length === 3) {
|
||||
this.widgets[2].value = filter
|
||||
}
|
||||
|
||||
127
src/extensions/core/widgetValuePropagation.test.ts
Normal file
127
src/extensions/core/widgetValuePropagation.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
graph_mouse: [0, 0]
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
|
||||
|
||||
function createWidget(
|
||||
name: string,
|
||||
value: IBaseWidget['value'],
|
||||
callback = vi.fn()
|
||||
): IBaseWidget {
|
||||
return fromPartial<IBaseWidget>({
|
||||
name,
|
||||
value,
|
||||
callback
|
||||
})
|
||||
}
|
||||
|
||||
function createTargetNode(
|
||||
widget: IBaseWidget,
|
||||
id = 7
|
||||
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
|
||||
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
|
||||
id,
|
||||
inputs: [
|
||||
fromPartial<INodeInputSlot>({
|
||||
widget: { name: widget.name }
|
||||
})
|
||||
],
|
||||
widgets: [widget]
|
||||
})
|
||||
}
|
||||
|
||||
function createLink(targetId: LLink['target_id'], targetSlot = 0): LLink {
|
||||
return createMockLLink({
|
||||
target_id: targetId,
|
||||
target_slot: targetSlot
|
||||
})
|
||||
}
|
||||
|
||||
function createSourceNode(options: {
|
||||
link: LLink
|
||||
targetNode: Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
|
||||
widgets?: IBaseWidget[]
|
||||
}): SourceNode {
|
||||
return {
|
||||
graph: {
|
||||
links: { 1: options.link },
|
||||
getNodeById: vi.fn((id: LLink['target_id']) =>
|
||||
id === options.targetNode.id ? options.targetNode : null
|
||||
)
|
||||
} as unknown as NonNullable<LGraphNode['graph']>,
|
||||
outputs: [{ links: [1] } as INodeOutputSlot],
|
||||
widgets: options.widgets ?? []
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyFirstWidgetValueToGraph', () => {
|
||||
it('returns early when the source widget is missing', () => {
|
||||
const targetCallback = vi.fn()
|
||||
const targetWidget = createWidget('value', 'unchanged', targetCallback)
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode
|
||||
})
|
||||
|
||||
expect(() => applyFirstWidgetValueToGraph(sourceNode)).not.toThrow()
|
||||
expect(targetWidget.value).toBe('unchanged')
|
||||
expect(targetCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates the first widget value to the linked widget', () => {
|
||||
const targetCallback = vi.fn()
|
||||
const targetWidget = createWidget('value', 'old', targetCallback)
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode,
|
||||
widgets: [createWidget('source', 'new value')]
|
||||
})
|
||||
|
||||
applyFirstWidgetValueToGraph(sourceNode)
|
||||
|
||||
expect(targetWidget.value).toBe('new value')
|
||||
expect(targetCallback).toHaveBeenCalledOnce()
|
||||
expect(targetCallback).toHaveBeenCalledWith(
|
||||
'new value',
|
||||
expect.anything(),
|
||||
targetNode,
|
||||
[0, 0],
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('applies a transform before propagating the widget value', () => {
|
||||
const targetWidget = createWidget('value', 'old')
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode,
|
||||
widgets: [createWidget('source', 'draft')]
|
||||
})
|
||||
|
||||
applyFirstWidgetValueToGraph(sourceNode, [], (value) => `${value}-saved`)
|
||||
|
||||
expect(targetWidget.value).toBe('draft-saved')
|
||||
})
|
||||
})
|
||||
68
src/extensions/core/widgetValuePropagation.ts
Normal file
68
src/extensions/core/widgetValuePropagation.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
|
||||
|
||||
export function applyFirstWidgetValueToGraph(
|
||||
node: SourceNode,
|
||||
extraLinks: LLink[] = [],
|
||||
transformValue?: (value: TWidgetValue) => TWidgetValue
|
||||
) {
|
||||
const output = node.outputs[0]
|
||||
if (!output?.links?.length || !node.graph) return
|
||||
|
||||
const sourceWidget = node.widgets?.[0]
|
||||
if (!sourceWidget) return
|
||||
|
||||
let value = sourceWidget.value
|
||||
if (transformValue) {
|
||||
value = transformValue(value)
|
||||
}
|
||||
|
||||
const graphMouse: Point = app.canvas?.graph_mouse ?? [0, 0]
|
||||
|
||||
const links = [
|
||||
...output.links.map((linkId) => node.graph!.links[linkId]),
|
||||
...extraLinks
|
||||
]
|
||||
|
||||
for (const linkInfo of links) {
|
||||
if (!linkInfo) continue
|
||||
|
||||
const targetNode = node.graph.getNodeById(linkInfo.target_id)
|
||||
const input = targetNode?.inputs[linkInfo.target_slot]
|
||||
if (!targetNode || !input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetWidget = targetNode.widgets?.find(
|
||||
(widget) => widget.name === widgetName
|
||||
)
|
||||
if (!targetWidget) {
|
||||
console.warn(
|
||||
`Unable to find widget "${widgetName}" on node [${targetNode.id}]`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
targetWidget.value = value
|
||||
targetWidget.callback?.(
|
||||
targetWidget.value,
|
||||
app.canvas,
|
||||
targetNode,
|
||||
graphMouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
static override type = TEST_NODE_TYPE
|
||||
|
||||
constructor(title?: string) {
|
||||
super(title ?? TEST_NODE_TYPE)
|
||||
this.type = TEST_NODE_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} satisfies Partial<CanvasRenderingContext2D>
|
||||
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
function createLayoutEntry(node: LGraphNode, zIndex: number) {
|
||||
const nodeId = String(node.id)
|
||||
const layout: NodeLayout = {
|
||||
id: nodeId,
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
|
||||
layoutStore.applyOperation({
|
||||
type: 'setNodeZIndex',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
zIndex,
|
||||
previousZIndex,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
describe('cloned node z-index in Vue renderer', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let previousVueNodesMode: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
previousVueNodesMode = LiteGraph.vueNodesMode
|
||||
LiteGraph.vueNodesMode = true
|
||||
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = createCanvas(graph)
|
||||
LGraphCanvas.active_canvas = canvas
|
||||
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
|
||||
// Simulate Vue runtime: create layout entries when nodes are added
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
createLayoutEntry(node, 0)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = previousVueNodesMode
|
||||
})
|
||||
|
||||
it('places cloned nodes above the original node z-index', () => {
|
||||
const originalNode = new TestNode()
|
||||
originalNode.pos = [100, 100]
|
||||
originalNode.size = [200, 100]
|
||||
graph.add(originalNode)
|
||||
|
||||
const originalNodeId = String(originalNode.id)
|
||||
|
||||
setZIndex(originalNodeId, 5, 0)
|
||||
|
||||
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
|
||||
expect(originalLayout?.zIndex).toBe(5)
|
||||
|
||||
// Clone the node via cloneNodes (same path as right-click > clone)
|
||||
const result = LGraphCanvas.cloneNodes([originalNode])
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.created.length).toBe(1)
|
||||
|
||||
const clonedNode = result!.created[0] as LGraphNode
|
||||
const clonedNodeId = String(clonedNode.id)
|
||||
|
||||
// The cloned node should have a z-index higher than the original
|
||||
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
|
||||
expect(clonedLayout).toBeDefined()
|
||||
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
|
||||
})
|
||||
|
||||
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
|
||||
const nodeA = new TestNode()
|
||||
nodeA.pos = [100, 100]
|
||||
nodeA.size = [200, 100]
|
||||
graph.add(nodeA)
|
||||
setZIndex(String(nodeA.id), 3, 0)
|
||||
|
||||
const nodeB = new TestNode()
|
||||
nodeB.pos = [400, 100]
|
||||
nodeB.size = [200, 100]
|
||||
graph.add(nodeB)
|
||||
setZIndex(String(nodeB.id), 7, 0)
|
||||
|
||||
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.created.length).toBe(2)
|
||||
|
||||
const clonedA = result!.created[0] as LGraphNode
|
||||
const clonedB = result!.created[1] as LGraphNode
|
||||
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
|
||||
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
|
||||
|
||||
// Both cloned nodes should be above the highest original (z-index 7)
|
||||
expect(layoutA.zIndex).toBeGreaterThan(7)
|
||||
expect(layoutB.zIndex).toBeGreaterThan(7)
|
||||
|
||||
// Each cloned node should have a distinct z-index
|
||||
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -4285,17 +4284,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
// Bring cloned/pasted nodes to front so they render above the originals
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
let maxZIndex = 0
|
||||
for (const [, layout] of allNodes) {
|
||||
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
|
||||
}
|
||||
const { setNodeZIndex } = useLayoutMutations()
|
||||
for (let i = 0; i < newPositions.length; i++) {
|
||||
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
|
||||
}
|
||||
|
||||
this.selectItems(created)
|
||||
forEachNode(graph, (n) => n.onGraphConfigured?.())
|
||||
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
|
||||
|
||||
@@ -186,16 +186,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
if (!widget) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
// Prefer serializeValue (per-instance) over the shared .value getter
|
||||
// so multiple SubgraphNode instances return their own configured values.
|
||||
const widgetValue = widget.serializeValue
|
||||
? widget.serializeValue(subgraphNode, -1)
|
||||
: widget.value
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widgetValue }
|
||||
widgetInfo: { value: widget.value }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: number = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 7)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
|
||||
instance1.graph!.add(instance1)
|
||||
instance2.graph!.add(instance2)
|
||||
|
||||
const widget1 = instance1.widgets?.[0]
|
||||
const widget2 = instance2.widgets?.[0]
|
||||
|
||||
expect(widget1?.value).toBe(7)
|
||||
expect(widget2?.value).toBe(7)
|
||||
|
||||
widget1!.value = 10
|
||||
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(7)
|
||||
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
|
||||
})
|
||||
|
||||
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 602,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
|
||||
widget.value = 45
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(45)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(45)
|
||||
})
|
||||
|
||||
it('clears stale per-instance values when reconfigured without widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
widget.value = 17
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
id: instance.id,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(instance.widgets?.[0].value).toBe(17)
|
||||
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -994,21 +994,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
/**
|
||||
* Per-instance promoted widget values.
|
||||
* Multiple SubgraphNode instances share the same inner nodes, so
|
||||
* promoted widget values must be stored per-instance to avoid collisions.
|
||||
* Key: `${sourceNodeId}:${sourceWidgetName}`
|
||||
*/
|
||||
readonly _instanceWidgetValues = new Map<string, unknown>()
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
this._instanceWidgetValues.clear()
|
||||
this._pendingWidgetsValues = info.widgets_values
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
@@ -1139,21 +1125,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
// Hydrate per-instance promoted widget values from serialized data.
|
||||
// LGraphNode.configure skips promoted widgets (serialize === false on
|
||||
// the view), so they must be applied here after promoted views exist.
|
||||
// Only iterate serializable views to match what serialize() wrote.
|
||||
if (this._pendingWidgetsValues) {
|
||||
const views = this._getPromotedViews()
|
||||
let i = 0
|
||||
for (const view of views) {
|
||||
if (!view.sourceSerialize) continue
|
||||
if (i >= this._pendingWidgetsValues.length) break
|
||||
view.value = this._pendingWidgetsValues[i++] as typeof view.value
|
||||
}
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1548,7 +1519,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._instanceWidgetValues.clear()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1604,7 +1574,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
@@ -1612,22 +1603,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
const serialized = super.serialize()
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
if (serializableViews.length > 0) {
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.serializeValue
|
||||
? view.serializeValue(this, -1)
|
||||
: view.value
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: (value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
return serialized
|
||||
return super.serialize()
|
||||
}
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
@@ -2707,7 +2707,6 @@
|
||||
"noReleaseNotes": "No release notes available."
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"blueprint": "Blueprint",
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
@@ -2763,55 +2762,51 @@
|
||||
"survey": {
|
||||
"title": "Cloud Survey",
|
||||
"placeholder": "Survey questions placeholder",
|
||||
"intro": "Help us tailor your ComfyUI experience.",
|
||||
"errors": {
|
||||
"chooseAnOption": "Please choose an option.",
|
||||
"selectAtLeastOne": "Please select at least one option.",
|
||||
"describeAnswer": "Please describe your answer."
|
||||
},
|
||||
"steps": {
|
||||
"usage": "How do you plan to use ComfyUI?",
|
||||
"familiarity": "How familiar are you with ComfyUI?",
|
||||
"intent": "What do you want to create with ComfyUI?",
|
||||
"source": "Where did you hear about ComfyUI?"
|
||||
"purpose": "What will you primarily use ComfyUI for?",
|
||||
"industry": "What's your primary industry?",
|
||||
"making": "What do you plan on making?"
|
||||
},
|
||||
"questions": {
|
||||
"familiarity": "How familiar are you with ComfyUI?",
|
||||
"purpose": "What will you primarily use ComfyUI for?",
|
||||
"industry": "What's your primary industry?",
|
||||
"making": "What do you plan on making?"
|
||||
},
|
||||
"options": {
|
||||
"usage": {
|
||||
"personal": "Personal use",
|
||||
"work": "Work",
|
||||
"education": "Education (student or educator)"
|
||||
},
|
||||
"familiarity": {
|
||||
"new": "New — never used it",
|
||||
"starting": "Beginner — following tutorials",
|
||||
"basics": "Intermediate — comfortable with basics",
|
||||
"advanced": "Advanced — build and edit workflows",
|
||||
"expert": "Expert — I help others"
|
||||
"new": "New to ComfyUI (never used it before)",
|
||||
"starting": "Just getting started (following tutorials)",
|
||||
"basics": "Comfortable with basics",
|
||||
"advanced": "Advanced user (custom workflows)",
|
||||
"expert": "Expert (help others)"
|
||||
},
|
||||
"intent": {
|
||||
"workflows": "Custom workflows or pipelines",
|
||||
"custom_nodes": "Custom nodes",
|
||||
"videos": "Videos",
|
||||
"purpose": {
|
||||
"personal": "Personal projects / hobby",
|
||||
"community": "Community contributions (nodes, workflows, etc.)",
|
||||
"client": "Client work (freelance)",
|
||||
"inhouse": "My own workplace (in-house)",
|
||||
"research": "Academic research"
|
||||
},
|
||||
"industry": {
|
||||
"film_tv_animation": "Film, TV, & animation",
|
||||
"gaming": "Gaming",
|
||||
"marketing": "Marketing & advertising",
|
||||
"architecture": "Architecture",
|
||||
"product_design": "Product & graphic design",
|
||||
"fine_art": "Fine art & illustration",
|
||||
"software": "Software & technology",
|
||||
"education": "Education",
|
||||
"other": "Other",
|
||||
"otherPlaceholder": "Please specify"
|
||||
},
|
||||
"making": {
|
||||
"images": "Images",
|
||||
"3d_game": "3D assets / game assets",
|
||||
"video": "Video & animation",
|
||||
"3d": "3D assets",
|
||||
"audio": "Audio / music",
|
||||
"apps": "Simplified Apps from workflows",
|
||||
"api": "API endpoints to run workflows",
|
||||
"not_sure": "Not sure"
|
||||
},
|
||||
"source": {
|
||||
"youtube": "YouTube",
|
||||
"reddit": "Reddit",
|
||||
"twitter": "Twitter / X",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"friend": "Friend or colleague",
|
||||
"search": "Google / search",
|
||||
"newsletter": "Newsletter or blog",
|
||||
"conference": "Conference or event",
|
||||
"discord": "Discord / community",
|
||||
"github": "GitHub",
|
||||
"other": "Other"
|
||||
"custom_nodes": "Custom nodes & workflows"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2881,10 +2876,10 @@
|
||||
"cloudForgotPassword_emailRequired": "Email is required",
|
||||
"cloudForgotPassword_passwordResetSent": "Password reset sent",
|
||||
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
|
||||
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
|
||||
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
|
||||
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
|
||||
"cloudSurvey_steps_industry": "What's your primary industry?",
|
||||
"cloudSurvey_steps_making": "What do you plan on making?",
|
||||
"assetBrowser": {
|
||||
"allCategory": "All {category}",
|
||||
"allModels": "All Models",
|
||||
|
||||
@@ -75,7 +75,7 @@ export const Default: Story = {
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:override-assets="assets"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
@@ -116,7 +116,7 @@ export const SingleAssetType: Story = {
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:override-assets="assets"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
@@ -159,7 +159,7 @@ export const NoLeftPanel: Story = {
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:override-assets="assets"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
|
||||
@@ -117,12 +117,6 @@ const props = defineProps<{
|
||||
onClose?: () => void
|
||||
showLeftPanel?: boolean
|
||||
title?: string
|
||||
/**
|
||||
* Storybook/test seam: when provided, bypasses the cloud-only
|
||||
* `assetsStore.getAssets(cacheKey)` fetch and renders this list directly.
|
||||
* Production callers should leave this undefined and rely on the store.
|
||||
*/
|
||||
overrideAssets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -138,9 +132,7 @@ const cacheKey = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const fetchedAssets = computed(
|
||||
() => props.overrideAssets ?? assetStore.getAssets(cacheKey.value)
|
||||
)
|
||||
const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value))
|
||||
|
||||
const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value))
|
||||
|
||||
@@ -149,7 +141,6 @@ const isLoading = computed(
|
||||
)
|
||||
|
||||
async function refreshAssets(): Promise<void> {
|
||||
if (props.overrideAssets) return
|
||||
if (props.nodeType) {
|
||||
await assetStore.updateModelsForNodeType(props.nodeType)
|
||||
} else if (props.assetType) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { render } from '@testing-library/vue'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PropType } from 'vue'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
@@ -33,7 +31,6 @@ vi.mock('@/utils/loaderNodeUtil', () => ({
|
||||
const mediaAssetActions = {
|
||||
addWorkflow: vi.fn(),
|
||||
downloadAsset: vi.fn(),
|
||||
downloadMultipleAssets: vi.fn(),
|
||||
openWorkflow: vi.fn(),
|
||||
exportWorkflow: vi.fn(),
|
||||
copyJobId: vi.fn(),
|
||||
@@ -44,18 +41,12 @@ vi.mock('../composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => mediaAssetActions
|
||||
}))
|
||||
|
||||
const capturedMenu = vi.hoisted(() => ({ model: [] as MenuItem[] }))
|
||||
|
||||
const contextMenuStub = defineComponent({
|
||||
name: 'ContextMenu',
|
||||
props: {
|
||||
pt: {
|
||||
type: Object,
|
||||
default: undefined
|
||||
},
|
||||
model: {
|
||||
type: Array as PropType<MenuItem[]>,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['hide'],
|
||||
@@ -64,14 +55,6 @@ const contextMenuStub = defineComponent({
|
||||
visible: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
model: {
|
||||
immediate: true,
|
||||
handler(items: MenuItem[]) {
|
||||
capturedMenu.model = items
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.visible = true
|
||||
@@ -145,24 +128,9 @@ async function showMenu(container: Element): Promise<HTMLElement> {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedRef = null
|
||||
capturedMenu.model = []
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
type MenuItemWithCommand = MenuItem & {
|
||||
command: NonNullable<MenuItem['command']>
|
||||
}
|
||||
|
||||
function findDownloadMenuItem(): MenuItemWithCommand {
|
||||
const downloadItem = capturedMenu.model.find(
|
||||
(item) => item.label === 'mediaAsset.actions.download'
|
||||
)
|
||||
if (!downloadItem?.command) {
|
||||
throw new Error('Download menu item or command was not registered')
|
||||
}
|
||||
return downloadItem as MenuItemWithCommand
|
||||
}
|
||||
|
||||
describe('MediaAssetContextMenu', () => {
|
||||
it('dismisses outside pointerdown using the rendered root id', async () => {
|
||||
const { container, unmount, onHide } = mountComponent()
|
||||
@@ -185,22 +153,4 @@ describe('MediaAssetContextMenu', () => {
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('routes Download through downloadMultipleAssets so multi-output jobs zip', async () => {
|
||||
const { container, unmount } = mountComponent()
|
||||
await showMenu(container)
|
||||
|
||||
const downloadItem = findDownloadMenuItem()
|
||||
downloadItem.command({
|
||||
originalEvent: new MouseEvent('click'),
|
||||
item: downloadItem
|
||||
})
|
||||
|
||||
expect(mediaAssetActions.downloadMultipleAssets).toHaveBeenCalledWith([
|
||||
asset
|
||||
])
|
||||
expect(mediaAssetActions.downloadAsset).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
command: () => actions.downloadMultipleAssets([asset])
|
||||
command: () => actions.downloadAsset(asset)
|
||||
})
|
||||
|
||||
// Separator before workflow actions (only if there are workflow actions)
|
||||
|
||||
@@ -117,7 +117,7 @@ const DialogDemoComponent = {
|
||||
>
|
||||
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
|
||||
<AssetBrowserModal
|
||||
:override-assets="mockAssets"
|
||||
:assets="mockAssets"
|
||||
:node-type="currentNodeType"
|
||||
:input-name="currentInputName"
|
||||
:current-value="currentValue"
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('useAssetBrowserDialog', () => {
|
||||
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
expect(dialogCall.props.nodeType).toBe('CheckpointLoaderSimple')
|
||||
expect(dialogCall.props.overrideAssets).toBeUndefined()
|
||||
expect(dialogCall.props.assets).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,251 @@
|
||||
<template>
|
||||
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
|
||||
<DynamicSurveyForm
|
||||
:key="activeSurvey.version"
|
||||
:survey="activeSurvey"
|
||||
:is-submitting="isSubmitting"
|
||||
@submit="onSubmitSurvey"
|
||||
/>
|
||||
<div>
|
||||
<Stepper
|
||||
value="1"
|
||||
class="flex h-[638px] max-h-[80vh] w-[320px] max-w-[90vw] flex-col"
|
||||
>
|
||||
<ProgressBar
|
||||
:value="progressPercent"
|
||||
:show-value="false"
|
||||
class="mb-8 h-2"
|
||||
/>
|
||||
|
||||
<StepPanels class="flex flex-1 flex-col p-0">
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="1"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_familiarity')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in familiarityOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="surveyData.familiarity"
|
||||
:input-id="`fam-${opt.value}`"
|
||||
name="familiarity"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`fam-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-4">
|
||||
<span />
|
||||
<Button
|
||||
:disabled="!validStep1"
|
||||
class="h-10 w-full border-none text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="2"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_purpose')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in purposeOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="surveyData.useCase"
|
||||
:input-id="`purpose-${opt.value}`"
|
||||
name="purpose"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`purpose-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="surveyData.useCase === 'other'" class="mt-4 ml-8">
|
||||
<InputText
|
||||
v-model="surveyData.useCaseOther"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(1, activateCallback)"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!validStep2"
|
||||
class="h-10 flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="3"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_industry')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in industryOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="surveyData.industry"
|
||||
:input-id="`industry-${opt.value}`"
|
||||
name="industry"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`industry-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="surveyData.industry === 'other'" class="mt-4 ml-8">
|
||||
<InputText
|
||||
v-model="surveyData.industryOther"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!validStep3"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
@click="goTo(4, activateCallback)"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="4"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_making')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in makingOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="surveyData.making"
|
||||
:input-id="`making-${opt.value}`"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`making-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!validStep4 || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
@click="onSubmitSurvey"
|
||||
>
|
||||
{{ $t('g.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
</StepPanels>
|
||||
</Stepper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import StepPanel from 'primevue/steppanel'
|
||||
import StepPanels from 'primevue/steppanels'
|
||||
import Stepper from 'primevue/stepper'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
getSurveyCompletedStatus,
|
||||
submitSurvey
|
||||
} from '@/platform/cloud/onboarding/auth'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import DynamicSurveyForm from './survey/DynamicSurveyForm.vue'
|
||||
import { defaultOnboardingSurvey } from './survey/defaultSurveySchema'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { flags } = useFeatureFlags()
|
||||
const onboardingSurveyEnabled = computed(() => flags.onboardingSurveyEnabled)
|
||||
|
||||
const activeSurvey = computed(
|
||||
() => remoteConfig.value.onboarding_survey ?? defaultOnboardingSurvey
|
||||
)
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// Check if survey is already completed on mount
|
||||
onMounted(async () => {
|
||||
if (!onboardingSurveyEnabled.value) {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
@@ -43,31 +254,156 @@ onMounted(async () => {
|
||||
try {
|
||||
const surveyCompleted = await getSurveyCompletedStatus()
|
||||
if (surveyCompleted) {
|
||||
// User already completed survey, return to onboarding flow
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
return
|
||||
}
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('opened')
|
||||
} else {
|
||||
// Track survey opened event
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('opened')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check survey status:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmitSurvey = async (payload: Record<string, unknown>) => {
|
||||
if (!onboardingSurveyEnabled.value) {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
return
|
||||
const activeStep = ref(1)
|
||||
const totalSteps = 4
|
||||
const progressPercent = computed(() =>
|
||||
Math.max(20, Math.min(100, ((activeStep.value - 1) / (totalSteps - 1)) * 100))
|
||||
)
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const surveyData = ref({
|
||||
familiarity: '',
|
||||
useCase: '',
|
||||
useCaseOther: '',
|
||||
industry: '',
|
||||
industryOther: '',
|
||||
making: [] as string[]
|
||||
})
|
||||
|
||||
// Options
|
||||
const familiarityOptions = [
|
||||
{ label: 'New to ComfyUI (never used it before)', value: 'new' },
|
||||
{ label: 'Just getting started (following tutorials)', value: 'starting' },
|
||||
{ label: 'Comfortable with basics', value: 'basics' },
|
||||
{ label: 'Advanced user (custom workflows)', value: 'advanced' },
|
||||
{ label: 'Expert (help others)', value: 'expert' }
|
||||
]
|
||||
|
||||
const purposeOptions = [
|
||||
{ label: 'Personal projects/hobby', value: 'personal' },
|
||||
{
|
||||
label: 'Community contributions (nodes, workflows, etc.)',
|
||||
value: 'community'
|
||||
},
|
||||
{ label: 'Client work (freelance)', value: 'client' },
|
||||
{ label: 'My own workplace (in-house)', value: 'inhouse' },
|
||||
{ label: 'Academic research', value: 'research' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
|
||||
const industryOptions = [
|
||||
{ label: 'Film, TV, & animation', value: 'film_tv_animation' },
|
||||
{ label: 'Gaming', value: 'gaming' },
|
||||
{ label: 'Marketing & advertising', value: 'marketing' },
|
||||
{ label: 'Architecture', value: 'architecture' },
|
||||
{ label: 'Product & graphic design', value: 'product_design' },
|
||||
{ label: 'Fine art & illustration', value: 'fine_art' },
|
||||
{ label: 'Software & technology', value: 'software' },
|
||||
{ label: 'Education', value: 'education' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
|
||||
const makingOptions = [
|
||||
{ label: 'Images', value: 'images' },
|
||||
{ label: 'Video & animation', value: 'video' },
|
||||
{ label: '3D assets', value: '3d' },
|
||||
{ label: 'Audio/music', value: 'audio' },
|
||||
{ label: 'Custom nodes & workflows', value: 'custom_nodes' }
|
||||
]
|
||||
|
||||
// Validation per step
|
||||
const validStep1 = computed(() => !!surveyData.value.familiarity)
|
||||
const validStep2 = computed(() => {
|
||||
if (!surveyData.value.useCase) return false
|
||||
if (surveyData.value.useCase === 'other') {
|
||||
return !!surveyData.value.useCaseOther?.trim()
|
||||
}
|
||||
isSubmitting.value = true
|
||||
return true
|
||||
})
|
||||
const validStep3 = computed(() => {
|
||||
if (!surveyData.value.industry) return false
|
||||
if (surveyData.value.industry === 'other') {
|
||||
return !!surveyData.value.industryOther?.trim()
|
||||
}
|
||||
return true
|
||||
})
|
||||
const validStep4 = computed(() => surveyData.value.making.length > 0)
|
||||
|
||||
const changeActiveStep = (step: number) => {
|
||||
activeStep.value = step
|
||||
}
|
||||
|
||||
const goTo = (step: number, activate: (val: string | number) => void) => {
|
||||
// keep Stepper panel and progress bar in sync; Stepper values are strings
|
||||
changeActiveStep(step)
|
||||
activate(String(step))
|
||||
}
|
||||
|
||||
// Submit
|
||||
const onSubmitSurvey = async () => {
|
||||
try {
|
||||
await submitSurvey(payload)
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('submitted', payload)
|
||||
if (!onboardingSurveyEnabled.value) {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
return
|
||||
}
|
||||
isSubmitting.value = true
|
||||
// prepare payload with consistent structure
|
||||
const payload = {
|
||||
familiarity: surveyData.value.familiarity,
|
||||
useCase:
|
||||
surveyData.value.useCase === 'other'
|
||||
? surveyData.value.useCaseOther?.trim() || 'other'
|
||||
: surveyData.value.useCase,
|
||||
industry:
|
||||
surveyData.value.industry === 'other'
|
||||
? surveyData.value.industryOther?.trim() || 'other'
|
||||
: surveyData.value.industry,
|
||||
making: surveyData.value.making
|
||||
}
|
||||
|
||||
await submitSurvey(payload)
|
||||
|
||||
// Track survey submitted event with responses
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('submitted', {
|
||||
industry: payload.industry,
|
||||
useCase: payload.useCase,
|
||||
familiarity: payload.familiarity,
|
||||
making: payload.making
|
||||
})
|
||||
}
|
||||
|
||||
await router.push({ name: 'cloud-user-check' })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-progressbar .p-progressbar-value) {
|
||||
background-color: #f0ff41 !important;
|
||||
}
|
||||
:deep(.p-radiobutton-checked .p-radiobutton-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<fieldset
|
||||
v-if="field.type !== 'text'"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
class="flex flex-col gap-4 border-0 p-0"
|
||||
>
|
||||
<legend class="mb-2 block text-lg font-medium text-base-foreground">
|
||||
{{ resolvedLabel }}
|
||||
</legend>
|
||||
<template v-if="field.type === 'single'">
|
||||
<div
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
:input-id="`${field.id}-${option.value}`"
|
||||
:name="field.id"
|
||||
:value="option.value"
|
||||
:dt="checkedTokens"
|
||||
@update:model-value="onSingleChange"
|
||||
/>
|
||||
<label
|
||||
:for="`${field.id}-${option.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ resolveOptionLabel(option) }}</label
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="(modelValue as string[]) ?? []"
|
||||
:input-id="`${field.id}-${option.value}`"
|
||||
:value="option.value"
|
||||
:dt="checkedTokens"
|
||||
@update:model-value="onMultiChange"
|
||||
/>
|
||||
<label
|
||||
:for="`${field.id}-${option.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ resolveOptionLabel(option) }}</label
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<Input
|
||||
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
|
||||
:model-value="(otherValue as string) ?? ''"
|
||||
:placeholder="
|
||||
$t(
|
||||
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
|
||||
$t('cloudOnboarding.survey.otherPlaceholder')
|
||||
)
|
||||
"
|
||||
class="ml-1"
|
||||
@update:model-value="onOtherChange"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
|
||||
</fieldset>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<label
|
||||
:for="controlId"
|
||||
class="block text-lg font-medium text-base-foreground"
|
||||
>
|
||||
{{ resolvedLabel }}
|
||||
</label>
|
||||
<Input
|
||||
:id="controlId"
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
@update:model-value="onTextChange"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import type {
|
||||
LocalizedString,
|
||||
OnboardingSurveyField,
|
||||
OnboardingSurveyOption
|
||||
} from '@/platform/remoteConfig/types'
|
||||
|
||||
const {
|
||||
field,
|
||||
modelValue,
|
||||
otherValue,
|
||||
errorMessage = ''
|
||||
} = defineProps<{
|
||||
field: OnboardingSurveyField
|
||||
modelValue: string | string[] | undefined
|
||||
otherValue?: string
|
||||
errorMessage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | string[]]
|
||||
'update:otherValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { t, te, locale } = useI18n()
|
||||
const controlId = useId()
|
||||
|
||||
const resolveLocalized = (value: LocalizedString): string => {
|
||||
if (typeof value === 'string') return value
|
||||
return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? ''
|
||||
}
|
||||
|
||||
const checkedTokens = {
|
||||
checked: {
|
||||
background: 'var(--color-electric-400)',
|
||||
borderColor: 'var(--color-electric-400)',
|
||||
hoverBackground: 'var(--color-electric-400)',
|
||||
hoverBorderColor: 'var(--color-electric-400)'
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedLabel = (() => {
|
||||
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
|
||||
if (field.label != null) return resolveLocalized(field.label)
|
||||
return field.id
|
||||
})()
|
||||
|
||||
const resolveOptionLabel = (option: OnboardingSurveyOption): string => {
|
||||
if (option.labelKey && te(option.labelKey)) return t(option.labelKey)
|
||||
if (option.label != null) return resolveLocalized(option.label)
|
||||
return option.value
|
||||
}
|
||||
|
||||
const onSingleChange = (value: unknown) => {
|
||||
emit('update:modelValue', typeof value === 'string' ? value : '')
|
||||
}
|
||||
const onMultiChange = (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
emit('update:modelValue', [])
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'update:modelValue',
|
||||
value.filter((v): v is string => typeof v === 'string')
|
||||
)
|
||||
}
|
||||
const onTextChange = (value: string | number | undefined) => {
|
||||
emit('update:modelValue', String(value ?? ''))
|
||||
}
|
||||
const onOtherChange = (value: string | number | undefined) => {
|
||||
emit('update:otherValue', String(value ?? ''))
|
||||
}
|
||||
</script>
|
||||
@@ -1,320 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import DynamicSurveyForm from './DynamicSurveyForm.vue'
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: 'Help us tailor your ComfyUI experience.',
|
||||
errors: {
|
||||
chooseAnOption: 'Please choose an option.',
|
||||
selectAtLeastOne: 'Please select at least one option.',
|
||||
describeAnswer: 'Please describe your answer.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const renderForm = (survey: OnboardingSurvey) =>
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, i18n] },
|
||||
props: { survey }
|
||||
})
|
||||
|
||||
const twoStepSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
introKey: 'cloudOnboarding.survey.intro',
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
label: 'How do you plan to use ComfyUI?',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'personal', label: 'Personal use' },
|
||||
{ value: 'work', label: 'Work' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'multi',
|
||||
label: 'What do you want to create with ComfyUI?',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'images', label: 'Images' },
|
||||
{ value: 'videos', label: 'Videos' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('DynamicSurveyForm', () => {
|
||||
it('renders the intro text and the first field options', () => {
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
expect(
|
||||
screen.getByText('Help us tailor your ComfyUI experience.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
|
||||
expect(screen.getByLabelText('Personal use')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Work')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Next until the user selects an option, then advances', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
const next = screen.getByRole('button', { name: 'Next' })
|
||||
expect(next).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByLabelText('Personal use'))
|
||||
expect(next).toBeEnabled()
|
||||
|
||||
await user.click(next)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
screen.getByText('What do you want to create with ComfyUI?')
|
||||
).toBeVisible()
|
||||
expect(screen.getByLabelText('Images')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates back to the previous step', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
await user.click(screen.getByLabelText('Personal use'))
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
await flushPromises()
|
||||
expect(
|
||||
screen.getByText('What do you want to create with ComfyUI?')
|
||||
).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }))
|
||||
await flushPromises()
|
||||
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
|
||||
})
|
||||
|
||||
it('resolves option and field labels via labelKey when provided', () => {
|
||||
const localizedI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: 'Help us tailor your ComfyUI experience.',
|
||||
errors: {
|
||||
chooseAnOption: '',
|
||||
selectAtLeastOne: '',
|
||||
describeAnswer: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
survey_label: 'Localized question?',
|
||||
survey_a: 'Localized A',
|
||||
survey_b: 'Localized B'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, localizedI18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q',
|
||||
type: 'single',
|
||||
labelKey: 'survey_label',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'a', labelKey: 'survey_a' },
|
||||
{ value: 'b', labelKey: 'survey_b' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Localized question?')).toBeVisible()
|
||||
expect(screen.getByLabelText('Localized A')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Localized B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders server-supplied translations from a label locale map', () => {
|
||||
const koreanI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'ko',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: '',
|
||||
errors: {
|
||||
chooseAnOption: '',
|
||||
selectAtLeastOne: '',
|
||||
describeAnswer: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ko: { g: { back: '뒤로', next: '다음', submit: '제출' } }
|
||||
}
|
||||
})
|
||||
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, koreanI18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
label: {
|
||||
en: 'How will you use it?',
|
||||
ko: '어떻게 사용하시겠어요?'
|
||||
},
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
value: 'personal',
|
||||
label: { en: 'Personal use', ko: '개인 용도' }
|
||||
},
|
||||
{ value: 'work', label: { en: 'Work', ko: '업무' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible()
|
||||
expect(screen.getByLabelText('개인 용도')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('업무')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to English when current locale missing from label map', () => {
|
||||
const fallbackI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', next: 'Next', submit: 'Submit' },
|
||||
cloudOnboarding: {
|
||||
survey: {
|
||||
intro: '',
|
||||
errors: {
|
||||
chooseAnOption: '',
|
||||
selectAtLeastOne: '',
|
||||
describeAnswer: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
fr: {}
|
||||
}
|
||||
})
|
||||
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, fallbackI18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q',
|
||||
type: 'single',
|
||||
label: { en: 'English question', ko: '한국어' },
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'a', label: { en: 'English A', ko: '한국어 A' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// fr is not in the map → falls back to en
|
||||
expect(screen.getByText('English question')).toBeVisible()
|
||||
expect(screen.getByLabelText('English A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows advancing past an optional field while still empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(DynamicSurveyForm, {
|
||||
global: { plugins: [PrimeVue, i18n] },
|
||||
props: {
|
||||
survey: {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'q1',
|
||||
type: 'single',
|
||||
label: 'Optional question?',
|
||||
options: [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' }
|
||||
]
|
||||
// no required: true — should be skippable
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
type: 'single',
|
||||
label: 'Required question?',
|
||||
required: true,
|
||||
options: [{ value: 'c', label: 'C' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const next = screen.getByRole('button', { name: 'Next' })
|
||||
expect(next).toBeEnabled()
|
||||
|
||||
await user.click(next)
|
||||
await flushPromises()
|
||||
expect(screen.getByText('Required question?')).toBeVisible()
|
||||
})
|
||||
|
||||
it('enables Submit only after the multi-select field has at least one choice', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(twoStepSurvey)
|
||||
|
||||
await user.click(screen.getByLabelText('Work'))
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
await flushPromises()
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /Images/i }))
|
||||
await flushPromises()
|
||||
expect(submitBtn).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
|
||||
<p v-if="introText" class="mb-4 text-sm text-muted">
|
||||
{{ introText }}
|
||||
</p>
|
||||
<div
|
||||
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-electric-400 transition-[width] duration-300 ease-out"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="currentField"
|
||||
:key="currentField.id"
|
||||
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
|
||||
>
|
||||
<DynamicSurveyField
|
||||
:field="currentField"
|
||||
:model-value="values[currentField.id]"
|
||||
:other-value="
|
||||
currentField.otherFieldId
|
||||
? (values[currentField.otherFieldId] as string)
|
||||
: undefined
|
||||
"
|
||||
:error-message="
|
||||
errors[currentField.id] ??
|
||||
(currentField.otherFieldId
|
||||
? errors[currentField.otherFieldId]
|
||||
: undefined)
|
||||
"
|
||||
@update:model-value="(value) => onFieldChange(currentField.id, value)"
|
||||
@update:other-value="
|
||||
(value) =>
|
||||
currentField.otherFieldId &&
|
||||
onFieldChange(currentField.otherFieldId, value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
v-if="!isFirst"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="h-10 flex-1 text-white"
|
||||
@click="goPrevious"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<span v-else class="flex-1" />
|
||||
<Button
|
||||
v-if="!isLast"
|
||||
type="button"
|
||||
:disabled="!isCurrentValid"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 flex-1 border-none',
|
||||
isCurrentValid
|
||||
? 'bg-electric-400 text-black hover:bg-electric-400/85'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
)
|
||||
"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
:disabled="!isCurrentValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 flex-1 border-none',
|
||||
isCurrentValid && !isSubmitting
|
||||
? 'bg-electric-400 text-black hover:bg-electric-400/85'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('g.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import DynamicSurveyField from './DynamicSurveyField.vue'
|
||||
import {
|
||||
buildInitialValues,
|
||||
buildSubmissionPayload,
|
||||
buildZodSchema,
|
||||
prepareSurvey,
|
||||
visibleFields
|
||||
} from './surveySchema'
|
||||
import type { SurveyValues } from './surveySchema'
|
||||
|
||||
const { survey } = defineProps<{
|
||||
survey: OnboardingSurvey
|
||||
isSubmitting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const { t, te } = useI18n()
|
||||
|
||||
const preparedSurvey = computed(() => prepareSurvey(survey))
|
||||
|
||||
const introText = computed(() => {
|
||||
const key = preparedSurvey.value.introKey
|
||||
if (!key) return ''
|
||||
return te(key) ? t(key) : ''
|
||||
})
|
||||
|
||||
const liveValues = ref<SurveyValues>(buildInitialValues(preparedSurvey.value))
|
||||
|
||||
const validationSchema = computed(() =>
|
||||
toTypedSchema(buildZodSchema(preparedSurvey.value, liveValues.value, t))
|
||||
)
|
||||
|
||||
const { values, errors, setFieldValue, validate, resetForm } =
|
||||
useForm<SurveyValues>({
|
||||
initialValues: liveValues.value,
|
||||
validationSchema
|
||||
})
|
||||
|
||||
watch(
|
||||
() => survey,
|
||||
() => {
|
||||
const fresh = buildInitialValues(preparedSurvey.value)
|
||||
liveValues.value = { ...fresh }
|
||||
resetForm({ values: fresh })
|
||||
stepIndex.value = 0
|
||||
}
|
||||
)
|
||||
|
||||
const visible = computed(() =>
|
||||
visibleFields(preparedSurvey.value, values as SurveyValues)
|
||||
)
|
||||
const stepIndex = ref(0)
|
||||
|
||||
const currentField = computed(() => visible.value[stepIndex.value])
|
||||
const isFirst = computed(() => stepIndex.value === 0)
|
||||
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
|
||||
|
||||
const totalSteps = computed(() => Math.max(visible.value.length, 1))
|
||||
const progressPercent = computed(() =>
|
||||
Math.max(
|
||||
100 / totalSteps.value,
|
||||
((stepIndex.value + 1) / totalSteps.value) * 100
|
||||
)
|
||||
)
|
||||
|
||||
const isCurrentValid = computed(() => {
|
||||
const field = currentField.value
|
||||
if (!field) return false
|
||||
|
||||
const value = values[field.id]
|
||||
const isEmpty =
|
||||
field.type === 'multi'
|
||||
? !Array.isArray(value) || value.length === 0
|
||||
: typeof value !== 'string' || value.length === 0
|
||||
|
||||
if (isEmpty) return !field.required
|
||||
|
||||
if (field.allowOther && field.otherFieldId && value === 'other') {
|
||||
const other = values[field.otherFieldId]
|
||||
return typeof other === 'string' && other.trim().length > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const onFieldChange = (id: string, value: string | string[]) => {
|
||||
setFieldValue(id, value)
|
||||
liveValues.value = { ...liveValues.value, [id]: value }
|
||||
if (stepIndex.value > visible.value.length - 1) {
|
||||
stepIndex.value = Math.max(0, visible.value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (stepIndex.value < visible.value.length - 1) stepIndex.value += 1
|
||||
}
|
||||
const goPrevious = () => {
|
||||
if (stepIndex.value > 0) stepIndex.value -= 1
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const result = await validate()
|
||||
if (!result.valid) return
|
||||
emit(
|
||||
'submit',
|
||||
buildSubmissionPayload(preparedSurvey.value, values as SurveyValues)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
const optionsFor = (
|
||||
fieldId: string,
|
||||
values: string[]
|
||||
): { value: string; labelKey: string }[] =>
|
||||
values.map((value) => ({
|
||||
value,
|
||||
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`
|
||||
}))
|
||||
|
||||
export const defaultOnboardingSurvey: OnboardingSurvey = {
|
||||
version: 2,
|
||||
introKey: 'cloudOnboarding.survey.intro',
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_usage',
|
||||
required: true,
|
||||
options: optionsFor('usage', ['personal', 'work', 'education'])
|
||||
},
|
||||
{
|
||||
id: 'familiarity',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_familiarity',
|
||||
required: true,
|
||||
options: optionsFor('familiarity', [
|
||||
'new',
|
||||
'starting',
|
||||
'basics',
|
||||
'advanced',
|
||||
'expert'
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'multi',
|
||||
labelKey: 'cloudSurvey_steps_intent',
|
||||
required: true,
|
||||
randomize: true,
|
||||
options: optionsFor('intent', [
|
||||
'workflows',
|
||||
'custom_nodes',
|
||||
'videos',
|
||||
'images',
|
||||
'3d_game',
|
||||
'audio',
|
||||
'apps',
|
||||
'api',
|
||||
'not_sure'
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_source',
|
||||
required: true,
|
||||
randomize: true,
|
||||
options: optionsFor('source', [
|
||||
'youtube',
|
||||
'reddit',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'linkedin',
|
||||
'friend',
|
||||
'search',
|
||||
'newsletter',
|
||||
'conference',
|
||||
'discord',
|
||||
'github',
|
||||
'other'
|
||||
])
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import {
|
||||
buildInitialValues,
|
||||
buildSubmissionPayload,
|
||||
buildZodSchema,
|
||||
prepareSurvey,
|
||||
visibleFields
|
||||
} from './surveySchema'
|
||||
|
||||
const baseSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'work', label: 'Work' },
|
||||
{ value: 'personal', label: 'Personal' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
type: 'single',
|
||||
required: true,
|
||||
showWhen: { field: 'usage', equals: 'work' },
|
||||
options: [{ value: 'engineer', label: 'Engineer' }]
|
||||
},
|
||||
{
|
||||
id: 'industry',
|
||||
type: 'single',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'industryOther',
|
||||
showWhen: { field: 'usage', equals: 'work' },
|
||||
options: [
|
||||
{ value: 'tech', label: 'Tech' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'images', label: 'Images' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('visibleFields', () => {
|
||||
it('hides fields when showWhen does not match', () => {
|
||||
const visible = visibleFields(baseSurvey, { usage: 'personal' })
|
||||
expect(visible.map((f) => f.id)).toEqual(['usage', 'making'])
|
||||
})
|
||||
|
||||
it('shows gated fields when showWhen matches', () => {
|
||||
const visible = visibleFields(baseSurvey, { usage: 'work' })
|
||||
expect(visible.map((f) => f.id)).toEqual([
|
||||
'usage',
|
||||
'role',
|
||||
'industry',
|
||||
'making'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats array equals as membership', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'role',
|
||||
type: 'single',
|
||||
showWhen: { field: 'usage', equals: ['work', 'education'] }
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(visibleFields(survey, { usage: 'education' })).toHaveLength(1)
|
||||
expect(visibleFields(survey, { usage: 'personal' })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('intersects multi-select source values with expected set', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'follow_up',
|
||||
type: 'single',
|
||||
showWhen: { field: 'making', equals: ['video', '3d'] }
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(visibleFields(survey, { making: [] })).toHaveLength(0)
|
||||
expect(visibleFields(survey, { making: ['images'] })).toHaveLength(0)
|
||||
expect(visibleFields(survey, { making: ['images', 'video'] })).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInitialValues', () => {
|
||||
it('initializes single fields to empty string and multi to empty array', () => {
|
||||
expect(buildInitialValues(baseSurvey)).toMatchObject({
|
||||
usage: '',
|
||||
role: '',
|
||||
industry: '',
|
||||
industryOther: '',
|
||||
making: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildZodSchema', () => {
|
||||
it('omits hidden fields from validation', () => {
|
||||
const schema = buildZodSchema(baseSurvey, { usage: 'personal' })
|
||||
const result = schema.safeParse({ usage: 'personal', making: ['video'] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('requires gated fields once visible', () => {
|
||||
const schema = buildZodSchema(baseSurvey, { usage: 'work' })
|
||||
const result = schema.safeParse({ usage: 'work', making: ['video'] })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires "other" detail when option is selected', () => {
|
||||
const schema = buildZodSchema(baseSurvey, {
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
making: ['video']
|
||||
})
|
||||
expect(
|
||||
schema.safeParse({
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: '',
|
||||
making: ['video']
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: 'Aerospace',
|
||||
making: ['video']
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSubmissionPayload', () => {
|
||||
it('clears hidden fields and prefers free-text "other" detail', () => {
|
||||
const payload = buildSubmissionPayload(baseSurvey, {
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: ' Aerospace ',
|
||||
making: ['video']
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'Aerospace',
|
||||
making: ['video']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to "other" when free-text is empty', () => {
|
||||
const payload = buildSubmissionPayload(baseSurvey, {
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: '',
|
||||
making: ['video']
|
||||
})
|
||||
expect(payload.industry).toBe('other')
|
||||
})
|
||||
|
||||
it('zeroes out fields hidden by showWhen', () => {
|
||||
const payload = buildSubmissionPayload(baseSurvey, {
|
||||
usage: 'personal',
|
||||
role: 'engineer',
|
||||
making: ['video']
|
||||
})
|
||||
expect(payload).toMatchObject({
|
||||
usage: 'personal',
|
||||
role: '',
|
||||
industry: '',
|
||||
making: ['video']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSurvey', () => {
|
||||
it('preserves option contents but may reorder when randomize=true', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
randomize: true,
|
||||
options: [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const prepared = prepareSurvey(survey)
|
||||
const values = prepared.fields[0]!.options!.map((o) => o.value)
|
||||
expect(values).toContain('a')
|
||||
expect(values).toContain('b')
|
||||
expect(values[values.length - 1]).toBe('other')
|
||||
})
|
||||
|
||||
it('pins both "other" and "not_sure" at the end while randomizing the rest', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'intent',
|
||||
type: 'multi',
|
||||
randomize: true,
|
||||
options: [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
{ value: 'not_sure', label: 'Not sure' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const prepared = prepareSurvey(survey)
|
||||
const values = prepared.fields[0]!.options!.map((o) => o.value)
|
||||
expect(values.slice(-2).sort()).toEqual(['not_sure', 'other'])
|
||||
expect(values.slice(0, -2).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { shuffle } from 'es-toolkit'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type {
|
||||
OnboardingSurvey,
|
||||
OnboardingSurveyField,
|
||||
OnboardingSurveyFieldCondition
|
||||
} from '@/platform/remoteConfig/types'
|
||||
|
||||
export type SurveyValues = Record<string, string | string[] | undefined>
|
||||
|
||||
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
|
||||
if (current === undefined || current === '') return false
|
||||
if (Array.isArray(current)) return current.length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
const conditionMatches = (
|
||||
condition: OnboardingSurveyFieldCondition | undefined,
|
||||
values: SurveyValues
|
||||
): boolean => {
|
||||
if (!condition) return true
|
||||
const current = values[condition.field]
|
||||
if (!hasNonEmptyValue(current)) return false
|
||||
const expected = condition.equals
|
||||
if (expected === undefined) return true
|
||||
const expectedSet = Array.isArray(expected) ? expected : [expected]
|
||||
if (Array.isArray(current)) {
|
||||
return current.some((v) => expectedSet.includes(v))
|
||||
}
|
||||
return typeof current === 'string' && expectedSet.includes(current)
|
||||
}
|
||||
|
||||
export const visibleFields = (
|
||||
survey: OnboardingSurvey,
|
||||
values: SurveyValues
|
||||
): OnboardingSurveyField[] =>
|
||||
survey.fields.filter((field) => conditionMatches(field.showWhen, values))
|
||||
|
||||
const PIN_LAST_VALUES = new Set(['other', 'not_sure'])
|
||||
|
||||
const randomizeOptions = (field: OnboardingSurveyField) => {
|
||||
if (!field.randomize || !field.options) return field
|
||||
const pinned = field.options.filter((opt) => PIN_LAST_VALUES.has(opt.value))
|
||||
const rest = field.options.filter((opt) => !PIN_LAST_VALUES.has(opt.value))
|
||||
return {
|
||||
...field,
|
||||
options: [...shuffle(rest), ...pinned]
|
||||
}
|
||||
}
|
||||
|
||||
export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
|
||||
...survey,
|
||||
fields: survey.fields.map(randomizeOptions)
|
||||
})
|
||||
|
||||
type Translator = (key: string) => string
|
||||
|
||||
const identityTranslator: Translator = (key) => key
|
||||
|
||||
const fieldSchema = (field: OnboardingSurveyField, t: Translator) => {
|
||||
if (field.type === 'multi') {
|
||||
const arr = z.array(z.string())
|
||||
return field.required
|
||||
? arr.min(1, {
|
||||
message: t('cloudOnboarding.survey.errors.selectAtLeastOne')
|
||||
})
|
||||
: arr.optional()
|
||||
}
|
||||
if (field.required) {
|
||||
return z.string().min(1, {
|
||||
message: t('cloudOnboarding.survey.errors.chooseAnOption')
|
||||
})
|
||||
}
|
||||
return z.string().optional()
|
||||
}
|
||||
|
||||
export const buildZodSchema = (
|
||||
survey: OnboardingSurvey,
|
||||
values: SurveyValues,
|
||||
t: Translator = identityTranslator
|
||||
) => {
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
for (const field of survey.fields) {
|
||||
if (!conditionMatches(field.showWhen, values)) continue
|
||||
shape[field.id] = fieldSchema(field, t)
|
||||
if (
|
||||
field.allowOther &&
|
||||
field.otherFieldId &&
|
||||
values[field.id] === 'other'
|
||||
) {
|
||||
shape[field.otherFieldId] = z.string().min(1, {
|
||||
message: t('cloudOnboarding.survey.errors.describeAnswer')
|
||||
})
|
||||
} else if (field.otherFieldId) {
|
||||
shape[field.otherFieldId] = z.string().optional()
|
||||
}
|
||||
}
|
||||
return z.object(shape)
|
||||
}
|
||||
|
||||
export const buildInitialValues = (survey: OnboardingSurvey): SurveyValues => {
|
||||
const initial: SurveyValues = {}
|
||||
for (const field of survey.fields) {
|
||||
initial[field.id] = field.type === 'multi' ? [] : ''
|
||||
if (field.otherFieldId) initial[field.otherFieldId] = ''
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
export const buildSubmissionPayload = (
|
||||
survey: OnboardingSurvey,
|
||||
values: SurveyValues
|
||||
): Record<string, unknown> => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of survey.fields) {
|
||||
const visible = conditionMatches(field.showWhen, values)
|
||||
if (!visible) {
|
||||
payload[field.id] = field.type === 'multi' ? [] : ''
|
||||
continue
|
||||
}
|
||||
const value = values[field.id]
|
||||
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
|
||||
if (
|
||||
field.allowOther &&
|
||||
field.otherFieldId &&
|
||||
value === 'other' &&
|
||||
typeof otherRaw === 'string'
|
||||
) {
|
||||
const other = otherRaw.trim()
|
||||
payload[field.id] = other || 'other'
|
||||
} else {
|
||||
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -7,21 +7,11 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const mockIsActiveSubscription = ref(false)
|
||||
const mockSubscriptionTier = ref<
|
||||
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
|
||||
@@ -35,35 +25,6 @@ const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
|
||||
const mockLocalStorage = vi.hoisted(() => {
|
||||
const store = new Map<string, string>()
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key)
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear()
|
||||
}),
|
||||
__reset: () => {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
@@ -187,20 +148,7 @@ function renderComponent() {
|
||||
},
|
||||
stubs: {
|
||||
SelectButton: {
|
||||
template: `
|
||||
<div>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
@click="$emit('update:modelValue', option.value)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue']
|
||||
},
|
||||
@@ -219,10 +167,7 @@ describe('PricingTable', () => {
|
||||
mockSubscriptionTier.value = null
|
||||
mockIsYearlySubscription.value = false
|
||||
mockUserId.value = 'user-123'
|
||||
mockAccessBillingPortal.mockReset()
|
||||
mockAccessBillingPortal.mockResolvedValue(true)
|
||||
mockTrackBeginCheckout.mockReset()
|
||||
mockLocalStorage.__reset()
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
|
||||
@@ -272,66 +217,6 @@ describe('PricingTable', () => {
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
|
||||
})
|
||||
|
||||
it('records the plan snapshot that was actually opened', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
const portalOpen = createDeferredPromise<boolean>()
|
||||
mockAccessBillingPortal.mockReturnValueOnce(portalOpen.promise)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const creatorButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Creator'))
|
||||
|
||||
await userEvent.click(creatorButton!)
|
||||
await flushPromises()
|
||||
|
||||
const monthlyToggle = screen.getByRole('button', { name: 'Monthly' })
|
||||
await userEvent.click(monthlyToggle)
|
||||
await flushPromises()
|
||||
|
||||
portalOpen.resolve(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
|
||||
expect(
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
) ?? '{}'
|
||||
)
|
||||
).toMatchObject({
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard',
|
||||
previous_cycle: 'monthly'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not record a pending upgrade when the billing portal does not open', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockAccessBillingPortal.mockResolvedValueOnce(false)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const creatorButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Creator'))
|
||||
|
||||
await userEvent.click(creatorButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
@@ -273,7 +273,6 @@ import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
@@ -451,31 +450,29 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
try {
|
||||
if (hasPaidSubscription.value) {
|
||||
const targetPlan = {
|
||||
tierKey,
|
||||
billingCycle: currentBillingCycle.value
|
||||
} as const
|
||||
const previousPlan = currentPlanDescriptor.value
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle.value,
|
||||
checkout_type: 'change',
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
...(currentTierKey.value
|
||||
? { previous_tier: currentTierKey.value }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
// Pass the target tier to create a deep link to subscription update confirmation
|
||||
const checkoutTier = getCheckoutTier(
|
||||
targetPlan.tierKey,
|
||||
targetPlan.billingCycle
|
||||
)
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
|
||||
const targetPlan = {
|
||||
tierKey,
|
||||
billingCycle: currentBillingCycle.value
|
||||
}
|
||||
const downgrade =
|
||||
previousPlan &&
|
||||
currentPlanDescriptor.value &&
|
||||
isPlanDowngrade({
|
||||
current: previousPlan,
|
||||
current: currentPlanDescriptor.value,
|
||||
target: targetPlan
|
||||
})
|
||||
|
||||
@@ -483,20 +480,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
|
||||
await accessBillingPortal()
|
||||
} else {
|
||||
const didOpenPortal = await accessBillingPortal(checkoutTier)
|
||||
if (!didOpenPortal) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
|
||||
...(previousPlan
|
||||
? { previous_cycle: previousPlan.billingCycle }
|
||||
: {})
|
||||
})
|
||||
await accessBillingPortal(checkoutTier)
|
||||
}
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -169,7 +169,7 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { fetchStatus, isActiveSubscription } = useBillingContext()
|
||||
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
@@ -190,10 +190,69 @@ const telemetry = useTelemetry()
|
||||
// Always show custom pricing table for cloud subscriptions
|
||||
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_ATTEMPTS = 3
|
||||
let pollInterval: number | null = null
|
||||
let pollAttempts = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollAttempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
pollAttempts++
|
||||
|
||||
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
if (showCustomPricingTable.value) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
showCustomPricingTable,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
} else {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
@@ -204,6 +263,7 @@ const handleSubscribed = () => {
|
||||
}
|
||||
|
||||
const handleChooseTeam = () => {
|
||||
stopPolling()
|
||||
if (onChooseTeam) {
|
||||
onChooseTeam()
|
||||
} else {
|
||||
@@ -212,6 +272,7 @@ const handleChooseTeam = () => {
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -232,6 +293,11 @@ const handleViewEnterprise = () => {
|
||||
})
|
||||
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
|
||||
const {
|
||||
mockIsLoggedIn,
|
||||
@@ -13,13 +12,10 @@ const {
|
||||
mockGetCheckoutAttribution,
|
||||
mockTelemetry,
|
||||
mockUserId,
|
||||
mockIsCloud,
|
||||
mockAuthStoreInitialized,
|
||||
mockLocalStorage
|
||||
mockIsCloud
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsLoggedIn: { value: false },
|
||||
mockIsCloud: { value: true },
|
||||
mockAuthStoreInitialized: { value: true },
|
||||
mockReportError: vi.fn(),
|
||||
mockAccessBillingPortal: vi.fn(),
|
||||
mockShowSubscriptionRequiredDialog: vi.fn(),
|
||||
@@ -32,29 +28,9 @@ const {
|
||||
})),
|
||||
mockTelemetry: {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
},
|
||||
mockUserId: { value: 'user-123' },
|
||||
mockLocalStorage: (() => {
|
||||
const store = new Map<string, string>()
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key)
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear()
|
||||
}),
|
||||
__reset: () => {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
})()
|
||||
mockUserId: { value: 'user-123' }
|
||||
}))
|
||||
|
||||
let scope: ReturnType<typeof effectScope> | undefined
|
||||
@@ -79,16 +55,6 @@ function useSubscriptionWithScope() {
|
||||
return subscription
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
isLoggedIn: mockIsLoggedIn
|
||||
@@ -143,9 +109,6 @@ vi.mock('@/services/dialogService', () => ({
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: mockGetAuthHeader,
|
||||
get isInitialized() {
|
||||
return mockAuthStoreInitialized.value
|
||||
},
|
||||
get userId() {
|
||||
return mockUserId.value
|
||||
}
|
||||
@@ -158,11 +121,9 @@ global.fetch = vi.fn()
|
||||
|
||||
describe('useSubscription', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
setDistribution('localhost')
|
||||
mockLocalStorage.__reset()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -171,16 +132,11 @@ describe('useSubscription', () => {
|
||||
setDistribution('cloud')
|
||||
|
||||
vi.clearAllMocks()
|
||||
mockLocalStorage.__reset()
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mockAccessBillingPortal.mockReset()
|
||||
mockAccessBillingPortal.mockResolvedValue(true)
|
||||
mockUserId.value = 'user-123'
|
||||
mockIsCloud.value = true
|
||||
mockAuthStoreInitialized.value = true
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -333,7 +289,7 @@ describe('useSubscription', () => {
|
||||
// Mock window.open
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { subscribe } = useSubscriptionWithScope()
|
||||
|
||||
@@ -355,16 +311,6 @@ describe('useSubscription', () => {
|
||||
)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ??
|
||||
'{}'
|
||||
)
|
||||
).toMatchObject({
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
@@ -381,181 +327,6 @@ describe('useSubscription', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending checkout recovery', () => {
|
||||
it('emits subscription_success when a pending new subscription becomes active', async () => {
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-123',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
subscription_tier: 'CREATOR',
|
||||
subscription_duration: 'ANNUAL',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'user-123',
|
||||
checkout_attempt_id: 'attempt-123',
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
value: 336,
|
||||
currency: 'USD'
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('emits subscription_success when a pending upgrade reaches the target tier', async () => {
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-456',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'creator',
|
||||
previous_cycle: 'monthly'
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkout_attempt_id: 'attempt-456',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'creator',
|
||||
value: 100
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rechecks pending checkout attempts when the document becomes visible', async () => {
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response)
|
||||
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
vi.mocked(global.fetch).mockClear()
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-visible',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clear pending attempts before auth initialization resolves', async () => {
|
||||
mockAuthStoreInitialized.value = false
|
||||
mockIsLoggedIn.value = false
|
||||
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-pre-auth',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears pending checkout attempts when initialized while logged out', async () => {
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-logout',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
mockIsLoggedIn.value = false
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireActiveSubscription', () => {
|
||||
it('should not show dialog when subscription is active', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
@@ -662,40 +433,6 @@ describe('useSubscription', () => {
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start cancellation watching when the billing portal does not open', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
mockAccessBillingPortal.mockResolvedValueOnce(false)
|
||||
|
||||
const activeResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_active',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue(activeResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
vi.mocked(global.fetch).mockClear()
|
||||
|
||||
await manageSubscription()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled
|
||||
).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('tracks cancellation after manage subscription when status flips', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
createSharedComposable,
|
||||
defaultDocument,
|
||||
defaultWindow,
|
||||
useEventListener
|
||||
} from '@vueuse/core'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
@@ -19,14 +14,6 @@ import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import {
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
clearPendingSubscriptionCheckoutAttempt,
|
||||
consumePendingSubscriptionCheckoutSuccess,
|
||||
hasPendingSubscriptionCheckoutAttempt,
|
||||
recordPendingSubscriptionCheckoutAttempt
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
type CloudSubscriptionCheckoutResponse = NonNullable<
|
||||
@@ -37,8 +24,6 @@ export type CloudSubscriptionStatusResponse = NonNullable<
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PENDING_SUBSCRIPTION_CHECKOUT_RETRY_DELAYS_MS = [3000, 10000, 30000]
|
||||
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
@@ -126,78 +111,6 @@ function useSubscriptionInternal() {
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
let pendingCheckoutRecoveryTimeout: number | null = null
|
||||
let pendingCheckoutRecoveryAttempt = 0
|
||||
let isRecoveringPendingCheckout = false
|
||||
|
||||
const stopPendingCheckoutRecovery = () => {
|
||||
if (pendingCheckoutRecoveryTimeout !== null && defaultWindow) {
|
||||
defaultWindow.clearTimeout(pendingCheckoutRecoveryTimeout)
|
||||
}
|
||||
|
||||
pendingCheckoutRecoveryTimeout = null
|
||||
pendingCheckoutRecoveryAttempt = 0
|
||||
}
|
||||
|
||||
const schedulePendingCheckoutRecovery = () => {
|
||||
if (
|
||||
!defaultWindow ||
|
||||
pendingCheckoutRecoveryTimeout !== null ||
|
||||
!isLoggedIn.value ||
|
||||
!hasPendingSubscriptionCheckoutAttempt()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextDelay =
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_RETRY_DELAYS_MS[
|
||||
pendingCheckoutRecoveryAttempt
|
||||
]
|
||||
|
||||
if (nextDelay === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingCheckoutRecoveryTimeout = defaultWindow.setTimeout(() => {
|
||||
pendingCheckoutRecoveryTimeout = null
|
||||
pendingCheckoutRecoveryAttempt += 1
|
||||
void recoverPendingSubscriptionCheckout('retry')
|
||||
}, nextDelay)
|
||||
}
|
||||
|
||||
const syncPendingSubscriptionSuccess = (
|
||||
statusData: CloudSubscriptionStatusResponse
|
||||
) => {
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(statusData)
|
||||
|
||||
if (!metadata) {
|
||||
if (hasPendingSubscriptionCheckoutAttempt()) {
|
||||
schedulePendingCheckoutRecovery()
|
||||
} else {
|
||||
stopPendingCheckoutRecovery()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
telemetry?.trackMonthlySubscriptionSucceeded({
|
||||
...(authStore.userId ? { user_id: authStore.userId } : {}),
|
||||
...metadata
|
||||
})
|
||||
stopPendingCheckoutRecovery()
|
||||
}
|
||||
|
||||
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
return {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
fetchSubscriptionStatus,
|
||||
reportError
|
||||
@@ -214,24 +127,7 @@ function useSubscriptionInternal() {
|
||||
)
|
||||
}
|
||||
|
||||
const checkoutWindow = window.open(response.checkout_url, '_blank')
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
|
||||
...(subscriptionTier.value
|
||||
? { previous_tier: TIER_TO_KEY[subscriptionTier.value] }
|
||||
: {}),
|
||||
...(subscriptionDuration.value === 'ANNUAL'
|
||||
? { previous_cycle: 'yearly' as const }
|
||||
: subscriptionDuration.value === 'MONTHLY'
|
||||
? { previous_cycle: 'monthly' as const }
|
||||
: {})
|
||||
})
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = (options?: {
|
||||
@@ -264,11 +160,7 @@ function useSubscriptionInternal() {
|
||||
})
|
||||
|
||||
const manageSubscription = async () => {
|
||||
const didOpenPortal = await accessBillingPortal()
|
||||
if (!didOpenPortal) {
|
||||
return
|
||||
}
|
||||
|
||||
await accessBillingPortal()
|
||||
startCancellationWatcher()
|
||||
}
|
||||
|
||||
@@ -292,44 +184,23 @@ function useSubscriptionInternal() {
|
||||
await accessBillingPortal()
|
||||
}
|
||||
|
||||
const recoverPendingSubscriptionCheckout = async (
|
||||
source: 'bootstrap' | 'pageshow' | 'visibilitychange' | 'retry'
|
||||
) => {
|
||||
if (
|
||||
!isCloud ||
|
||||
!isLoggedIn.value ||
|
||||
!hasPendingSubscriptionCheckoutAttempt() ||
|
||||
isRecoveringPendingCheckout
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isRecoveringPendingCheckout = true
|
||||
|
||||
try {
|
||||
await fetchSubscriptionStatus()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Subscription] Failed to recover pending checkout on ${source}:`,
|
||||
error
|
||||
)
|
||||
schedulePendingCheckoutRecovery()
|
||||
} finally {
|
||||
isRecoveringPendingCheckout = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current cloud subscription status for the authenticated user
|
||||
* @returns Subscription status or null if no subscription exists
|
||||
*/
|
||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const headers = await buildAuthHeaders()
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-status'),
|
||||
{
|
||||
headers
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -344,55 +215,16 @@ function useSubscriptionInternal() {
|
||||
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
syncPendingSubscriptionSuccess(statusData)
|
||||
|
||||
return statusData
|
||||
}
|
||||
|
||||
const handlePendingSubscriptionCheckoutChange = () => {
|
||||
if (!hasPendingSubscriptionCheckoutAttempt()) {
|
||||
stopPendingCheckoutRecovery()
|
||||
return
|
||||
}
|
||||
|
||||
stopPendingCheckoutRecovery()
|
||||
void recoverPendingSubscriptionCheckout('retry')
|
||||
}
|
||||
|
||||
useEventListener(defaultWindow, PENDING_SUBSCRIPTION_CHECKOUT_EVENT, () => {
|
||||
handlePendingSubscriptionCheckoutChange()
|
||||
})
|
||||
|
||||
useEventListener(defaultWindow, 'storage', (event: StorageEvent) => {
|
||||
if (event.key === PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) {
|
||||
handlePendingSubscriptionCheckoutChange()
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(defaultWindow, 'pageshow', () => {
|
||||
void recoverPendingSubscriptionCheckout('pageshow')
|
||||
})
|
||||
|
||||
useEventListener(defaultDocument, 'visibilitychange', () => {
|
||||
if (defaultDocument?.visibilityState === 'visible') {
|
||||
void recoverPendingSubscriptionCheckout('visibilitychange')
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [authStore.isInitialized, isLoggedIn.value] as const,
|
||||
async ([authInitialized, loggedIn]) => {
|
||||
if (!authInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn && isCloud) {
|
||||
try {
|
||||
if (hasPendingSubscriptionCheckoutAttempt()) {
|
||||
await recoverPendingSubscriptionCheckout('bootstrap')
|
||||
} else {
|
||||
await fetchSubscriptionStatus()
|
||||
}
|
||||
await fetchSubscriptionStatus()
|
||||
} catch (error) {
|
||||
// Network errors are expected during navigation/component unmount
|
||||
// and when offline - log for debugging but don't surface to user
|
||||
@@ -402,8 +234,6 @@ function useSubscriptionInternal() {
|
||||
}
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
stopPendingCheckoutRecovery()
|
||||
stopCancellationWatcher()
|
||||
isInitialized.value = true
|
||||
}
|
||||
@@ -413,14 +243,20 @@ function useSubscriptionInternal() {
|
||||
|
||||
const initiateSubscriptionCheckout =
|
||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||
const headers = await buildAuthHeaders()
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
export type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
|
||||
export type TierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder'
|
||||
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import {
|
||||
TIER_TO_KEY,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
TierKey
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
|
||||
|
||||
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
const VALID_TIER_KEYS = new Set<TierKey>([
|
||||
'free',
|
||||
'standard',
|
||||
'creator',
|
||||
'pro',
|
||||
'founder'
|
||||
])
|
||||
|
||||
export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
|
||||
'comfy.subscription.pending_checkout_attempt'
|
||||
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
|
||||
'comfy:subscription-checkout-attempt-changed'
|
||||
|
||||
type CheckoutType = 'new' | 'change'
|
||||
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
|
||||
|
||||
interface SubscriptionStatusSnapshot {
|
||||
is_active?: boolean
|
||||
subscription_tier?: SubscriptionTier | null
|
||||
subscription_duration?: SubscriptionDuration | null
|
||||
}
|
||||
|
||||
interface PendingSubscriptionCheckoutAttempt {
|
||||
attempt_id: string
|
||||
started_at_ms: number
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: CheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
}
|
||||
|
||||
interface RecordPendingSubscriptionCheckoutAttemptInput {
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: CheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
}
|
||||
|
||||
const dispatchPendingCheckoutChangeEvent = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(PENDING_SUBSCRIPTION_CHECKOUT_EVENT))
|
||||
}
|
||||
|
||||
const createAttemptId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `attempt-${Date.now()}`
|
||||
}
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
let storage: Storage | null = null
|
||||
|
||||
try {
|
||||
storage = globalThis.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!storage ||
|
||||
typeof storage.getItem !== 'function' ||
|
||||
typeof storage.setItem !== 'function' ||
|
||||
typeof storage.removeItem !== 'function'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
const getAnnualCheckoutValue = (tier: Exclude<TierKey, 'free' | 'founder'>) =>
|
||||
getTierPrice(tier, true) * 12
|
||||
|
||||
const getCheckoutValue = (tier: TierKey, cycle: BillingCycle): number => {
|
||||
if (tier === 'free' || tier === 'founder') {
|
||||
return getTierPrice(tier, cycle === 'yearly')
|
||||
}
|
||||
|
||||
return cycle === 'yearly'
|
||||
? getAnnualCheckoutValue(tier)
|
||||
: getTierPrice(tier, false)
|
||||
}
|
||||
|
||||
const getTierFromStatus = (
|
||||
status: SubscriptionStatusSnapshot
|
||||
): TierKey | null => {
|
||||
const subscriptionTier = status.subscription_tier
|
||||
if (!subscriptionTier) {
|
||||
return null
|
||||
}
|
||||
|
||||
return TIER_TO_KEY[subscriptionTier] ?? null
|
||||
}
|
||||
|
||||
const getCycleFromStatus = (
|
||||
status: SubscriptionStatusSnapshot
|
||||
): BillingCycle | null => {
|
||||
if (status.subscription_duration === 'ANNUAL') {
|
||||
return 'yearly'
|
||||
}
|
||||
|
||||
if (status.subscription_duration === 'MONTHLY') {
|
||||
return 'monthly'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const isExpired = (attempt: PendingSubscriptionCheckoutAttempt): boolean =>
|
||||
Date.now() - attempt.started_at_ms > PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS
|
||||
|
||||
const normalizeAttempt = (
|
||||
value: unknown
|
||||
): PendingSubscriptionCheckoutAttempt | null => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = value as Partial<PendingSubscriptionCheckoutAttempt>
|
||||
|
||||
if (
|
||||
typeof candidate.attempt_id !== 'string' ||
|
||||
typeof candidate.started_at_ms !== 'number' ||
|
||||
typeof candidate.tier !== 'string' ||
|
||||
typeof candidate.cycle !== 'string' ||
|
||||
typeof candidate.checkout_type !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!VALID_TIER_KEYS.has(candidate.tier as TierKey) ||
|
||||
(candidate.cycle !== 'monthly' && candidate.cycle !== 'yearly') ||
|
||||
(candidate.checkout_type !== 'new' && candidate.checkout_type !== 'change')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
attempt_id: candidate.attempt_id,
|
||||
started_at_ms: candidate.started_at_ms,
|
||||
tier: candidate.tier as TierKey,
|
||||
cycle: candidate.cycle,
|
||||
checkout_type: candidate.checkout_type,
|
||||
...(candidate.previous_tier &&
|
||||
VALID_TIER_KEYS.has(candidate.previous_tier as TierKey)
|
||||
? { previous_tier: candidate.previous_tier as TierKey }
|
||||
: {}),
|
||||
...(candidate.previous_cycle === 'monthly' ||
|
||||
candidate.previous_cycle === 'yearly'
|
||||
? { previous_cycle: candidate.previous_cycle }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPendingSubscriptionCheckoutAttempt = (): void => {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
dispatchPendingCheckoutChangeEvent()
|
||||
}
|
||||
|
||||
const getPendingSubscriptionCheckoutAttempt =
|
||||
(): PendingSubscriptionCheckoutAttempt | null => {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return null
|
||||
}
|
||||
|
||||
let rawAttempt: string | null
|
||||
|
||||
try {
|
||||
rawAttempt = storage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!rawAttempt) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawAttempt)
|
||||
const attempt = normalizeAttempt(parsed)
|
||||
|
||||
if (!attempt || isExpired(attempt)) {
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
return null
|
||||
}
|
||||
|
||||
return attempt
|
||||
} catch {
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
|
||||
getPendingSubscriptionCheckoutAttempt() !== null
|
||||
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: RecordPendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
const storage = getStorage()
|
||||
const attempt: PendingSubscriptionCheckoutAttempt = {
|
||||
attempt_id: createAttemptId(),
|
||||
started_at_ms: Date.now(),
|
||||
tier: input.tier,
|
||||
cycle: input.cycle,
|
||||
checkout_type: input.checkout_type,
|
||||
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
|
||||
}
|
||||
|
||||
if (!storage) {
|
||||
return attempt
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify(attempt)
|
||||
)
|
||||
} catch {
|
||||
return attempt
|
||||
}
|
||||
dispatchPendingCheckoutChangeEvent()
|
||||
|
||||
return attempt
|
||||
}
|
||||
|
||||
const didAttemptSucceed = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt,
|
||||
status: SubscriptionStatusSnapshot
|
||||
): boolean => {
|
||||
if (!status.is_active) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
getTierFromStatus(status) === attempt.tier &&
|
||||
getCycleFromStatus(status) === attempt.cycle
|
||||
)
|
||||
}
|
||||
|
||||
export const consumePendingSubscriptionCheckoutSuccess = (
|
||||
status: SubscriptionStatusSnapshot
|
||||
): SubscriptionSuccessMetadata | null => {
|
||||
const attempt = getPendingSubscriptionCheckoutAttempt()
|
||||
if (!attempt || !didAttemptSucceed(attempt, status)) {
|
||||
return null
|
||||
}
|
||||
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
|
||||
const value = getCheckoutValue(attempt.tier, attempt.cycle)
|
||||
|
||||
return {
|
||||
checkout_attempt_id: attempt.attempt_id,
|
||||
tier: attempt.tier,
|
||||
cycle: attempt.cycle,
|
||||
checkout_type: attempt.checkout_type,
|
||||
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
|
||||
value,
|
||||
currency: 'USD',
|
||||
ecommerce: {
|
||||
value,
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
item_name: attempt.tier,
|
||||
item_category: 'subscription',
|
||||
item_variant: attempt.cycle,
|
||||
price: value,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { performSubscriptionCheckout } from './subscriptionCheckoutUtil'
|
||||
|
||||
const {
|
||||
@@ -9,8 +8,7 @@ const {
|
||||
mockGetAuthHeader,
|
||||
mockUserId,
|
||||
mockIsCloud,
|
||||
mockGetCheckoutAttribution,
|
||||
mockLocalStorage
|
||||
mockGetCheckoutAttribution
|
||||
} = vi.hoisted(() => ({
|
||||
mockTelemetry: {
|
||||
trackBeginCheckout: vi.fn()
|
||||
@@ -31,38 +29,9 @@ const {
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
})),
|
||||
mockLocalStorage: (() => {
|
||||
const store = new Map<string, string>()
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key)
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear()
|
||||
}),
|
||||
__reset: () => {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
})()
|
||||
}))
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
@@ -112,20 +81,16 @@ describe('performSubscriptionCheckout', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockUserId.value = 'user-123'
|
||||
mockLocalStorage.__reset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setDistribution('localhost')
|
||||
mockLocalStorage.__reset()
|
||||
})
|
||||
|
||||
it('tracks begin_checkout with user id and tier metadata', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -210,9 +175,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const authHeader = createDeferred<{ Authorization: string }>()
|
||||
|
||||
mockUserId.value = 'user-early'
|
||||
@@ -240,21 +203,4 @@ describe('performSubscriptionCheckout', () => {
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
@@ -114,24 +113,9 @@ export async function performSubscriptionCheckout(
|
||||
...checkoutAttribution
|
||||
})
|
||||
}
|
||||
|
||||
if (openInNewTab) {
|
||||
const checkoutWindow = window.open(data.checkout_url, '_blank')
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
window.open(data.checkout_url, '_blank')
|
||||
} else {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
globalThis.location.href = data.checkout_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,68 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import { KeyComboImpl } from './keyCombo'
|
||||
|
||||
describe('KeyComboImpl', () => {
|
||||
function mockKeyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
return {
|
||||
key: '',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...overrides
|
||||
} as KeyboardEvent
|
||||
}
|
||||
|
||||
describe('getKeySequences', () => {
|
||||
it.each([
|
||||
{
|
||||
event: { key: 'Shift', shiftKey: true },
|
||||
expected: ['Shift'],
|
||||
label: 'Shift'
|
||||
},
|
||||
{
|
||||
event: { key: 'Control', ctrlKey: true },
|
||||
expected: ['Ctrl'],
|
||||
label: 'Control'
|
||||
},
|
||||
{
|
||||
event: { key: 'Alt', altKey: true },
|
||||
expected: ['Alt'],
|
||||
label: 'Alt'
|
||||
},
|
||||
{
|
||||
event: { key: 'Meta', metaKey: true },
|
||||
expected: ['Ctrl'],
|
||||
label: 'Meta'
|
||||
}
|
||||
])(
|
||||
'does not duplicate a single $label modifier press',
|
||||
({ event, expected }) => {
|
||||
const combo = KeyComboImpl.fromEvent(mockKeyEvent(event))
|
||||
|
||||
expect(combo.getKeySequences()).toEqual(expected)
|
||||
expect(combo.toString()).toBe(expected.join(' + '))
|
||||
}
|
||||
)
|
||||
|
||||
it('lists held modifiers once when the pressed key is also a modifier', () => {
|
||||
const combo = KeyComboImpl.fromEvent(
|
||||
mockKeyEvent({ key: 'Shift', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
|
||||
expect(combo.getKeySequences()).toEqual(['Ctrl', 'Shift'])
|
||||
expect(combo.toString()).toBe('Ctrl + Shift')
|
||||
})
|
||||
|
||||
it('keeps the primary key for non-modifier shortcuts', () => {
|
||||
const combo = KeyComboImpl.fromEvent(
|
||||
mockKeyEvent({ key: 'k', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
|
||||
expect(combo.getKeySequences()).toEqual(['Ctrl', 'Shift', 'k'])
|
||||
expect(combo.toString()).toBe('Ctrl + Shift + k')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBrowserReserved', () => {
|
||||
it.each([
|
||||
{ key: 't', ctrl: true, label: 'Ctrl + t' },
|
||||
|
||||
@@ -3,13 +3,6 @@ import { toRaw } from 'vue'
|
||||
import { RESERVED_BY_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
|
||||
import type { KeyCombo } from './types'
|
||||
|
||||
const MODIFIER_KEY_LABELS: Record<string, string> = {
|
||||
Control: 'Ctrl',
|
||||
Meta: 'Ctrl',
|
||||
Alt: 'Alt',
|
||||
Shift: 'Shift'
|
||||
}
|
||||
|
||||
export class KeyComboImpl implements KeyCombo {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
@@ -56,7 +49,7 @@ export class KeyComboImpl implements KeyCombo {
|
||||
}
|
||||
|
||||
get isModifier(): boolean {
|
||||
return this.key in MODIFIER_KEY_LABELS
|
||||
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
|
||||
}
|
||||
|
||||
get modifierCount(): number {
|
||||
@@ -81,37 +74,26 @@ export class KeyComboImpl implements KeyCombo {
|
||||
}
|
||||
|
||||
getKeySequences(): string[] {
|
||||
const sequences = getModifierSequences(this)
|
||||
|
||||
if (!this.isModifier || sequences.length === 0) {
|
||||
sequences.push(getKeyLabel(this.key))
|
||||
const sequences: string[] = []
|
||||
if (this.ctrl) {
|
||||
sequences.push('Ctrl')
|
||||
}
|
||||
|
||||
if (this.alt) {
|
||||
sequences.push('Alt')
|
||||
}
|
||||
if (this.shift) {
|
||||
sequences.push('Shift')
|
||||
}
|
||||
sequences.push(this.key)
|
||||
return sequences
|
||||
}
|
||||
}
|
||||
|
||||
function toNormalizedString(combo: KeyComboImpl): string {
|
||||
const sequences = getModifierSequences(combo)
|
||||
|
||||
if (!combo.isModifier || sequences.length === 0) {
|
||||
sequences.push(getKeyLabel(combo.key, true))
|
||||
}
|
||||
|
||||
return sequences.join(' + ')
|
||||
}
|
||||
|
||||
function getModifierSequences(combo: KeyComboImpl): string[] {
|
||||
const sequences: string[] = []
|
||||
if (combo.ctrl) sequences.push('Ctrl')
|
||||
if (combo.alt) sequences.push('Alt')
|
||||
if (combo.shift) sequences.push('Shift')
|
||||
return sequences
|
||||
}
|
||||
|
||||
function getKeyLabel(key: string, normalizeSingleCharacter = false): string {
|
||||
const label = MODIFIER_KEY_LABELS[key] ?? key
|
||||
return normalizeSingleCharacter && label.length === 1
|
||||
? label.toLowerCase()
|
||||
: label
|
||||
sequences.push(combo.key.length === 1 ? combo.key.toLowerCase() : combo.key)
|
||||
return sequences.join(' + ')
|
||||
}
|
||||
|
||||
@@ -23,54 +23,6 @@ type FirebaseRuntimeConfig = {
|
||||
measurementId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-driven onboarding survey schema.
|
||||
*
|
||||
* The backend ships the entire form definition so onboarding questions can
|
||||
* be tweaked without a frontend release. Field types map 1:1 to a component
|
||||
* in our internal UI library — see `DynamicSurveyField.vue`.
|
||||
*/
|
||||
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
|
||||
|
||||
/**
|
||||
* A translatable string. Either:
|
||||
* - a single literal (treated as the fallback in any locale), or
|
||||
* - a locale → text map, e.g. `{ en: 'Personal use', ko: '개인 용도' }`,
|
||||
* so the backend can ship translations without a frontend release.
|
||||
*/
|
||||
export type LocalizedString = string | Record<string, string>
|
||||
|
||||
export type OnboardingSurveyOption = {
|
||||
value: string
|
||||
label?: LocalizedString
|
||||
labelKey?: string
|
||||
}
|
||||
|
||||
export type OnboardingSurveyFieldCondition = {
|
||||
field: string
|
||||
equals?: string | string[]
|
||||
}
|
||||
|
||||
export type OnboardingSurveyField = {
|
||||
id: string
|
||||
type: OnboardingSurveyFieldType
|
||||
labelKey?: string
|
||||
label?: LocalizedString
|
||||
options?: OnboardingSurveyOption[]
|
||||
required?: boolean
|
||||
randomize?: boolean
|
||||
allowOther?: boolean
|
||||
otherFieldId?: string
|
||||
placeholder?: string
|
||||
showWhen?: OnboardingSurveyFieldCondition
|
||||
}
|
||||
|
||||
export type OnboardingSurvey = {
|
||||
version: number
|
||||
introKey?: string
|
||||
fields: OnboardingSurveyField[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote configuration type
|
||||
* Configuration fetched from the server at runtime
|
||||
@@ -93,7 +45,6 @@ export type RemoteConfig = {
|
||||
asset_rename_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
onboarding_survey?: OnboardingSurvey
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryDispatcher,
|
||||
@@ -81,12 +80,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackMonthlySubscriptionSucceeded?.(metadata)
|
||||
)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
|
||||
@@ -98,90 +98,6 @@ describe('GtmTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes subscription_success metadata with ecommerce reset', () => {
|
||||
const provider = createInitializedProvider()
|
||||
|
||||
provider.trackMonthlySubscriptionSucceeded({
|
||||
checkout_attempt_id: 'attempt-123',
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
value: 336,
|
||||
currency: 'USD',
|
||||
ecommerce: {
|
||||
currency: 'USD',
|
||||
value: 336,
|
||||
items: [
|
||||
{
|
||||
item_name: 'creator',
|
||||
item_category: 'subscription',
|
||||
item_variant: 'yearly',
|
||||
price: 336,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const dataLayer = window.dataLayer as Record<string, unknown>[]
|
||||
|
||||
expect(dataLayer[dataLayer.length - 2]).toMatchObject({ ecommerce: null })
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'subscription_success',
|
||||
checkout_attempt_id: 'attempt-123',
|
||||
value: 336
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reset ecommerce when GTM is not initialized', () => {
|
||||
window.__CONFIG__ = {
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
|
||||
const provider = new GtmTelemetryProvider()
|
||||
|
||||
provider.trackMonthlySubscriptionSucceeded({
|
||||
checkout_attempt_id: 'attempt-123',
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
value: 336,
|
||||
currency: 'USD',
|
||||
ecommerce: {
|
||||
currency: 'USD',
|
||||
value: 336,
|
||||
items: [
|
||||
{
|
||||
item_name: 'creator',
|
||||
item_category: 'subscription',
|
||||
item_variant: 'yearly',
|
||||
price: 336,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const dataLayer = window.dataLayer as unknown[]
|
||||
|
||||
expect(
|
||||
dataLayer.some(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'ecommerce' in (entry as Record<string, unknown>)
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
dataLayer.some(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).event === 'subscription_success'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('pushes run_workflow with trigger_source', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackRunButton({ trigger_source: 'button' })
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryProvider,
|
||||
@@ -168,17 +167,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
this.pushEvent('signup_opened')
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
if (this.initialized && metadata?.ecommerce) {
|
||||
window.dataLayer?.push({ ecommerce: null })
|
||||
}
|
||||
|
||||
this.pushEvent(
|
||||
'subscription_success',
|
||||
metadata ? { ...metadata } : undefined
|
||||
)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.pushEvent('subscription_success')
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
|
||||
@@ -31,7 +31,6 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
@@ -236,10 +235,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
@@ -256,10 +255,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
|
||||
@@ -40,11 +40,6 @@ export interface SurveyResponses {
|
||||
industry?: string
|
||||
useCase?: string
|
||||
making?: string[]
|
||||
role?: string
|
||||
teamSize?: string
|
||||
source?: string
|
||||
usage?: string
|
||||
intent?: string[]
|
||||
}
|
||||
|
||||
export interface SurveyResponsesNormalized extends SurveyResponses {
|
||||
@@ -349,32 +344,6 @@ export interface BeginCheckoutMetadata
|
||||
previous_tier?: TierKey
|
||||
}
|
||||
|
||||
interface EcommerceItemMetadata {
|
||||
item_name: string
|
||||
item_category: string
|
||||
item_variant?: string
|
||||
price: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
interface EcommerceMetadata {
|
||||
currency: string
|
||||
value: number
|
||||
items: EcommerceItemMetadata[]
|
||||
}
|
||||
|
||||
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
user_id?: string
|
||||
checkout_attempt_id: string
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
value: number
|
||||
currency: string
|
||||
ecommerce: EcommerceMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
@@ -391,9 +360,7 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionMetadata
|
||||
): void
|
||||
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
|
||||
trackMonthlySubscriptionSucceeded?(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionSucceeded?(): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
@@ -592,4 +559,3 @@ export type TelemetryEventProperties =
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
|
||||
@@ -102,7 +102,6 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -129,7 +128,6 @@ const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
@@ -228,7 +226,6 @@ async function handleAddCreditCard() {
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
@@ -283,7 +280,6 @@ async function handleConfirmTransition() {
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
|
||||
@@ -11,6 +11,11 @@ function makeOutput(
|
||||
}
|
||||
|
||||
describe(flattenNodeOutput, () => {
|
||||
it('returns empty array for nullish node output', () => {
|
||||
expect(flattenNodeOutput(['1', null])).toEqual([])
|
||||
expect(flattenNodeOutput(['1', undefined])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ unknown: 'hello' })])
|
||||
expect(result).toEqual([])
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function flattenNodeOutput([nodeId, nodeOutput]: [
|
||||
string | number,
|
||||
NodeExecutionOutput
|
||||
NodeExecutionOutput | null | undefined
|
||||
]): ResultItemImpl[] {
|
||||
return parseNodeOutput(nodeId, nodeOutput)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<button
|
||||
v-for="(url, index) in imageUrls"
|
||||
:key="index"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
@@ -193,7 +193,7 @@ const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
|
||||
@@ -820,6 +820,17 @@ async function handleDrop(event: DragEvent) {
|
||||
const node = lgraphNode.value
|
||||
if (!node?.onDragDrop) return
|
||||
|
||||
await node.onDragDrop(event, true)
|
||||
// Backport-only compat: preserve the legacy `handled === true` sync-return
|
||||
// path for custom-node `onDragDrop` implementations that don't participate
|
||||
// in the new `claimEvent` flag. Async handlers from `useNodeDragAndDrop`
|
||||
// claim the event themselves via the second arg; sync handlers that
|
||||
// return `true` still get their event claimed here so the drop does not
|
||||
// bubble to the document fallback in `app.ts`.
|
||||
const result = node.onDragDrop(event, true)
|
||||
if (result === true && !event.defaultPrevented) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
await result
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import type { RenderOptions } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { NodeId as VueNodeId } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import {
|
||||
createMockNodeInputSlot,
|
||||
createMockNodeOutputSlot
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
|
||||
const toVueNodeId = (id: string | number): VueNodeId => String(id)
|
||||
|
||||
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
id: '123',
|
||||
title: 'Test Node',
|
||||
@@ -41,6 +29,22 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
function makeInputSlot(
|
||||
name: string,
|
||||
type: string,
|
||||
extra?: Partial<INodeInputSlot>
|
||||
): INodeInputSlot {
|
||||
return { name, type, boundingRect: [0, 0, 0, 0], link: null, ...extra }
|
||||
}
|
||||
|
||||
function makeOutputSlot(
|
||||
name: string,
|
||||
type: string,
|
||||
extra?: Partial<INodeOutputSlot>
|
||||
): INodeOutputSlot {
|
||||
return { name, type, boundingRect: [0, 0, 0, 0], links: [], ...extra }
|
||||
}
|
||||
|
||||
// Explicit stubs to capture props for assertions
|
||||
interface StubSlotData {
|
||||
name?: string
|
||||
@@ -51,7 +55,6 @@ interface StubSlotData {
|
||||
const STUB_SLOT_PROPS = {
|
||||
slotData: { type: Object as PropType<StubSlotData>, required: true },
|
||||
nodeId: { type: String, required: false, default: '' },
|
||||
hasError: { type: Boolean, required: false, default: false },
|
||||
index: { type: Number, required: true },
|
||||
readonly: { type: Boolean, required: false, default: false }
|
||||
} as const
|
||||
@@ -66,7 +69,6 @@ const InputSlotStub = defineComponent({
|
||||
:data-name="slotData && slotData.name ? slotData.name : ''"
|
||||
:data-type="slotData && slotData.type ? slotData.type : ''"
|
||||
:data-node-id="nodeId"
|
||||
:data-has-error="hasError ? 'true' : 'false'"
|
||||
:data-readonly="readonly ? 'true' : 'false'"
|
||||
/>
|
||||
`
|
||||
@@ -87,21 +89,6 @@ const OutputSlotStub = defineComponent({
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
type SlotComponentStubs = NonNullable<
|
||||
NonNullable<RenderOptions<typeof NodeSlots>['global']>['stubs']
|
||||
>
|
||||
|
||||
const defaultSlotStubs: SlotComponentStubs = {
|
||||
InputSlot: InputSlotStub,
|
||||
OutputSlot: OutputSlotStub
|
||||
}
|
||||
|
||||
function createTrackingStub(
|
||||
componentName: 'InputSlot' | 'OutputSlot',
|
||||
mountCounts: Map<string, number>
|
||||
@@ -125,10 +112,40 @@ function createTrackingStub(
|
||||
})
|
||||
}
|
||||
|
||||
function renderSlots(
|
||||
const mountSlots = (nodeData: VueNodeData) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(NodeSlots, {
|
||||
global: {
|
||||
plugins: [i18n, createTestingPinia({ stubActions: false })],
|
||||
stubs: {
|
||||
InputSlot: InputSlotStub,
|
||||
OutputSlot: OutputSlotStub
|
||||
}
|
||||
},
|
||||
props: { nodeData }
|
||||
})
|
||||
}
|
||||
|
||||
function mountSlotsWithTracking(
|
||||
nodeData: VueNodeData,
|
||||
stubs: SlotComponentStubs = defaultSlotStubs
|
||||
mountCounts: Map<string, number>,
|
||||
trackingTarget: 'InputSlot' | 'OutputSlot'
|
||||
) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
const trackingStub = createTrackingStub(trackingTarget, mountCounts)
|
||||
const stubs =
|
||||
trackingTarget === 'InputSlot'
|
||||
? { InputSlot: trackingStub, OutputSlot: OutputSlotStub }
|
||||
: { InputSlot: InputSlotStub, OutputSlot: trackingStub }
|
||||
|
||||
return render(NodeSlots, {
|
||||
global: {
|
||||
plugins: [i18n, createTestingPinia({ stubActions: false })],
|
||||
@@ -138,27 +155,9 @@ function renderSlots(
|
||||
})
|
||||
}
|
||||
|
||||
function renderSlotsWithTracking(
|
||||
nodeData: VueNodeData,
|
||||
mountCounts: Map<string, number>,
|
||||
trackingTarget: 'InputSlot' | 'OutputSlot'
|
||||
) {
|
||||
const trackingStub = createTrackingStub(trackingTarget, mountCounts)
|
||||
const stubs =
|
||||
trackingTarget === 'InputSlot'
|
||||
? { InputSlot: trackingStub, OutputSlot: OutputSlotStub }
|
||||
: { InputSlot: InputSlotStub, OutputSlot: trackingStub }
|
||||
|
||||
return renderSlots(nodeData, stubs)
|
||||
}
|
||||
|
||||
const INPUT_SLOT_SELECTOR = '.stub-input-slot'
|
||||
const OUTPUT_SLOT_SELECTOR = '.stub-output-slot'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function querySlotElements(
|
||||
container: Element,
|
||||
selector: string
|
||||
@@ -171,42 +170,25 @@ function querySlotElements(
|
||||
}
|
||||
|
||||
function getRenderedSlotIndex(container: Element, slotName: string) {
|
||||
return Number(getRenderedSlotElement(container, slotName).dataset.index)
|
||||
}
|
||||
|
||||
function getRenderedSlotElement(container: Element, slotName: string) {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const el = container.querySelector(`[data-name="${slotName}"]`)
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
throw new Error(`Slot element "${slotName}" not found`)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function expectSlotError(
|
||||
container: Element,
|
||||
slotName: string,
|
||||
hasError: boolean
|
||||
) {
|
||||
expect(getRenderedSlotElement(container, slotName)).toHaveAttribute(
|
||||
'data-has-error',
|
||||
hasError ? 'true' : 'false'
|
||||
)
|
||||
return Number(el.dataset.index)
|
||||
}
|
||||
|
||||
describe('NodeSlots.vue', () => {
|
||||
it('filters out inputs with widget property and maps indexes correctly', () => {
|
||||
const inputs = [
|
||||
createMockNodeInputSlot({ name: 'objNoWidget', type: 'number' }),
|
||||
createMockNodeInputSlot({
|
||||
name: 'objWithWidget',
|
||||
type: 'number',
|
||||
const inputs: INodeInputSlot[] = [
|
||||
makeInputSlot('objNoWidget', 'number'),
|
||||
makeInputSlot('objWithWidget', 'number', {
|
||||
widget: { name: 'objWithWidget' }
|
||||
}),
|
||||
createMockNodeInputSlot({ name: 'stringInput', type: 'string' })
|
||||
makeInputSlot('stringInput', 'string')
|
||||
]
|
||||
|
||||
const { container } = renderSlots(makeNodeData({ inputs }))
|
||||
const { container } = mountSlots(makeNodeData({ inputs }))
|
||||
|
||||
const inputEls = querySlotElements(container, INPUT_SLOT_SELECTOR)
|
||||
expect(inputEls).toHaveLength(2)
|
||||
@@ -240,12 +222,12 @@ describe('NodeSlots.vue', () => {
|
||||
})
|
||||
|
||||
it('maps outputs and passes correct indexes', () => {
|
||||
const outputs = [
|
||||
createMockNodeOutputSlot({ name: 'outA', type: 'any' }),
|
||||
createMockNodeOutputSlot({ name: 'outB', type: 'any' })
|
||||
const outputs: INodeOutputSlot[] = [
|
||||
makeOutputSlot('outA', 'any'),
|
||||
makeOutputSlot('outB', 'any')
|
||||
]
|
||||
|
||||
const { container } = renderSlots(makeNodeData({ outputs }))
|
||||
const { container } = mountSlots(makeNodeData({ outputs }))
|
||||
const outputEls = querySlotElements(container, OUTPUT_SLOT_SELECTOR)
|
||||
|
||||
expect(outputEls).toHaveLength(2)
|
||||
@@ -262,100 +244,15 @@ describe('NodeSlots.vue', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('passes validation error state to matching input slots', async () => {
|
||||
const inputs = [
|
||||
createMockNodeInputSlot({ name: 'model', type: 'MODEL' }),
|
||||
createMockNodeInputSlot({ name: 'steps', type: 'INT' })
|
||||
]
|
||||
const nodeData = makeNodeData({ inputs })
|
||||
const { container } = renderSlots(nodeData)
|
||||
seedRequiredInputMissingNodeError(
|
||||
useExecutionErrorStore(),
|
||||
nodeData.id,
|
||||
'model'
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expectSlotError(container, 'model', true)
|
||||
expectSlotError(container, 'steps', false)
|
||||
})
|
||||
|
||||
it('maps one-level subgraph execution ids to input slot errors', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('InteriorNode')
|
||||
interiorNode.id = 70
|
||||
interiorNode.addInput('model', 'MODEL')
|
||||
interiorNode.addInput('steps', 'INT')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const graph = subgraphNode.rootGraph
|
||||
graph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const nodeData = makeNodeData({
|
||||
id: toVueNodeId(interiorNode.id),
|
||||
subgraphId: subgraph.id,
|
||||
inputs: interiorNode.inputs
|
||||
})
|
||||
const { container } = renderSlots(nodeData)
|
||||
seedRequiredInputMissingNodeError(
|
||||
useExecutionErrorStore(),
|
||||
'65:70',
|
||||
'model'
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expectSlotError(container, 'model', true)
|
||||
expectSlotError(container, 'steps', false)
|
||||
})
|
||||
|
||||
it('maps nested subgraph execution ids to input slot errors', async () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
innerNode.id = 63
|
||||
innerNode.addInput('image', 'IMAGE')
|
||||
innerNode.addInput('mask', 'MASK')
|
||||
innerSubgraph.add(innerNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 70,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 65 })
|
||||
const graph = outerSubgraphNode.rootGraph
|
||||
graph.add(outerSubgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const nodeData = makeNodeData({
|
||||
id: toVueNodeId(innerNode.id),
|
||||
subgraphId: innerSubgraph.id,
|
||||
inputs: innerNode.inputs
|
||||
})
|
||||
const { container } = renderSlots(nodeData)
|
||||
seedRequiredInputMissingNodeError(
|
||||
useExecutionErrorStore(),
|
||||
'65:70:63',
|
||||
'mask'
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expectSlotError(container, 'image', false)
|
||||
expectSlotError(container, 'mask', true)
|
||||
})
|
||||
|
||||
it('remounts OutputSlot when index shifts due to output removal', async () => {
|
||||
const mountCounts = new Map<string, number>()
|
||||
const outputs = [
|
||||
createMockNodeOutputSlot({ name: 'outA', type: 'IMAGE' }),
|
||||
createMockNodeOutputSlot({ name: 'outB', type: 'VIDEO' }),
|
||||
createMockNodeOutputSlot({ name: 'outC', type: 'AUDIO' })
|
||||
makeOutputSlot('outA', 'IMAGE'),
|
||||
makeOutputSlot('outB', 'VIDEO'),
|
||||
makeOutputSlot('outC', 'AUDIO')
|
||||
]
|
||||
|
||||
const { container, rerender } = renderSlotsWithTracking(
|
||||
const { container, rerender } = mountSlotsWithTracking(
|
||||
makeNodeData({ outputs }),
|
||||
mountCounts,
|
||||
'OutputSlot'
|
||||
@@ -367,8 +264,8 @@ describe('NodeSlots.vue', () => {
|
||||
await rerender({
|
||||
nodeData: makeNodeData({
|
||||
outputs: [
|
||||
createMockNodeOutputSlot({ name: 'outA', type: 'IMAGE' }),
|
||||
createMockNodeOutputSlot({ name: 'outC', type: 'AUDIO' })
|
||||
makeOutputSlot('outA', 'IMAGE'),
|
||||
makeOutputSlot('outC', 'AUDIO')
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -378,21 +275,21 @@ describe('NodeSlots.vue', () => {
|
||||
})
|
||||
|
||||
it('renders nothing when there are no inputs/outputs', () => {
|
||||
const { container } = renderSlots(makeNodeData({ inputs: [], outputs: [] }))
|
||||
const { container } = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
|
||||
expect(querySlotElements(container, INPUT_SLOT_SELECTOR)).toHaveLength(0)
|
||||
expect(querySlotElements(container, OUTPUT_SLOT_SELECTOR)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes correct actual indices for multi-group input layout', () => {
|
||||
const inputs = [
|
||||
createMockNodeInputSlot({ name: 'ref_images.img0', type: 'IMAGE' }),
|
||||
createMockNodeInputSlot({ name: 'ref_images.img1', type: 'IMAGE' }),
|
||||
createMockNodeInputSlot({ name: 'ref_images.img2', type: 'IMAGE' }),
|
||||
createMockNodeInputSlot({ name: 'ref_videos.vid0', type: 'VIDEO' }),
|
||||
createMockNodeInputSlot({ name: 'ref_videos.vid1', type: 'VIDEO' })
|
||||
const inputs: INodeInputSlot[] = [
|
||||
makeInputSlot('ref_images.img0', 'IMAGE'),
|
||||
makeInputSlot('ref_images.img1', 'IMAGE'),
|
||||
makeInputSlot('ref_images.img2', 'IMAGE'),
|
||||
makeInputSlot('ref_videos.vid0', 'VIDEO'),
|
||||
makeInputSlot('ref_videos.vid1', 'VIDEO')
|
||||
]
|
||||
|
||||
const { container } = renderSlots(makeNodeData({ inputs }))
|
||||
const { container } = mountSlots(makeNodeData({ inputs }))
|
||||
|
||||
const inputEls = querySlotElements(container, INPUT_SLOT_SELECTOR)
|
||||
|
||||
@@ -414,11 +311,11 @@ describe('NodeSlots.vue', () => {
|
||||
it('remounts InputSlot when index shifts due to autogrow insertion', async () => {
|
||||
const mountCounts = new Map<string, number>()
|
||||
const initialInputs = [
|
||||
createMockNodeInputSlot({ name: 'ref_images.img0', type: 'IMAGE' }),
|
||||
createMockNodeInputSlot({ name: 'ref_videos.vid0', type: 'VIDEO' })
|
||||
makeInputSlot('ref_images.img0', 'IMAGE'),
|
||||
makeInputSlot('ref_videos.vid0', 'VIDEO')
|
||||
]
|
||||
|
||||
const { container, rerender } = renderSlotsWithTracking(
|
||||
const { container, rerender } = mountSlotsWithTracking(
|
||||
makeNodeData({ inputs: initialInputs }),
|
||||
mountCounts,
|
||||
'InputSlot'
|
||||
@@ -430,9 +327,9 @@ describe('NodeSlots.vue', () => {
|
||||
await rerender({
|
||||
nodeData: makeNodeData({
|
||||
inputs: [
|
||||
createMockNodeInputSlot({ name: 'ref_images.img0', type: 'IMAGE' }),
|
||||
createMockNodeInputSlot({ name: 'ref_images.img1', type: 'IMAGE' }),
|
||||
createMockNodeInputSlot({ name: 'ref_videos.vid0', type: 'VIDEO' })
|
||||
makeInputSlot('ref_images.img0', 'IMAGE'),
|
||||
makeInputSlot('ref_images.img1', 'IMAGE'),
|
||||
makeInputSlot('ref_videos.vid0', 'VIDEO')
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
:slot-data="input"
|
||||
:node-type="nodeData?.type || ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:has-error="inputHasError(input)"
|
||||
:index="getActualInputIndex(input, index)"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,8 +44,6 @@ import {
|
||||
linkedWidgetedInputs,
|
||||
nonWidgetedInputs
|
||||
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { getLocatorIdFromNodeData } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
@@ -58,8 +55,6 @@ interface NodeSlotsProps {
|
||||
}
|
||||
|
||||
const { nodeData, unified = false } = defineProps<NodeSlotsProps>()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
|
||||
const linkedWidgetInputs = computed(() =>
|
||||
unified ? linkedWidgetedInputs(nodeData) : []
|
||||
@@ -70,10 +65,6 @@ const filteredInputs = computed(() => [
|
||||
...linkedWidgetInputs.value
|
||||
])
|
||||
|
||||
function inputHasError(input: INodeSlot): boolean {
|
||||
return executionErrorStore.slotHasError(nodeLocatorId.value, input.name)
|
||||
}
|
||||
|
||||
const unifiedWrapperClass = computed((): string =>
|
||||
cn(
|
||||
unified &&
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
class="h-auto w-full rounded-sm object-contain"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
@@ -233,7 +238,7 @@ const showNavButtons = computed(
|
||||
)
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors hover:bg-base-foreground/90'
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
|
||||
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
|
||||
const mockAssetsStoreState = vi.hoisted(() => {
|
||||
const inputAssets: AssetItem[] = []
|
||||
return {
|
||||
@@ -56,8 +55,7 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
return mockAssetsStoreState.inputLoading
|
||||
},
|
||||
updateInputs: mockUpdateInputs,
|
||||
getInputName: mockGetInputName,
|
||||
getAssets: mockGetAssets
|
||||
getInputName: mockGetInputName
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -201,117 +199,67 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
describe('cloud asset browser widget', () => {
|
||||
// "Select model" is the fallback from t('widgets.selectModel')
|
||||
// in createAssetWidget when defaultValue is undefined.
|
||||
const PLACEHOLDER = 'Select model'
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
function setupCloudAssetWidget(
|
||||
inputSpecOverrides: Partial<InputSpec> = {}
|
||||
) {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: ''
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
...inputSpecOverrides
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
return { mockNode }
|
||||
}
|
||||
|
||||
function getWidgetDefault(mockNode: ReturnType<typeof createMockNode>) {
|
||||
return vi.mocked(mockNode.addWidget).mock.calls[0]?.[2]
|
||||
}
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
expect(
|
||||
vi.mocked(assetService.shouldUseAssetBrowser)
|
||||
).toHaveBeenCalledWith('CheckpointLoaderSimple', 'ckpt_name')
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'model1.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
it('should use first cloud asset as default instead of server combo options', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['local_only_model.safetensors']
|
||||
})
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'fallback.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
default: 'fallback.safetensors'
|
||||
// Note: no options array provided
|
||||
})
|
||||
|
||||
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
default: 'not_in_cloud.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
})
|
||||
|
||||
it('should prefer inputSpec.default when it exists in cloud assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'other_model.safetensors' }),
|
||||
createMockAssetItem({ name: 'fallback.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('fallback.safetensors')
|
||||
})
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('should fallback to placeholder when cloud assets not loaded', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['local_model.safetensors']
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'fallback.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type {
|
||||
@@ -105,25 +104,6 @@ const addMultiSelectWidget = (
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default value for a cloud asset widget.
|
||||
* Priority: inputSpec.default (if present in cloud assets) → first cloud
|
||||
* asset → undefined (shows placeholder).
|
||||
*/
|
||||
function resolveCloudDefault(
|
||||
nodeType: string,
|
||||
specDefault: string | undefined
|
||||
): string | undefined {
|
||||
const assets = useAssetsStore().getAssets(nodeType)
|
||||
if (specDefault != null) {
|
||||
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
|
||||
if (inAssets) return specDefault
|
||||
}
|
||||
// empty filename → undefined (shows placeholder)
|
||||
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
|
||||
return filename || undefined
|
||||
}
|
||||
|
||||
function createAssetBrowserWidget(
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec,
|
||||
@@ -215,14 +195,7 @@ const addComboWidget = (
|
||||
|
||||
if (isCloud) {
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
// Default from cloud assets, not from server combo options.
|
||||
// Server options list local files that may not exist in the user's
|
||||
// cloud asset library, leading to missing-model errors on undo/reload.
|
||||
const cloudDefault = resolveCloudDefault(
|
||||
node.comfyClass ?? '',
|
||||
inputSpec.default
|
||||
)
|
||||
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
|
||||
return createAssetBrowserWidget(node, inputSpec, defaultValue)
|
||||
}
|
||||
|
||||
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
|
||||
|
||||
@@ -323,38 +323,6 @@ describe('useGLSLPreview', () => {
|
||||
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses custom resolution when size_mode is custom', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
store._widgetMap.set('size_mode', { value: 'custom' })
|
||||
store._widgetMap.set('size_mode.width', { value: 800 })
|
||||
store._widgetMap.set('size_mode.height', { value: 600 })
|
||||
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
|
||||
|
||||
store._widgetMap.delete('size_mode')
|
||||
store._widgetMap.delete('size_mode.width')
|
||||
store._widgetMap.delete('size_mode.height')
|
||||
})
|
||||
|
||||
it('uses default resolution when size_mode is not custom', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
store._widgetMap.set('size_mode', { value: 'from_input' })
|
||||
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
|
||||
|
||||
store._widgetMap.delete('size_mode')
|
||||
})
|
||||
|
||||
it('disposes renderer and cancels debounce on cleanup', async () => {
|
||||
const node = createMockNode()
|
||||
const { dispose } = await setupAndRender(node)
|
||||
|
||||
@@ -282,44 +282,7 @@ function createInnerPreview(
|
||||
}
|
||||
}
|
||||
|
||||
const customResolution = computed((): [number, number] | null => {
|
||||
const gId = graphId.value
|
||||
if (!gId) return null
|
||||
|
||||
const sizeModeNodeId = innerGLSLNode
|
||||
? (innerGLSLNode.id as NodeId)
|
||||
: nodeId.value
|
||||
if (sizeModeNodeId == null) return null
|
||||
|
||||
const sizeMode = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode'
|
||||
)
|
||||
if (sizeMode?.value !== 'custom') return null
|
||||
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (!widthWidget || !heightWidget) return null
|
||||
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
})
|
||||
|
||||
function getResolution(): [number, number] {
|
||||
const custom = customResolution.value
|
||||
if (custom) return custom
|
||||
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
|
||||
@@ -362,6 +325,27 @@ function createInnerPreview(
|
||||
}
|
||||
}
|
||||
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (gId && nId != null) {
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (widthWidget && heightWidget) {
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
}
|
||||
|
||||
@@ -481,8 +465,7 @@ function createInnerPreview(
|
||||
floatValues.value,
|
||||
intValues.value,
|
||||
boolValues.value,
|
||||
curveValues.value,
|
||||
customResolution.value
|
||||
curveValues.value
|
||||
] as const,
|
||||
() => {
|
||||
if (shouldRender.value) debouncedRender()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user