mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-26 15:27:32 +00:00
Compare commits
10 Commits
perf/batch
...
fix/dropdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96b56d771 | ||
|
|
8894119dc9 | ||
|
|
d2345fc7eb | ||
|
|
8a88e40c40 | ||
|
|
0def631c52 | ||
|
|
7b5a49975f | ||
|
|
3d0389ac5b | ||
|
|
049657b38f | ||
|
|
b5bae1f721 | ||
|
|
59f4ed8232 |
@@ -18,20 +18,12 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## CI Safety Rules
|
||||
|
||||
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
|
||||
|
||||
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
|
||||
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
|
||||
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
@@ -116,15 +108,11 @@ git fetch origin TARGET_BRANCH
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
|
||||
@@ -19,44 +19,23 @@ done
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
|
||||
|
||||
## Step 2: Wait for CI & Merge Clean Auto-PRs
|
||||
|
||||
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
# Wait for CI to complete (~45 minutes for full suite)
|
||||
sleep 2700
|
||||
|
||||
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
|
||||
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
|
||||
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
|
||||
|
||||
# For PRs still open, check CI status
|
||||
for pr in $STILL_OPEN_PRS; do
|
||||
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
|
||||
if [ "$CI_FAILED" != "0" ]; then
|
||||
# CI failed — collect details for triage
|
||||
echo "PR #$pr — CI FAILED:"
|
||||
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
|
||||
elif [ "$CI_PENDING" != "0" ]; then
|
||||
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
|
||||
else
|
||||
# All checks passed but didn't auto-merge (race condition or label issue)
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
fi
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
@@ -84,13 +63,6 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
|
||||
# Wait for CI before merging — NEVER admin-merge without CI passing
|
||||
echo "Waiting for CI on PR #$NEW_PR..."
|
||||
gh pr checks $NEW_PR --watch --fail-fast || {
|
||||
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
|
||||
continue
|
||||
}
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
@@ -110,7 +82,7 @@ After completing all PRs in a wave for a target branch:
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
@@ -160,8 +132,7 @@ git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
@@ -175,31 +146,5 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
When CI fails on a backport PR, present failures to the user using this template:
|
||||
|
||||
```markdown
|
||||
### PR #XXXX — CI Failed
|
||||
|
||||
- **Failing check:** test / lint / typecheck
|
||||
- **Error:** (summary of the failure message)
|
||||
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
|
||||
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
|
||||
```
|
||||
|
||||
Common failure categories:
|
||||
|
||||
| Category | Example | Resolution |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
|
||||
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
|
||||
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
|
||||
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
@@ -19,7 +19,6 @@ Track verification results per wave:
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Unit tests: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
@@ -42,11 +41,6 @@ Track verification results per wave:
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## CI Failure Report
|
||||
|
||||
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
|
||||
| --- | ------ | ------------- | ------------- | ----- | ---------- |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
|
||||
2
.github/workflows/pr-report.yaml
vendored
2
.github/workflows/pr-report.yaml
vendored
@@ -180,7 +180,7 @@ jobs:
|
||||
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
|
||||
git fetch origin perf-data --depth=1
|
||||
mkdir -p temp/perf-history
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
|
||||
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
|
||||
done
|
||||
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
{
|
||||
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 16,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451.0058559453123, 189.0019842294924],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [25.988896564209426, 473.9973077158204],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
|
||||
"pos": [711.776576770508, 420.55569028417983],
|
||||
"size": [400, 293],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 12
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "text"],
|
||||
["6", "text"],
|
||||
["3", "seed"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[10, 4, 1, 10, 0, "CLIP"],
|
||||
[11, 4, 0, 10, 1, "MODEL"],
|
||||
[12, 4, 2, 10, 2, "VAE"],
|
||||
[13, 10, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 16,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [233, 404.5, 120, 100]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1494, 424.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [14],
|
||||
"localized_name": "clip",
|
||||
"pos": [333, 424.5]
|
||||
},
|
||||
{
|
||||
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [333, 444.5]
|
||||
},
|
||||
{
|
||||
"id": "e61199fa-9113-4532-a3d9-879095969171",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"linkIds": [8],
|
||||
"localized_name": "vae",
|
||||
"pos": [333, 464.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [9],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1514, 444.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473.007643669922, 609.0214689174805],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [862.990643669922, 185.9853293300783],
|
||||
"size": [400, 317],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209.0062878349609, 188.00400724755877],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
|
||||
"pos": [485.5190761650391, 283.9247189174806],
|
||||
"size": [400, 237],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 14
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [15]
|
||||
},
|
||||
{
|
||||
"localized_name": "CONDITIONING_1",
|
||||
"name": "CONDITIONING_1",
|
||||
"type": "CONDITIONING",
|
||||
"links": [16]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "text"],
|
||||
["6", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 8,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 8,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 16,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [5, 3],
|
||||
"localized_name": "clip",
|
||||
"pos": [55, 20]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "CONDITIONING",
|
||||
"pos": [20, 20]
|
||||
},
|
||||
{
|
||||
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
|
||||
"name": "CONDITIONING_1",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "CONDITIONING_1",
|
||||
"pos": [20, 40]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413.01228575000005, 388.98593823266606],
|
||||
"size": [425, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [6]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [414.99053247091683, 185.9946096918335],
|
||||
"size": [423, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6830134553650709,
|
||||
"offset": [-203.70966200000038, 259.92420099999975]
|
||||
},
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
@@ -40,7 +40,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
|
||||
@@ -25,15 +25,13 @@ export class DragDropHelper {
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
waitForUpload = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
@@ -45,8 +43,7 @@ export class DragDropHelper {
|
||||
fileType?: string
|
||||
buffer?: Uint8Array | number[]
|
||||
url?: string
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
} = { dropPosition }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
@@ -118,17 +115,15 @@ export class DragDropHelper {
|
||||
)
|
||||
}
|
||||
|
||||
if (!params.preserveNativePropagation) {
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
}
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
targetElement.dispatchEvent(dragOverEvent)
|
||||
targetElement.dispatchEvent(dropEvent)
|
||||
@@ -159,10 +154,7 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
options: { dropPosition?: Position } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -191,7 +190,6 @@ export class PerformanceHelper {
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize'),
|
||||
heapUsedBytes: after.JSHeapUsedSize,
|
||||
domNodes: delta('Nodes'),
|
||||
jsHeapTotalBytes: delta('JSHeapTotalSize'),
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
|
||||
@@ -62,12 +62,12 @@ export const TestIds = {
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
formDropdownMenu: 'form-dropdown-menu',
|
||||
formDropdownTrigger: 'form-dropdown-trigger'
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
export default function globalSetup() {
|
||||
export default function globalSetup(_config: FullConfig) {
|
||||
if (!process.env.CI) {
|
||||
if (process.env.TEST_COMFYUI_DIR) {
|
||||
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
export default function globalTeardown() {
|
||||
export default function globalTeardown(_config: FullConfig) {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
@@ -143,15 +144,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
const menu = comfyPage.page
|
||||
.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const isInViewport = await menu.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
@@ -162,7 +160,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,44 +67,5 @@ test.describe(
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('Load workflow from URL dropped onto Vue node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fakeUrl = 'https://example.com/workflow.png'
|
||||
await comfyPage.page.route(fakeUrl, (route) =>
|
||||
route.fulfill({
|
||||
path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png')
|
||||
})
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
|
||||
const dropPosition = {
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
|
||||
dropPosition,
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
|
||||
await comfyPage.page.waitForFunction(
|
||||
(prevCount) => window.app!.graph.nodes.length !== prevCount,
|
||||
initialNodeCount,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newNodeCount).not.toBe(initialNodeCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await expect(widgets).toHaveCount(1)
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe(
|
||||
'FormDropdown positioning in Vue nodes',
|
||||
{ tag: ['@widget', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('dropdown menu appears directly below the trigger', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const triggerBox = await trigger.first().boundingBox()
|
||||
const menuBox = await menu.boundingBox()
|
||||
|
||||
expect(triggerBox).toBeTruthy()
|
||||
expect(menuBox).toBeTruthy()
|
||||
|
||||
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
|
||||
expect(menuBox!.y).toBeGreaterThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height - 5
|
||||
)
|
||||
expect(menuBox!.y).toBeLessThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height + 20
|
||||
)
|
||||
|
||||
// Menu left should be near the trigger left (within 10px tolerance)
|
||||
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
|
||||
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
|
||||
})
|
||||
|
||||
test('dropdown menu appears correctly at different zoom levels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
for (const zoom of [0.75, 1.5]) {
|
||||
// Set zoom via canvas
|
||||
await comfyPage.page.evaluate((scale) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = scale
|
||||
canvas.setDirty(true, true)
|
||||
}, zoom)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.formDropdownMenu
|
||||
)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const triggerBox = await trigger.first().boundingBox()
|
||||
const menuBox = await menu.boundingBox()
|
||||
|
||||
expect(triggerBox).toBeTruthy()
|
||||
expect(menuBox).toBeTruthy()
|
||||
|
||||
// Menu top should still be near trigger bottom regardless of zoom
|
||||
expect(menuBox!.y).toBeGreaterThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height - 5
|
||||
)
|
||||
expect(menuBox!.y).toBeLessThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height + 20 * zoom
|
||||
)
|
||||
|
||||
// Close dropdown before next iteration
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(menu).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('dropdown closes on outside click', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Click outside the node
|
||||
await comfyPage.page.mouse.click(10, 10)
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('dropdown closes on Escape key', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -22,7 +22,6 @@ interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -44,46 +43,22 @@ const HISTORY_DIR = 'temp/perf-history'
|
||||
|
||||
type MetricKey =
|
||||
| 'styleRecalcs'
|
||||
| 'styleRecalcDurationMs'
|
||||
| 'layouts'
|
||||
| 'layoutDurationMs'
|
||||
| 'taskDurationMs'
|
||||
| 'domNodes'
|
||||
| 'scriptDurationMs'
|
||||
| 'eventListeners'
|
||||
| 'totalBlockingTimeMs'
|
||||
| 'frameDurationMs'
|
||||
| 'heapUsedBytes'
|
||||
|
||||
interface MetricDef {
|
||||
key: MetricKey
|
||||
label: string
|
||||
unit: string
|
||||
/** Minimum absolute delta to consider meaningful (effect size gate) */
|
||||
minAbsDelta?: number
|
||||
}
|
||||
|
||||
const REPORTED_METRICS: MetricDef[] = [
|
||||
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
|
||||
{
|
||||
key: 'styleRecalcDurationMs',
|
||||
label: 'style recalc duration',
|
||||
unit: 'ms'
|
||||
},
|
||||
{ key: 'layouts', label: 'layout count', unit: '', minAbsDelta: 5 },
|
||||
{
|
||||
key: 'styleRecalcs',
|
||||
label: 'style recalc count',
|
||||
unit: '',
|
||||
minAbsDelta: 5
|
||||
},
|
||||
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
|
||||
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
|
||||
{ key: 'layouts', label: 'layouts', unit: '' },
|
||||
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
|
||||
{ key: 'domNodes', label: 'DOM nodes', unit: '' },
|
||||
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
|
||||
{ key: 'eventListeners', label: 'event listeners', unit: '' },
|
||||
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
|
||||
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
|
||||
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
|
||||
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
|
||||
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
|
||||
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' }
|
||||
]
|
||||
|
||||
function groupByName(
|
||||
@@ -159,9 +134,7 @@ function computeCV(stats: MetricStats): number {
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: string): string {
|
||||
if (unit === 'ms') return `${value.toFixed(0)}ms`
|
||||
if (unit === 'bytes') return formatBytes(value)
|
||||
return `${value.toFixed(0)}`
|
||||
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
|
||||
}
|
||||
|
||||
function formatDelta(pct: number | null): string {
|
||||
@@ -186,21 +159,6 @@ function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null {
|
||||
return values.reduce((sum, v) => sum + v, 0) / values.length
|
||||
}
|
||||
|
||||
function medianMetric(
|
||||
samples: PerfMeasurement[],
|
||||
key: MetricKey
|
||||
): number | null {
|
||||
const values = samples
|
||||
.map((s) => getMetricValue(s, key))
|
||||
.filter((v): v is number => v !== null)
|
||||
.sort((a, b) => a - b)
|
||||
if (values.length === 0) return null
|
||||
const mid = Math.floor(values.length / 2)
|
||||
return values.length % 2 === 0
|
||||
? (values[mid - 1] + values[mid]) / 2
|
||||
: values[mid]
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (Math.abs(bytes) < 1024) return `${bytes} B`
|
||||
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
@@ -215,7 +173,7 @@ function renderFullReport(
|
||||
const lines: string[] = []
|
||||
const baselineGroups = groupByName(baseline.measurements)
|
||||
const tableHeader = [
|
||||
'| Metric | Baseline | PR (median) | Δ | Sig |',
|
||||
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
|
||||
'|--------|----------|----------|---|-----|'
|
||||
]
|
||||
|
||||
@@ -225,38 +183,36 @@ function renderFullReport(
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
const baseSamples = baselineGroups.get(testName)
|
||||
|
||||
for (const { key, label, unit, minAbsDelta } of REPORTED_METRICS) {
|
||||
// Use median for PR values — robust to outlier runs in CI
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
const histStats = getHistoricalStats(historical, testName, key)
|
||||
const cv = computeCV(histStats)
|
||||
|
||||
if (!baseSamples?.length) {
|
||||
allRows.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const baseVal = medianMetric(baseSamples, key)
|
||||
const baseVal = meanMetric(baseSamples, key)
|
||||
if (baseVal === null) {
|
||||
allRows.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const absDelta = prVal - baseVal
|
||||
const deltaPct =
|
||||
baseVal === 0
|
||||
? prVal === 0
|
||||
? prMean === 0
|
||||
? 0
|
||||
: null
|
||||
: ((prVal - baseVal) / baseVal) * 100
|
||||
const z = zScore(prVal, histStats)
|
||||
const sig = classifyChange(z, cv, absDelta, minAbsDelta)
|
||||
: ((prMean - baseVal) / baseVal) * 100
|
||||
const z = zScore(prMean, histStats)
|
||||
const sig = classifyChange(z, cv)
|
||||
|
||||
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
|
||||
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
|
||||
allRows.push(row)
|
||||
if (isNoteworthy(sig)) {
|
||||
flaggedRows.push(row)
|
||||
@@ -343,7 +299,7 @@ function renderColdStartReport(
|
||||
const lines: string[] = []
|
||||
const baselineGroups = groupByName(baseline.measurements)
|
||||
lines.push(
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
|
||||
'',
|
||||
'| Metric | Baseline | PR | Δ |',
|
||||
'|--------|----------|-----|---|'
|
||||
@@ -353,31 +309,31 @@ function renderColdStartReport(
|
||||
const baseSamples = baselineGroups.get(testName)
|
||||
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
|
||||
if (!baseSamples?.length) {
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const baseVal = medianMetric(baseSamples, key)
|
||||
const baseVal = meanMetric(baseSamples, key)
|
||||
if (baseVal === null) {
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const deltaPct =
|
||||
baseVal === 0
|
||||
? prVal === 0
|
||||
? prMean === 0
|
||||
? 0
|
||||
: null
|
||||
: ((prVal - baseVal) / baseVal) * 100
|
||||
: ((prMean - baseVal) / baseVal) * 100
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |`
|
||||
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -396,10 +352,14 @@ function renderNoBaselineReport(
|
||||
)
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
|
||||
}
|
||||
const heapMean =
|
||||
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
|
||||
prSamples.length
|
||||
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -99,21 +99,6 @@ describe('classifyChange', () => {
|
||||
expect(classifyChange(2, 10)).toBe('neutral')
|
||||
expect(classifyChange(-2, 10)).toBe('neutral')
|
||||
})
|
||||
|
||||
it('returns neutral when absDelta below minAbsDelta despite high z', () => {
|
||||
// z=7.2 but only 1 unit change with minAbsDelta=5
|
||||
expect(classifyChange(7.2, 10, 1, 5)).toBe('neutral')
|
||||
expect(classifyChange(-7.2, 10, -1, 5)).toBe('neutral')
|
||||
})
|
||||
|
||||
it('returns regression when absDelta meets minAbsDelta', () => {
|
||||
expect(classifyChange(3, 10, 10, 5)).toBe('regression')
|
||||
})
|
||||
|
||||
it('ignores effect size gate when minAbsDelta not provided', () => {
|
||||
expect(classifyChange(3, 10)).toBe('regression')
|
||||
expect(classifyChange(3, 10, 1)).toBe('regression')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSignificance', () => {
|
||||
|
||||
@@ -31,28 +31,12 @@ export function zScore(value: number, stats: MetricStats): number | null {
|
||||
|
||||
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
|
||||
|
||||
/**
|
||||
* Classify a metric change as regression/improvement/neutral/noisy.
|
||||
*
|
||||
* Uses both statistical significance (z-score) and practical significance
|
||||
* (effect size gate via minAbsDelta) to reduce false positives from
|
||||
* integer-quantized metrics with near-zero variance.
|
||||
*/
|
||||
export function classifyChange(
|
||||
z: number | null,
|
||||
historicalCV: number,
|
||||
absDelta?: number,
|
||||
minAbsDelta?: number
|
||||
historicalCV: number
|
||||
): Significance {
|
||||
if (historicalCV > 50) return 'noisy'
|
||||
if (z === null) return 'neutral'
|
||||
|
||||
// Effect size gate: require minimum absolute change for count metrics
|
||||
// to avoid flagging e.g. 11→12 style recalcs as z=7.2 regression.
|
||||
if (minAbsDelta !== undefined && absDelta !== undefined) {
|
||||
if (Math.abs(absDelta) < minAbsDelta) return 'neutral'
|
||||
}
|
||||
|
||||
if (z > 2) return 'regression'
|
||||
if (z < -2) return 'improvement'
|
||||
return 'neutral'
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useClipboard: vi.fn(() => ({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true),
|
||||
text: ref('')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows success toast when modern clipboard succeeds', async () => {
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to legacy when modern clipboard fails', async () => {
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when both modern and legacy fail', async () => {
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => false)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls through to legacy when isSupported is false', async () => {
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => false),
|
||||
text: ref('')
|
||||
})
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockCopy).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,60 +3,34 @@ import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
function legacyCopy(text: string): boolean {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
return document.execCommand('copy')
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, isSupported } = useClipboard()
|
||||
const { copy, copied } = useClipboard({ legacy: true })
|
||||
const toast = useToast()
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
let success = false
|
||||
|
||||
try {
|
||||
if (isSupported.value) {
|
||||
await copy(text)
|
||||
success = true
|
||||
await copy(text)
|
||||
if (copied.value) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Modern clipboard API failed, fall through to legacy
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
try {
|
||||
success = legacyCopy(text)
|
||||
} catch {
|
||||
// Legacy also failed
|
||||
}
|
||||
}
|
||||
|
||||
toast.add(
|
||||
success
|
||||
? {
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
}
|
||||
: {
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -751,7 +751,7 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('full linked coverage prunes promotions referencing non-existent nodes', () => {
|
||||
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
@@ -776,9 +776,9 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
// Node 9999 does not exist in the subgraph, so its entry is pruned
|
||||
expect(promotions).toStrictEqual([
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' }
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -958,69 +958,3 @@ describe('SubgraphNode promotion view keys', () => {
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode label propagation', () => {
|
||||
it('should preserve input labels from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps', type: 'number' }]
|
||||
})
|
||||
subgraph.inputs[0].label = 'Steps Count'
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
|
||||
expect(subgraphNode.inputs[0].name).toBe('steps')
|
||||
})
|
||||
|
||||
it('should preserve output labels from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'result', type: 'number' }]
|
||||
})
|
||||
subgraph.outputs[0].label = 'Final Result'
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('Final Result')
|
||||
expect(subgraphNode.outputs[0].name).toBe('result')
|
||||
})
|
||||
|
||||
it('should propagate label via renaming-input event', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraph.addInput('steps', 'number')
|
||||
expect(subgraphNode.inputs[0].label).toBeUndefined()
|
||||
|
||||
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
|
||||
|
||||
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
|
||||
expect(subgraphNode.inputs[0].name).toBe('steps')
|
||||
})
|
||||
|
||||
it('should propagate label via renaming-output event', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraph.addOutput('result', 'number')
|
||||
expect(subgraphNode.outputs[0].label).toBeUndefined()
|
||||
|
||||
subgraph.renameOutput(subgraph.outputs[0], 'Final Result')
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('Final Result')
|
||||
expect(subgraphNode.outputs[0].name).toBe('result')
|
||||
})
|
||||
|
||||
it('should preserve localized_name from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps', type: 'number' }],
|
||||
outputs: [{ name: 'result', type: 'number' }]
|
||||
})
|
||||
subgraph.inputs[0].localized_name = 'ステップ'
|
||||
subgraph.outputs[0].localized_name = '結果'
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.inputs[0].localized_name).toBe('ステップ')
|
||||
expect(subgraphNode.outputs[0].localized_name).toBe('結果')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -515,8 +515,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const prunedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
for (const entry of fallbackStoredEntries) {
|
||||
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
|
||||
|
||||
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
|
||||
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
|
||||
|
||||
@@ -1065,7 +1063,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!this.subgraph.getNodeById(nodeId)) return null
|
||||
const entry: PromotedWidgetSource = {
|
||||
sourceNodeId: nodeId,
|
||||
sourceWidgetName: widgetName,
|
||||
@@ -1077,8 +1074,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, entries)
|
||||
|
||||
// Write back resolved entries so legacy or stale entries don't persist
|
||||
if (entries.length !== raw.length) {
|
||||
// Write back resolved entries so legacy -1 format doesn't persist
|
||||
if (raw.some(([id]) => id === '-1')) {
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
import {
|
||||
createEventCapture,
|
||||
@@ -264,62 +263,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nested Subgraph Widget Promotion', () => {
|
||||
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
|
||||
// Reproduces the bug where packing nodes into a nested subgraph leaves
|
||||
// stale proxyWidgets on the outer subgraph node referencing grandchild
|
||||
// node IDs that no longer exist directly in the outer subgraph.
|
||||
// Uses 3 inputs with only 1 having a linked widget entry, matching the
|
||||
// real workflow structure where model/vae inputs don't resolve widgets.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'clip', type: 'CLIP' },
|
||||
{ name: 'model', type: 'MODEL' },
|
||||
{ name: 'vae', type: 'VAE' }
|
||||
]
|
||||
})
|
||||
|
||||
const { node: samplerNode } = createNodeWithWidget(
|
||||
'Sampler',
|
||||
'number',
|
||||
42,
|
||||
'number'
|
||||
)
|
||||
subgraph.add(samplerNode)
|
||||
subgraph.inputNode.slots[1].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
// Add nodes without widget-connected inputs for the other slots
|
||||
const modelNode = new LGraphNode('ModelNode')
|
||||
modelNode.addInput('model', 'MODEL')
|
||||
subgraph.add(modelNode)
|
||||
|
||||
const vaeNode = new LGraphNode('VAENode')
|
||||
vaeNode.addInput('vae', 'VAE')
|
||||
subgraph.add(vaeNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Inject stale proxyWidgets referencing nodes that don't exist in
|
||||
// this subgraph (they were packed into a nested subgraph)
|
||||
outerNode.properties.proxyWidgets = [
|
||||
['999', 'text'],
|
||||
['998', 'text'],
|
||||
[String(samplerNode.id), 'widget']
|
||||
]
|
||||
|
||||
outerNode.configure(outerNode.serialize())
|
||||
|
||||
// Check widgets getter — stale entries should not produce views
|
||||
const widgetSourceIds = outerNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((w) => !w.name.startsWith('$$'))
|
||||
.map((w) => w.sourceNodeId)
|
||||
|
||||
expect(widgetSourceIds).not.toContain('999')
|
||||
expect(widgetSourceIds).not.toContain('998')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Promotion', () => {
|
||||
it('should preserve widget tooltip when promoting', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
|
||||
@@ -24,13 +24,7 @@ vi.mock('@/platform/telemetry/topupTracker', () => ({
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
|
||||
mockActiveWorkflow: null as null | {
|
||||
filename: string
|
||||
fullFilename: string
|
||||
},
|
||||
mockKnownTemplateNames: new Set<string>(),
|
||||
mockTemplateByName: null as null | { sourceModule?: string }
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
@@ -41,9 +35,7 @@ vi.mock('@/stores/nodeDefStore', () => ({
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return hoisted.mockActiveWorkflow
|
||||
}
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -51,11 +43,7 @@ vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
get knownTemplateNames() {
|
||||
return hoisted.mockKnownTemplateNames
|
||||
},
|
||||
getTemplateByName: (_name: string) => hoisted.mockTemplateByName,
|
||||
getEnglishMetadata: () => null
|
||||
knownTemplateNames: new Set()
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -97,9 +85,6 @@ describe('getExecutionContext', () => {
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
hoisted.mockActiveWorkflow = null
|
||||
hoisted.mockKnownTemplateNames = new Set()
|
||||
hoisted.mockTemplateByName = null
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
@@ -190,50 +175,4 @@ describe('getExecutionContext', () => {
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
|
||||
describe('template detection', () => {
|
||||
it('detects a regular template by name', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'flux-dev',
|
||||
fullFilename: 'flux-dev.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('flux-dev')
|
||||
})
|
||||
|
||||
it('detects an app mode template whose name ends with .app', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set([
|
||||
'templates-qwen_multiangle.app'
|
||||
])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
// getFilenameDetails strips ".app.json" as a compound extension, yielding
|
||||
// filename = "templates-qwen_multiangle" — the previous code would fail here.
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'templates-qwen_multiangle',
|
||||
fullFilename: 'templates-qwen_multiangle.app.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('templates-qwen_multiangle.app')
|
||||
})
|
||||
|
||||
it('does not flag a non-template workflow as a template', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'my-custom-workflow',
|
||||
fullFilename: 'my-custom-workflow.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,21 +80,20 @@ export function getExecutionContext(): ExecutionContext {
|
||||
)
|
||||
|
||||
if (activeWorkflow?.filename) {
|
||||
// Use fullFilename minus .json to reconstruct the template name, which
|
||||
// preserves compound suffixes like ".app" (e.g. "foo.app.json" → "foo.app").
|
||||
// Using just `filename` strips ".app.json" entirely (e.g. "foo"), which
|
||||
// won't match knownTemplateNames entries like "foo.app".
|
||||
const templateName = activeWorkflow.fullFilename.replace(/\.json$/i, '')
|
||||
const isTemplate = templatesStore.knownTemplateNames.has(templateName)
|
||||
const isTemplate = templatesStore.knownTemplateNames.has(
|
||||
activeWorkflow.filename
|
||||
)
|
||||
|
||||
if (isTemplate) {
|
||||
const template = templatesStore.getTemplateByName(templateName)
|
||||
const template = templatesStore.getTemplateByName(activeWorkflow.filename)
|
||||
|
||||
const englishMetadata = templatesStore.getEnglishMetadata(templateName)
|
||||
const englishMetadata = templatesStore.getEnglishMetadata(
|
||||
activeWorkflow.filename
|
||||
)
|
||||
|
||||
return {
|
||||
is_template: true,
|
||||
workflow_name: templateName,
|
||||
workflow_name: activeWorkflow.filename,
|
||||
template_source: template?.sourceModule,
|
||||
template_category: englishMetadata?.category ?? template?.category,
|
||||
template_tags: englishMetadata?.tags ?? template?.tags,
|
||||
|
||||
@@ -13,21 +13,9 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
mockExecuting: false,
|
||||
mockLgraphNode: null as Record<string, unknown> | null
|
||||
mockExecuting: false
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
getLocatorIdFromNodeData: vi.fn(() => 'test-node-123'),
|
||||
getNodeByLocatorId: vi.fn(
|
||||
() => mockData.mockLgraphNode ?? { isSubgraphNode: () => false }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
return {
|
||||
useTransformState: () => ({
|
||||
@@ -61,6 +49,16 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
getNodeByLocatorId: vi.fn(() => ({
|
||||
isSubgraphNode: () => false
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: vi.fn()
|
||||
@@ -295,52 +293,4 @@ describe('LGraphNode', () => {
|
||||
expect(wrapper.find('[role="button"][aria-label]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDrop', () => {
|
||||
it('should stop propagation when onDragDrop returns true', async () => {
|
||||
const onDragDrop = vi.fn().mockReturnValue(true)
|
||||
mockData.mockLgraphNode = {
|
||||
onDragDrop,
|
||||
onDragOver: vi.fn(),
|
||||
isSubgraphNode: () => false
|
||||
}
|
||||
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
const parentListener = vi.fn()
|
||||
const parent = wrapper.element.parentElement
|
||||
expect(parent).not.toBeNull()
|
||||
parent!.addEventListener('drop', parentListener)
|
||||
|
||||
wrapper.element.dispatchEvent(
|
||||
new Event('drop', { bubbles: true, cancelable: true })
|
||||
)
|
||||
|
||||
expect(onDragDrop).toHaveBeenCalled()
|
||||
expect(parentListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when onDragDrop returns false', async () => {
|
||||
const onDragDrop = vi.fn().mockReturnValue(false)
|
||||
mockData.mockLgraphNode = {
|
||||
onDragDrop,
|
||||
onDragOver: vi.fn(),
|
||||
isSubgraphNode: () => false
|
||||
}
|
||||
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
const parentListener = vi.fn()
|
||||
const parent = wrapper.element.parentElement
|
||||
expect(parent).not.toBeNull()
|
||||
parent!.addEventListener('drop', parentListener)
|
||||
|
||||
wrapper.element.dispatchEvent(
|
||||
new Event('drop', { bubbles: true, cancelable: true })
|
||||
)
|
||||
|
||||
expect(onDragDrop).toHaveBeenCalled()
|
||||
expect(parentListener).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
@contextmenu="handleContextMenu"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
>
|
||||
<!-- Selection/Execution Outline Overlay -->
|
||||
<AppOutput
|
||||
@@ -827,13 +827,15 @@ function handleDragLeave() {
|
||||
isDraggingOver.value = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
async function handleDrop(event: DragEvent) {
|
||||
isDraggingOver.value = false
|
||||
|
||||
const node = lgraphNode.value
|
||||
if (!node?.onDragDrop) return
|
||||
if (!node || !node.onDragDrop) {
|
||||
return
|
||||
}
|
||||
|
||||
const handled = node.onDragDrop(event)
|
||||
if (handled === true) event.stopPropagation()
|
||||
// Forward the drop event to the litegraph node's onDragDrop callback
|
||||
await node.onDragDrop(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
data-testid="node-widgets"
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
|
||||
@@ -25,7 +24,6 @@
|
||||
<template v-for="widget in processedWidgets" :key="widget.renderKey">
|
||||
<div
|
||||
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
|
||||
data-testid="node-widget"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ toastErrorHandler: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => ({
|
||||
useSlotLinkDragUIState: () => ({
|
||||
state: { active: false, compatible: new Map() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeTooltips', () => ({
|
||||
useNodeTooltips: () => ({
|
||||
getOutputSlotTooltip: () => '',
|
||||
createTooltipConfig: (text: string) => ({ value: text })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
|
||||
() => ({ useSlotElementTracking: vi.fn() })
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction',
|
||||
() => ({
|
||||
useSlotLinkInteraction: () => ({ onPointerDown: vi.fn() })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/renderer/core/layout/slots/slotIdentifier', () => ({
|
||||
getSlotKey: () => 'mock-key'
|
||||
}))
|
||||
|
||||
const SlotConnectionDotStub = defineComponent({
|
||||
name: 'SlotConnectionDot',
|
||||
template: '<div class="stub-dot" />'
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function mountOutputSlot(slotData: Partial<INodeSlot>, index = 0) {
|
||||
return mount(OutputSlot, {
|
||||
props: {
|
||||
slotData: { type: '*', ...slotData } as INodeSlot,
|
||||
index,
|
||||
nodeId: 'test-node'
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: { SlotConnectionDot: SlotConnectionDotStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('OutputSlot', () => {
|
||||
it('renders label when present on slotData', () => {
|
||||
const wrapper = mountOutputSlot({
|
||||
name: 'internal_name',
|
||||
localized_name: 'Localized Name',
|
||||
label: 'My Custom Label'
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('My Custom Label')
|
||||
expect(wrapper.text()).not.toContain('internal_name')
|
||||
expect(wrapper.text()).not.toContain('Localized Name')
|
||||
})
|
||||
|
||||
it('falls back to localized_name when label is absent', () => {
|
||||
const wrapper = mountOutputSlot({
|
||||
name: 'internal_name',
|
||||
localized_name: 'Localized Name'
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Localized Name')
|
||||
expect(wrapper.text()).not.toContain('internal_name')
|
||||
})
|
||||
|
||||
it('falls back to name when label and localized_name are absent', () => {
|
||||
const wrapper = mountOutputSlot({ name: 'internal_name' })
|
||||
|
||||
expect(wrapper.text()).toContain('internal_name')
|
||||
})
|
||||
|
||||
it('falls back to "Output N" when all names are absent', () => {
|
||||
const wrapper = mountOutputSlot({ name: undefined }, 2)
|
||||
|
||||
expect(wrapper.text()).toContain('Output 2')
|
||||
})
|
||||
})
|
||||
@@ -41,11 +41,6 @@ const MockFormDropdownInput = {
|
||||
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
|
||||
}
|
||||
|
||||
const MockPopover = {
|
||||
name: 'Popover',
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
|
||||
interface MountDropdownOptions {
|
||||
searcher?: (
|
||||
query: string,
|
||||
@@ -65,13 +60,17 @@ function mountDropdown(
|
||||
plugins: [PrimeVue, i18n],
|
||||
stubs: {
|
||||
FormDropdownInput: MockFormDropdownInput,
|
||||
Popover: MockPopover,
|
||||
FormDropdownMenu: MockFormDropdownMenu
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openDropdown(wrapper: ReturnType<typeof mountDropdown>) {
|
||||
await wrapper.find('.mock-dropdown-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function getMenuItems(
|
||||
wrapper: ReturnType<typeof mountDropdown>
|
||||
): FormDropdownItem[] {
|
||||
@@ -87,7 +86,7 @@ describe('FormDropdown', () => {
|
||||
createItem('input-0', 'video1.mp4'),
|
||||
createItem('input-1', 'video2.mp4')
|
||||
])
|
||||
await flushPromises()
|
||||
await openDropdown(wrapper)
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(2)
|
||||
|
||||
@@ -106,7 +105,7 @@ describe('FormDropdown', () => {
|
||||
|
||||
it('updates when items change but IDs stay the same', async () => {
|
||||
const wrapper = mountDropdown([createItem('1', 'alpha')])
|
||||
await flushPromises()
|
||||
await openDropdown(wrapper)
|
||||
|
||||
await wrapper.setProps({ items: [createItem('1', 'beta')] })
|
||||
await flushPromises()
|
||||
@@ -116,7 +115,7 @@ describe('FormDropdown', () => {
|
||||
|
||||
it('updates when switching between empty and non-empty items', async () => {
|
||||
const wrapper = mountDropdown([])
|
||||
await flushPromises()
|
||||
await openDropdown(wrapper)
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(0)
|
||||
|
||||
@@ -154,7 +153,10 @@ describe('FormDropdown', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(searcher).not.toHaveBeenCalled()
|
||||
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['3', '4'])
|
||||
|
||||
await openDropdown(wrapper)
|
||||
|
||||
expect(searcher).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs filtering when dropdown opens', async () => {
|
||||
@@ -169,8 +171,7 @@ describe('FormDropdown', () => {
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.mock-dropdown-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
await openDropdown(wrapper)
|
||||
|
||||
expect(searcher).toHaveBeenCalled()
|
||||
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['keep'])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync, refDebounced } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { computedAsync, onClickOutside, refDebounced } from '@vueuse/core'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, inject, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -51,7 +52,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -95,8 +95,11 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
|
||||
const isOpen = defineModel<boolean>('isOpen', { default: false })
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const triggerRef = useTemplateRef('triggerRef')
|
||||
const dropdownRef = useTemplateRef('dropdownRef')
|
||||
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
const shouldTeleport = computed(() => injectedAppendTo === 'body')
|
||||
|
||||
const maxSelectable = computed(() => {
|
||||
if (multiple === true) return Infinity
|
||||
@@ -142,18 +145,57 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
|
||||
return isSelected(selected.value, item, index)
|
||||
}
|
||||
|
||||
const toggleDropdown = (event: Event) => {
|
||||
const MENU_HEIGHT = 640 + 8
|
||||
const openUpward = ref(false)
|
||||
const fixedPosition = ref({ top: 0, left: 0 })
|
||||
|
||||
const teleportStyle = computed<CSSProperties | undefined>(() => {
|
||||
if (!shouldTeleport.value) return undefined
|
||||
const pos = fixedPosition.value
|
||||
return openUpward.value
|
||||
? {
|
||||
position: 'fixed',
|
||||
left: `${pos.left}px`,
|
||||
bottom: `${window.innerHeight - pos.top}px`,
|
||||
paddingBottom: '0.5rem'
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
left: `${pos.left}px`,
|
||||
top: `${pos.top}px`,
|
||||
paddingTop: '0.5rem'
|
||||
}
|
||||
})
|
||||
|
||||
function toggleDropdown() {
|
||||
if (disabled) return
|
||||
if (popoverRef.value && triggerRef.value) {
|
||||
popoverRef.value.toggle?.(event, triggerRef.value)
|
||||
isOpen.value = !isOpen.value
|
||||
if (!isOpen.value && triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
openUpward.value = spaceBelow < MENU_HEIGHT && spaceAbove > spaceBelow
|
||||
|
||||
if (shouldTeleport.value) {
|
||||
const MENU_WIDTH = 412
|
||||
fixedPosition.value = {
|
||||
top: openUpward.value ? rect.top : rect.bottom,
|
||||
left: Math.min(rect.right, window.innerWidth - MENU_WIDTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (popoverRef.value) {
|
||||
popoverRef.value.hide?.()
|
||||
isOpen.value = false
|
||||
function closeDropdown() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
onClickOutside(triggerRef, closeDropdown, { ignore: [dropdownRef] })
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +234,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="triggerRef">
|
||||
<div ref="triggerRef" class="relative" @keydown="handleEscape">
|
||||
<FormDropdownInput
|
||||
:files
|
||||
:is-open
|
||||
@@ -207,42 +249,41 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
@select-click="toggleDropdown"
|
||||
@file-change="handleFileChange"
|
||||
/>
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
},
|
||||
content: {
|
||||
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||
}
|
||||
}"
|
||||
@hide="isOpen = false"
|
||||
>
|
||||
<FormDropdownMenu
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
v-model:sort-selected="sortSelected"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
:disabled
|
||||
:items="sortedItems"
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable
|
||||
@close="closeDropdown"
|
||||
@item-click="handleSelection"
|
||||
/>
|
||||
</Popover>
|
||||
<Teleport to="body" :disabled="!shouldTeleport">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 rounded-lg border-none bg-transparent p-0 shadow-lg',
|
||||
!shouldTeleport && 'absolute left-0',
|
||||
!shouldTeleport &&
|
||||
(openUpward ? 'bottom-full pb-2' : 'top-full pt-2')
|
||||
)
|
||||
"
|
||||
:style="teleportStyle"
|
||||
>
|
||||
<FormDropdownMenu
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
v-model:sort-selected="sortSelected"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
:disabled
|
||||
:items="sortedItems"
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable
|
||||
@close="closeDropdown"
|
||||
@item-click="handleSelection"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
|
||||
"
|
||||
>
|
||||
<button
|
||||
data-testid="form-dropdown-trigger"
|
||||
:class="
|
||||
cn(
|
||||
theButtonStyle,
|
||||
|
||||
@@ -97,6 +97,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="form-dropdown-menu"
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
|
||||
@@ -75,49 +75,6 @@ import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
import { useExtensionService } from './extensionService'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
async function reencodeAsPngBlob(
|
||||
blob: Blob,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<Blob> {
|
||||
const canvas = $el('canvas', { width, height }) as HTMLCanvasElement
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
let image: ImageBitmap | HTMLImageElement
|
||||
if (typeof window.createImageBitmap === 'undefined') {
|
||||
const img = new Image()
|
||||
const loaded = new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error('Image load failed'))
|
||||
})
|
||||
img.src = URL.createObjectURL(blob)
|
||||
try {
|
||||
await loaded
|
||||
} finally {
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
image = img
|
||||
} else {
|
||||
image = await createImageBitmap(blob)
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.drawImage(image, 0, 0)
|
||||
} finally {
|
||||
if ('close' in image && typeof image.close === 'function') {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) resolve(result)
|
||||
else reject(new Error('PNG conversion failed'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
export interface HasInitialMinSize {
|
||||
_initialMinSize: { width: number; height: number }
|
||||
}
|
||||
@@ -648,24 +605,58 @@ export const useLitegraphService = () => {
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const writeImage = async (blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetch(url)
|
||||
const blob = await data.blob()
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
])
|
||||
await writeImage(blob)
|
||||
} catch (error) {
|
||||
// Chrome seems to only support PNG on write, convert and try again
|
||||
if (blob.type !== 'image/png') {
|
||||
const pngBlob = await reencodeAsPngBlob(
|
||||
blob,
|
||||
img.naturalWidth,
|
||||
img.naturalHeight
|
||||
)
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
])
|
||||
const canvas = $el('canvas', {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
}) as HTMLCanvasElement
|
||||
const ctx = canvas.getContext('2d')
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let image
|
||||
if (typeof window.createImageBitmap === 'undefined') {
|
||||
image = new Image()
|
||||
const p = new Promise((resolve, reject) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
image.onload = resolve
|
||||
// @ts-expect-error fixme ts strict error
|
||||
image.onerror = reject
|
||||
}).finally(() => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
URL.revokeObjectURL(image.src)
|
||||
})
|
||||
image.src = URL.createObjectURL(blob)
|
||||
await p
|
||||
} else {
|
||||
image = await createImageBitmap(blob)
|
||||
}
|
||||
try {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
ctx.drawImage(image, 0, 0)
|
||||
canvas.toBlob(writeImage, 'image/png')
|
||||
} finally {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
if (typeof image.close === 'function') {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -17,27 +16,6 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
clientId: null,
|
||||
apiURL: vi.fn((path: string) => path)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokePreviewsByExecutionId: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/jobPreviewStore', () => ({
|
||||
useJobPreviewStore: () => ({
|
||||
clearPreview: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock the workflowStore
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
|
||||
@@ -353,12 +331,9 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
|
||||
handler(
|
||||
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
|
||||
)
|
||||
// Flush the RAF so the batched update is applied immediately
|
||||
vi.advanceTimersByTime(16)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -366,10 +341,6 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should retain entries below the limit', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
|
||||
@@ -724,309 +695,3 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
|
||||
expect(store.missingNodesError?.nodeTypes).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - RAF batching', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function getRegisteredHandler(eventName: string) {
|
||||
const calls = vi.mocked(api.addEventListener).mock.calls
|
||||
const call = calls.find(([name]) => name === eventName)
|
||||
return call?.[1] as (e: CustomEvent) => void
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('handleProgress', () => {
|
||||
function makeProgressEvent(value: number, max: number): CustomEvent {
|
||||
return new CustomEvent('progress', {
|
||||
detail: { value, max, prompt_id: 'job-1', node: '1' }
|
||||
})
|
||||
}
|
||||
|
||||
it('batches multiple progress events into one reactive update per frame', () => {
|
||||
const handler = getRegisteredHandler('progress')
|
||||
|
||||
handler(makeProgressEvent(1, 10))
|
||||
handler(makeProgressEvent(5, 10))
|
||||
handler(makeProgressEvent(9, 10))
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toEqual({
|
||||
value: 9,
|
||||
max: 10,
|
||||
prompt_id: 'job-1',
|
||||
node: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not update reactive state before RAF fires', () => {
|
||||
const handler = getRegisteredHandler('progress')
|
||||
|
||||
handler(makeProgressEvent(3, 10))
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('allows a new batch after the previous RAF fires', () => {
|
||||
const handler = getRegisteredHandler('progress')
|
||||
|
||||
handler(makeProgressEvent(1, 10))
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
|
||||
handler(makeProgressEvent(7, 10))
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toEqual(
|
||||
expect.objectContaining({ value: 7 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleProgressState', () => {
|
||||
function makeProgressStateEvent(
|
||||
nodeId: string,
|
||||
state: string,
|
||||
value = 0,
|
||||
max = 10
|
||||
): CustomEvent {
|
||||
return new CustomEvent('progress_state', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
nodes: {
|
||||
[nodeId]: {
|
||||
value,
|
||||
max,
|
||||
state,
|
||||
node_id: nodeId,
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('batches multiple progress_state events into one reactive update per frame', () => {
|
||||
const handler = getRegisteredHandler('progress_state')
|
||||
|
||||
handler(makeProgressStateEvent('1', 'running', 1))
|
||||
handler(makeProgressStateEvent('1', 'running', 5))
|
||||
handler(makeProgressStateEvent('1', 'running', 9))
|
||||
|
||||
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store.nodeProgressStates['1']).toEqual(
|
||||
expect.objectContaining({ value: 9, state: 'running' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not update reactive state before RAF fires', () => {
|
||||
const handler = getRegisteredHandler('progress_state')
|
||||
|
||||
handler(makeProgressStateEvent('1', 'running'))
|
||||
|
||||
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending RAF is discarded when execution completes', () => {
|
||||
it('discards pending progress RAF on execution_success', () => {
|
||||
const progressHandler = getRegisteredHandler('progress')
|
||||
const startHandler = getRegisteredHandler('execution_start')
|
||||
const successHandler = getRegisteredHandler('execution_success')
|
||||
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0 }
|
||||
})
|
||||
)
|
||||
|
||||
progressHandler(
|
||||
new CustomEvent('progress', {
|
||||
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
|
||||
})
|
||||
)
|
||||
|
||||
successHandler(
|
||||
new CustomEvent('execution_success', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0 }
|
||||
})
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('discards pending progress_state RAF on execution_success', () => {
|
||||
const progressStateHandler = getRegisteredHandler('progress_state')
|
||||
const startHandler = getRegisteredHandler('execution_start')
|
||||
const successHandler = getRegisteredHandler('execution_success')
|
||||
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0 }
|
||||
})
|
||||
)
|
||||
|
||||
progressStateHandler(
|
||||
new CustomEvent('progress_state', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
nodes: {
|
||||
'1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: '1'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
successHandler(
|
||||
new CustomEvent('execution_success', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0 }
|
||||
})
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discards pending progress RAF on execution_error', () => {
|
||||
const progressHandler = getRegisteredHandler('progress')
|
||||
const startHandler = getRegisteredHandler('execution_start')
|
||||
const errorHandler = getRegisteredHandler('execution_error')
|
||||
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0 }
|
||||
})
|
||||
)
|
||||
|
||||
progressHandler(
|
||||
new CustomEvent('progress', {
|
||||
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
|
||||
})
|
||||
)
|
||||
|
||||
errorHandler(
|
||||
new CustomEvent('execution_error', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
exception_message: 'error',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('discards pending progress RAF on execution_interrupted', () => {
|
||||
const progressHandler = getRegisteredHandler('progress')
|
||||
const startHandler = getRegisteredHandler('execution_start')
|
||||
const interruptedHandler = getRegisteredHandler('execution_interrupted')
|
||||
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0 }
|
||||
})
|
||||
)
|
||||
|
||||
progressHandler(
|
||||
new CustomEvent('progress', {
|
||||
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
|
||||
})
|
||||
)
|
||||
|
||||
interruptedHandler(
|
||||
new CustomEvent('execution_interrupted', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
executed: []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unbindExecutionEvents cancels pending RAFs', () => {
|
||||
it('cancels pending progress RAF on unbind', () => {
|
||||
const handler = getRegisteredHandler('progress')
|
||||
|
||||
handler(
|
||||
new CustomEvent('progress', {
|
||||
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
|
||||
})
|
||||
)
|
||||
|
||||
store.unbindExecutionEvents()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels pending progress_state RAF on unbind', () => {
|
||||
const handler = getRegisteredHandler('progress_state')
|
||||
|
||||
handler(
|
||||
new CustomEvent('progress_state', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
nodes: {
|
||||
'1': {
|
||||
value: 0,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: '1'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
store.unbindExecutionEvents()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { createRafCoalescer } from '@/utils/rafBatch'
|
||||
|
||||
interface QueuedJob {
|
||||
/**
|
||||
@@ -243,8 +242,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.removeEventListener('status', handleStatus)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
api.removeEventListener('progress_text', handleProgressText)
|
||||
|
||||
cancelPendingProgressUpdates()
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
@@ -293,10 +290,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>): void {
|
||||
// Cancel any pending progress RAF before clearing state to prevent
|
||||
// stale data from being written back on the next frame.
|
||||
progressCoalescer.cancel()
|
||||
|
||||
// Clear the current node progress when a new node starts executing
|
||||
_executingNodeProgress.value = null
|
||||
|
||||
@@ -339,15 +332,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeProgressStatesByJob.value = pruned
|
||||
}
|
||||
|
||||
const progressStateCoalescer =
|
||||
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
progressStateCoalescer.push(e.detail)
|
||||
}
|
||||
|
||||
function _applyProgressState(detail: ProgressStateWsMessage) {
|
||||
const { nodes, prompt_id: jobId } = detail
|
||||
const { nodes, prompt_id: jobId } = e.detail
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
|
||||
@@ -383,17 +369,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
|
||||
_executingNodeProgress.value = detail
|
||||
})
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
progressCoalescer.push(e.detail)
|
||||
}
|
||||
|
||||
function cancelPendingProgressUpdates() {
|
||||
progressCoalescer.cancel()
|
||||
progressStateCoalescer.cancel()
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
|
||||
function handleStatus() {
|
||||
@@ -515,8 +492,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState(jobIdParam?: string | null) {
|
||||
cancelPendingProgressUpdates()
|
||||
|
||||
executionIdToLocatorCache.clear()
|
||||
nodeProgressStates.value = {}
|
||||
const jobId = jobIdParam ?? activeJobId.value ?? null
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { extractFilesFromDragEvent } from '@/utils/eventUtils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('eventUtils', () => {
|
||||
describe('extractFilesFromDragEvent', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
it('should return empty array when no dataTransfer', async () => {
|
||||
const actual = await extractFilesFromDragEvent(new FakeDragEvent('drop'))
|
||||
expect(actual).toEqual([])
|
||||
@@ -108,56 +98,19 @@ describe('eventUtils', () => {
|
||||
expect(actual).toEqual([file1, file2])
|
||||
})
|
||||
|
||||
it('should fetch URI and return as File when text/uri-list is present', async () => {
|
||||
const uri = 'https://example.com/api/view?filename=test.png&type=input'
|
||||
const imageBlob = new Blob([new Uint8Array([0x89, 0x50])], {
|
||||
type: 'image/png'
|
||||
})
|
||||
fetchSpy.mockResolvedValue(new Response(imageBlob))
|
||||
// Skip until we can setup MSW
|
||||
it.skip('should handle drops with URLs', async () => {
|
||||
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/uri-list', uri)
|
||||
dataTransfer.setData('text/uri-list', urlWithWorkflow)
|
||||
dataTransfer.setData('text/x-moz-url', urlWithWorkflow)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
expect(actual).toHaveLength(1)
|
||||
expect(actual.length).toBe(1)
|
||||
expect(actual[0]).toBeInstanceOf(File)
|
||||
expect(actual[0].type).toBe('image/png')
|
||||
})
|
||||
|
||||
it('should handle text/x-moz-url type', async () => {
|
||||
const uri = 'https://example.com/api/view?filename=test.png&type=input'
|
||||
const imageBlob = new Blob([new Uint8Array([0x89, 0x50])], {
|
||||
type: 'image/png'
|
||||
})
|
||||
fetchSpy.mockResolvedValue(new Response(imageBlob))
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/x-moz-url', uri)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
expect(actual).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should return empty array when URI fetch fails', async () => {
|
||||
const uri = 'https://example.com/api/view?filename=test.png&type=input'
|
||||
fetchSpy.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/uri-list', uri)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
|
||||
expect(actual).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,13 +20,9 @@ export async function extractFilesFromDragEvent(
|
||||
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
||||
if (!uri) return []
|
||||
|
||||
try {
|
||||
const response = await fetch(uri)
|
||||
const blob = await response.blob()
|
||||
return [new File([blob], uri, { type: blob.type })]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const response = await fetch(uri)
|
||||
const blob = await response.blob()
|
||||
return [new File([blob], uri, { type: blob.type })]
|
||||
}
|
||||
|
||||
export function hasImageType({ type }: File): boolean {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createRafCoalescer } from '@/utils/rafBatch'
|
||||
|
||||
describe('createRafCoalescer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('applies the latest pushed value on the next frame', () => {
|
||||
const apply = vi.fn()
|
||||
const coalescer = createRafCoalescer<number>(apply)
|
||||
|
||||
coalescer.push(1)
|
||||
coalescer.push(2)
|
||||
coalescer.push(3)
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(apply).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('does not apply after cancel', () => {
|
||||
const apply = vi.fn()
|
||||
const coalescer = createRafCoalescer<number>(apply)
|
||||
|
||||
coalescer.push(42)
|
||||
coalescer.cancel()
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies immediately on flush', () => {
|
||||
const apply = vi.fn()
|
||||
const coalescer = createRafCoalescer<number>(apply)
|
||||
|
||||
coalescer.push(99)
|
||||
coalescer.flush()
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(apply).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('does nothing on flush when no value is pending', () => {
|
||||
const apply = vi.fn()
|
||||
const coalescer = createRafCoalescer<number>(apply)
|
||||
|
||||
coalescer.flush()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not double-apply after flush', () => {
|
||||
const apply = vi.fn()
|
||||
const coalescer = createRafCoalescer<number>(apply)
|
||||
|
||||
coalescer.push(1)
|
||||
coalescer.flush()
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports scheduled state correctly', () => {
|
||||
const coalescer = createRafCoalescer<number>(vi.fn())
|
||||
|
||||
expect(coalescer.isScheduled()).toBe(false)
|
||||
|
||||
coalescer.push(1)
|
||||
expect(coalescer.isScheduled()).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
expect(coalescer.isScheduled()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -27,40 +27,3 @@ export function createRafBatch(run: () => void) {
|
||||
|
||||
return { schedule, cancel, flush, isScheduled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-write-wins RAF coalescer. Buffers the latest value and applies it
|
||||
* on the next animation frame, coalescing multiple pushes into a single
|
||||
* reactive update.
|
||||
*/
|
||||
export function createRafCoalescer<T>(apply: (value: T) => void) {
|
||||
let hasPending = false
|
||||
let pendingValue: T | undefined
|
||||
|
||||
const batch = createRafBatch(() => {
|
||||
if (!hasPending) return
|
||||
const value = pendingValue as T
|
||||
hasPending = false
|
||||
pendingValue = undefined
|
||||
apply(value)
|
||||
})
|
||||
|
||||
const push = (value: T) => {
|
||||
pendingValue = value
|
||||
hasPending = true
|
||||
batch.schedule()
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
hasPending = false
|
||||
pendingValue = undefined
|
||||
batch.cancel()
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (!hasPending) return
|
||||
batch.flush()
|
||||
}
|
||||
|
||||
return { push, cancel, flush, isScheduled: batch.isScheduled }
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('ComfyApp.getNodeDefs', () => {
|
||||
|
||||
const result = await comfyApp.getNodeDefs()
|
||||
|
||||
// When display_name is empty, should fall back to name
|
||||
expect(result.TestNode.display_name).toBe('TestNode')
|
||||
})
|
||||
|
||||
@@ -83,6 +84,7 @@ describe('ComfyApp.getNodeDefs', () => {
|
||||
}
|
||||
|
||||
vi.mocked(api.getNodeDefs).mockResolvedValue(mockNodeDefs)
|
||||
// Mock st to return a translation instead of fallback
|
||||
vi.mocked(st).mockReturnValue('Translated Display Name')
|
||||
|
||||
const result = await comfyApp.getNodeDefs()
|
||||
Reference in New Issue
Block a user