mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
43 Commits
v1.42.7
...
fix-flaky-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6de37b0c62 | ||
|
|
2af3940867 | ||
|
|
48ae70159f | ||
|
|
0030cadba3 | ||
|
|
60e8da308f | ||
|
|
1ee75332b3 | ||
|
|
e710ad5b8e | ||
|
|
e85fc6390a | ||
|
|
4a8f68a6bd | ||
|
|
c8a03b8cf1 | ||
|
|
a75444d56a | ||
|
|
1934064839 | ||
|
|
54fe02bdf1 | ||
|
|
912283a8e2 | ||
|
|
7823cfc83e | ||
|
|
25e1b0e708 | ||
|
|
cdf74c36f7 | ||
|
|
b0d7f38caa | ||
|
|
d6c1dd2e59 | ||
|
|
1dcd7bca43 | ||
|
|
918095f197 | ||
|
|
14274fb8fa | ||
|
|
95eb51633f | ||
|
|
46d8567f10 | ||
|
|
b22c646910 | ||
|
|
bc30062bcb | ||
|
|
3712cb01f3 | ||
|
|
469d6291a6 | ||
|
|
a321d66583 | ||
|
|
4c2e64b5fe | ||
|
|
f0b91bdcfa | ||
|
|
46dad2e077 | ||
|
|
f68d8365a6 | ||
|
|
b3ebf1418a | ||
|
|
fb756b41c8 | ||
|
|
45b3e0ec64 | ||
|
|
ed5e0a0b51 | ||
|
|
a41e8b8a19 | ||
|
|
f9102c7c44 | ||
|
|
bf23384de0 | ||
|
|
eaf1f9ac77 | ||
|
|
b92f7f19d0 | ||
|
|
144cb0f821 |
82
.claude/skills/layer-audit/SKILL.md
Normal file
82
.claude/skills/layer-audit/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: layer-audit
|
||||
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
|
||||
---
|
||||
|
||||
# Layer Architecture Audit
|
||||
|
||||
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
|
||||
|
||||
## Layer Hierarchy (bottom to top)
|
||||
|
||||
```
|
||||
renderer (top -- can import from all lower layers)
|
||||
^
|
||||
workbench
|
||||
^
|
||||
platform
|
||||
^
|
||||
base (bottom -- cannot import from any upper layer)
|
||||
```
|
||||
|
||||
Each layer may only import from layers below it.
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Run ESLint filtering for just the layer boundary rule violations
|
||||
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
|
||||
```
|
||||
|
||||
To get a full structured report, run:
|
||||
|
||||
```bash
|
||||
# Collect all violations from base/, platform/, workbench/ layers
|
||||
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
Each violation line shows:
|
||||
|
||||
- The **file** containing the bad import
|
||||
- The **import path** crossing the boundary
|
||||
- The **message** identifying which layer pair is violated
|
||||
|
||||
### Grouping by Layer Pair
|
||||
|
||||
After collecting violations, group them by the layer pair pattern:
|
||||
|
||||
| Layer pair | Meaning |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| base -> platform | base/ importing from platform/ |
|
||||
| base -> workbench | base/ importing from workbench/ |
|
||||
| base -> renderer | base/ importing from renderer/ |
|
||||
| platform -> workbench | platform/ importing from workbench/ |
|
||||
| platform -> renderer | platform/ importing from renderer/ |
|
||||
| workbench -> renderer | workbench/ importing from renderer/ |
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
|
||||
- When auditing the codebase to find and plan migration of existing violations
|
||||
- After moving files between layers to verify no new violations were introduced
|
||||
|
||||
## Fixing Violations
|
||||
|
||||
Common strategies to resolve a layer violation:
|
||||
|
||||
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
|
||||
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
|
||||
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
|
||||
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ------------------------------- | ------------------ |
|
||||
| ESLint config (rule definition) | `eslint.config.ts` |
|
||||
| Base layer | `src/base/` |
|
||||
| Platform layer | `src/platform/` |
|
||||
| Workbench layer | `src/workbench/` |
|
||||
| Renderer layer | `src/renderer/` |
|
||||
@@ -44,15 +44,18 @@ await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
|
||||
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
|
||||
|
||||
## Test Tags
|
||||
|
||||
|
||||
@@ -45,3 +45,4 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
|
||||
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -12,7 +12,7 @@ runs:
|
||||
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
1
.github/workflows/ci-perf-report.yaml
vendored
1
.github/workflows/ci-perf-report.yaml
vendored
@@ -64,6 +64,7 @@ jobs:
|
||||
mkdir -p temp/perf-meta
|
||||
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
|
||||
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
|
||||
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
7
.github/workflows/ci-size-data.yaml
vendored
7
.github/workflows/ci-size-data.yaml
vendored
@@ -8,6 +8,10 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: size-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -28,11 +32,12 @@ jobs:
|
||||
- name: Collect size data
|
||||
run: node scripts/size-collect.js
|
||||
|
||||
- name: Save PR number & base branch
|
||||
- name: Save PR metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
echo ${{ github.event.number }} > ./temp/size/number.txt
|
||||
echo ${{ github.base_ref }} > ./temp/size/base.txt
|
||||
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
|
||||
|
||||
- name: Upload size data
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -144,7 +144,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/pr-backport.yaml
vendored
2
.github/workflows/pr-backport.yaml
vendored
@@ -348,6 +348,8 @@ jobs:
|
||||
PR_NUM=$(echo "${PR_URL}" | grep -o '[0-9]*$')
|
||||
|
||||
if [ -n "${PR_NUM}" ]; then
|
||||
gh pr merge "${PR_NUM}" --auto --squash --repo "${{ github.repository }}" \
|
||||
|| echo "::warning::Failed to enable auto-merge for PR #${PR_NUM}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
|
||||
fi
|
||||
else
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
148
.github/workflows/pr-perf-report.yaml
vendored
148
.github/workflows/pr-perf-report.yaml
vendored
@@ -1,148 +0,0 @@
|
||||
name: 'PR: Performance Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Performance Report']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download PR metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-meta
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/perf-meta/
|
||||
|
||||
- name: Resolve and validate PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const artifactPr = Number(fs.readFileSync('temp/perf-meta/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/perf-meta/base.txt', 'utf8').trim();
|
||||
|
||||
// Resolve PR from trusted workflow context
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setFailed('Unable to resolve PR from workflow_run context.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(pr.number) !== artifactPr) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedBase = pr.base?.ref;
|
||||
if (!trustedBase || artifactBase !== trustedBase) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Check if results are still current
|
||||
id: sha-check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const runSha = context.payload.workflow_run.head_sha;
|
||||
const currentSha = pr.head.sha;
|
||||
if (runSha !== currentSha) {
|
||||
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
|
||||
core.setOutput('stale', 'true');
|
||||
} else {
|
||||
core.setOutput('stale', 'false');
|
||||
}
|
||||
|
||||
- name: Download PR perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: test-results/
|
||||
|
||||
- name: Download baseline perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Load historical baselines from perf-data branch
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p temp/perf-history
|
||||
|
||||
git fetch origin perf-data 2>/dev/null || {
|
||||
echo "perf-data branch not found, skipping historical data"
|
||||
exit 0
|
||||
}
|
||||
|
||||
INDEX=0
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
|
||||
DIR="temp/perf-history/$INDEX"
|
||||
mkdir -p "$DIR"
|
||||
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
|
||||
INDEX=$((INDEX + 1))
|
||||
done
|
||||
|
||||
echo "Loaded $INDEX historical baselines"
|
||||
|
||||
- name: Generate perf report
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./perf-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PERF -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
233
.github/workflows/pr-report.yaml
vendored
Normal file
233
.github/workflows/pr-report.yaml
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
name: 'PR: Unified Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: pr-report-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the workflow_run head SHA matches the current PR head
|
||||
const { data: livePr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
|
||||
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
|
||||
- name: Find size workflow run for this commit
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-size
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci-size-data.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
|
||||
- name: Find perf workflow run for this commit
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-perf
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci-perf-report.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
|
||||
- name: Download size data (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: size-data
|
||||
run_id: ${{ steps.find-size.outputs.run-id }}
|
||||
path: temp/size
|
||||
|
||||
- name: Download size baseline
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-size-data.yaml
|
||||
event: push
|
||||
name: size-data
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf metrics (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
run_id: ${{ steps.find-perf.outputs.run-id }}
|
||||
path: test-results/
|
||||
|
||||
- name: Download perf baseline
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf history from perf-data branch
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
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 -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"
|
||||
fi
|
||||
|
||||
- name: Generate unified report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: >
|
||||
node scripts/unified-report.js
|
||||
--size-status=${{ steps.find-size.outputs.status }}
|
||||
--perf-status=${{ steps.find-perf.outputs.status }}
|
||||
> pr-report.md
|
||||
|
||||
- name: Remove legacy separate comments
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const comment of comments) {
|
||||
if (legacyMarkers.some(m => comment.body?.includes(m))) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
core.info(`Deleted legacy comment ${comment.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./pr-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
24
.github/workflows/pr-request-team-review.yaml
vendored
Normal file
24
.github/workflows/pr-request-team-review.yaml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Request team review for PRs from external contributors.
|
||||
name: PR:Request Team Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
request-review:
|
||||
if: >-
|
||||
!contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'),
|
||||
github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Request team review
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--add-reviewer Comfy-org/comfy_frontend_devs
|
||||
133
.github/workflows/pr-size-report.yaml
vendored
133
.github/workflows/pr-size-report.yaml
vendored
@@ -1,133 +0,0 @@
|
||||
name: 'PR: Size Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data']
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to report on'
|
||||
required: true
|
||||
type: number
|
||||
run_id:
|
||||
description: 'Size data workflow run ID'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success') ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download size data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: size-data
|
||||
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
|
||||
path: temp/size
|
||||
|
||||
- name: Resolve and validate PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// workflow_dispatch: validate artifact metadata against API-resolved PR
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const pullNumber = Number('${{ inputs.pr_number }}');
|
||||
const { data: dispatchPr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
|
||||
|
||||
if (artifactPr !== dispatchPr.number) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match dispatch PR (${dispatchPr.number}).`);
|
||||
return;
|
||||
}
|
||||
if (artifactBase !== dispatchPr.base.ref) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match dispatch PR base (${dispatchPr.base.ref}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(dispatchPr.number));
|
||||
core.setOutput('base', dispatchPr.base.ref);
|
||||
return;
|
||||
}
|
||||
|
||||
// workflow_run: validate artifact metadata against trusted context
|
||||
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
|
||||
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setFailed('Unable to resolve PR from workflow_run context.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(pr.number) !== artifactPr) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedBase = pr.base?.ref;
|
||||
if (!trustedBase || artifactBase !== trustedBase) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Download previous size data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-size-data.yaml
|
||||
event: push
|
||||
name: size-data
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Generate size report
|
||||
run: node scripts/size-report.js > size-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./size-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_SIZE -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
142
.github/workflows/release-biweekly-comfyui.yaml
vendored
142
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
path: comfyui
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -162,9 +162,132 @@ jobs:
|
||||
echo "- Target version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
publish-pypi:
|
||||
needs: [resolve-version, trigger-release-if-needed]
|
||||
if: >
|
||||
always() &&
|
||||
needs.resolve-version.result == 'success' &&
|
||||
(needs.trigger-release-if-needed.result == 'success' ||
|
||||
needs.trigger-release-if-needed.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for release PR to be created and merged
|
||||
if: needs.trigger-release-if-needed.result == 'success'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}"
|
||||
echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..."
|
||||
|
||||
# Poll for up to 30 minutes (a human or automation needs to merge the version bump PR)
|
||||
for i in $(seq 1 60); do
|
||||
# Check if the tag exists (release-draft-create creates a tag on merge)
|
||||
if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then
|
||||
echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "❌ Timed out waiting for tag v${TARGET_VERSION}"
|
||||
exit 1
|
||||
|
||||
- name: Checkout code at target version
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: v${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
ENABLE_MINIFY: 'true'
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
|
||||
- name: Build and publish PyPI package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
- name: Wait for PyPI propagation
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
PACKAGE="comfyui-frontend-package"
|
||||
echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..."
|
||||
|
||||
# Wait up to 15 minutes (polling every 30 seconds)
|
||||
for i in $(seq 1 30); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI"
|
||||
exit 1
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## PyPI Publishing" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create-comfyui-pr:
|
||||
needs: [check-release-week, resolve-version, trigger-release-if-needed]
|
||||
if: always() && needs.resolve-version.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
|
||||
needs:
|
||||
[
|
||||
check-release-week,
|
||||
resolve-version,
|
||||
trigger-release-if-needed,
|
||||
publish-pypi
|
||||
]
|
||||
if: always() && needs.resolve-version.result == 'success' && needs.publish-pypi.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -236,11 +359,8 @@ jobs:
|
||||
EOF
|
||||
)
|
||||
|
||||
# Add release PR note if release was triggered
|
||||
if [ "${{ needs.resolve-version.outputs.needs_release }}" = "true" ]; then
|
||||
RELEASE_NOTE="⚠️ **Release PR must be merged first** - check [release workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)"
|
||||
BODY=$''"${RELEASE_NOTE}"$'\n\n'"${BODY}"
|
||||
fi
|
||||
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
|
||||
BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}"
|
||||
|
||||
# Save to file for later use
|
||||
printf '%s\n' "$BODY" > pr-body.txt
|
||||
@@ -307,7 +427,11 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
||||
echo "PR already exists (#${EXISTING_PR}), updating branch will update the PR"
|
||||
echo "PR already exists (#${EXISTING_PR}), refreshing title/body"
|
||||
gh pr edit "$EXISTING_PR" \
|
||||
--repo Comfy-Org/ComfyUI \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
--body-file ../pr-body.txt
|
||||
else
|
||||
echo "Failed to create PR and no existing PR found"
|
||||
exit 1
|
||||
|
||||
34
.github/workflows/release-draft-create.yaml
vendored
34
.github/workflows/release-draft-create.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -99,37 +99,6 @@ jobs:
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
publish_types:
|
||||
needs: build
|
||||
uses: ./.github/workflows/release-npm-types.yaml
|
||||
@@ -142,7 +111,6 @@ jobs:
|
||||
name: Comment Release Summary
|
||||
needs:
|
||||
- draft_release
|
||||
- publish_pypi
|
||||
- publish_types
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
2
.github/workflows/release-npm-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/release-pypi-dev.yaml
vendored
2
.github/workflows/release-pypi-dev.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
2
.github/workflows/release-version-bump.yaml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
56
CODEOWNERS
56
CODEOWNERS
@@ -1,61 +1,55 @@
|
||||
# Global Ownership
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
/src/components/topbar/ @pythongosssss
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -30,6 +30,14 @@ browser_tests/
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| -------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `subtree intercepts pointer events` on DOM widgets | Canvas `z-999` overlay intercepts `click()` | Use Playwright's `locator.dispatchEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })` |
|
||||
| Context menu empty or wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in test asset JSON | Use node size `[400, 200]` minimum |
|
||||
|
||||
## After Making Changes
|
||||
|
||||
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
@@ -184,9 +186,11 @@ export class ComfyPage {
|
||||
public readonly contextMenu: ContextMenu
|
||||
public readonly toast: ToastHelper
|
||||
public readonly dragDrop: DragDropHelper
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly queue: QueueHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -227,9 +231,11 @@ export class ComfyPage {
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
|
||||
73
browser_tests/fixtures/helpers/FeatureFlagHelper.ts
Normal file
73
browser_tests/fixtures/helpers/FeatureFlagHelper.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class FeatureFlagHelper {
|
||||
private featuresRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Seed feature flags via `addInitScript` so they are available in
|
||||
* localStorage before the app JS executes on first load.
|
||||
* Must be called before `comfyPage.setup()` / `page.goto()`.
|
||||
*
|
||||
* Note: Playwright init scripts persist for the page lifetime and
|
||||
* cannot be removed. Call this once per test, before navigation.
|
||||
*/
|
||||
async seedFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.addInitScript((flagMap: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(flagMap)) {
|
||||
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set feature flags at runtime via localStorage. Uses the `ff:` prefix
|
||||
* that devFeatureFlagOverride.ts reads in dev mode.
|
||||
* For flags needed before page init, use `seedFlags()` instead.
|
||||
*/
|
||||
async setFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(flagMap)) {
|
||||
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setFlags({ [name]: value })
|
||||
}
|
||||
|
||||
async clearFlags(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith('ff:')) keysToRemove.push(key)
|
||||
}
|
||||
keysToRemove.forEach((k) => {
|
||||
localStorage.removeItem(k)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
async mockServerFeatures(features: Record<string, unknown>): Promise<void> {
|
||||
this.featuresRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(features)
|
||||
})
|
||||
await this.page.route('**/api/features', this.featuresRouteHandler)
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.featuresRouteHandler) {
|
||||
await this.page.unroute('**/api/features', this.featuresRouteHandler)
|
||||
this.featuresRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
79
browser_tests/fixtures/helpers/QueueHelper.ts
Normal file
79
browser_tests/fixtures/helpers/QueueHelper.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Mock the /api/queue endpoint to return specific queue state.
|
||||
*/
|
||||
async mockQueueState(
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
queue_running: Array.from({ length: running }, (_, i) => [
|
||||
i,
|
||||
`running-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
]),
|
||||
queue_pending: Array.from({ length: pending }, (_, i) => [
|
||||
i,
|
||||
`pending-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
])
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the /api/history endpoint with completed/failed job entries.
|
||||
*/
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
prompt: [0, job.promptId, {}, {}, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: job.status === 'success' ? 'success' : 'error',
|
||||
completed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.historyRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all route mocks set by this helper.
|
||||
*/
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.queueRouteHandler) {
|
||||
await this.page.unroute('**/api/queue', this.queueRouteHandler)
|
||||
this.queueRouteHandler = null
|
||||
}
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export const TestIds = {
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
missingNodes: 'missing-nodes-warning',
|
||||
errorOverlay: 'error-overlay',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
|
||||
102
browser_tests/tests/bottomPanelLogs.spec.ts
Normal file
102
browser_tests/tests/bottomPanelLogs.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Logs tab when terminal panel opens', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between shortcuts and terminal panels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should persist Logs tab content in bottom panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
|
||||
const xtermContainer = bottomPanel.root.locator('.xterm')
|
||||
await expect(xtermContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('should render xterm container in terminal panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
|
||||
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
|
||||
await expect(xtermScreen.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -9,63 +9,114 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when loading a workflow with missing nodes in subgraphs', async ({
|
||||
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Verify the missing node text includes subgraph context
|
||||
const warningText = await missingNodesWarning.textContent()
|
||||
expect(warningText).toContain('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
expect(warningText).toContain('in subgraph')
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
// Expand the pack group row to reveal node type names
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab when clicking See Errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the right side panel errors tab
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify MissingNodeCard is rendered in the errors tab
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
|
||||
// Wait for any async operations to complete after dialog closes
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Make a change to the graph by moving a node
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Make a change to the graph
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
|
||||
// Undo and redo the change
|
||||
// Undo and redo should not resurface the error overlay
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
@@ -86,7 +137,9 @@ test.describe('Execution error', () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the error overlay to be visible
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -110,7 +163,9 @@ test.describe('Missing models in Error Tab', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
@@ -124,7 +179,9 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
@@ -141,7 +198,9 @@ test.describe('Missing models in Error Tab', () => {
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -152,7 +211,9 @@ test.describe('Missing models in Error Tab', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
|
||||
92
browser_tests/tests/errorOverlaySeeErrors.spec.ts
Normal file
92
browser_tests/tests/errorOverlaySeeErrors.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function triggerExecutionError(comfyPage: {
|
||||
canvasOps: { disconnectEdge: () => Promise<void> }
|
||||
page: Page
|
||||
command: { executeCommand: (cmd: string) => Promise<void> }
|
||||
}) {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
}
|
||||
|
||||
test('Error overlay appears on execution error', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error overlay shows error message', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /Dismiss/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
65
browser_tests/tests/focusMode.spec.ts
Normal file
65
browser_tests/tests/focusMode.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Focus mode hides UI chrome', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(true)
|
||||
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(false)
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle focus mode command works', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode hides topbar', async ({ comfyPage }) => {
|
||||
const topMenu = comfyPage.page.locator('.comfy-menu-button-wrapper')
|
||||
await expect(topMenu).toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(true)
|
||||
|
||||
await expect(topMenu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(false)
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,15 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Fit view button is present and clickable', async ({ comfyPage }) => {
|
||||
const fitViewButton = comfyPage.page
|
||||
.locator('button')
|
||||
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--focus\\]') })
|
||||
await expect(fitViewButton).toBeVisible()
|
||||
await fitViewButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Zoom controls popup opens and closes', async ({ comfyPage }) => {
|
||||
// Find the zoom button by its percentage text content
|
||||
const zoomButton = comfyPage.page.locator('button').filter({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '../fixtures/components/SidebarTab'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
@@ -224,7 +225,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
91
browser_tests/tests/jobHistoryActions.spec.ts
Normal file
91
browser_tests/tests/jobHistoryActions.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Job History Actions', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
|
||||
// Expand the queue overlay so the JobHistoryActionsMenu is visible
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
})
|
||||
|
||||
async function openMoreOptionsPopover(comfyPage: {
|
||||
page: { getByLabel(label: string | RegExp): Locator }
|
||||
}) {
|
||||
const moreButton = comfyPage.page.getByLabel(/More options/i).first()
|
||||
await moreButton.click()
|
||||
}
|
||||
|
||||
test('More options popover opens', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="docked-job-history-action"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Docked job history action is visible with text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
const action = comfyPage.page.locator(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await expect(action).toBeVisible()
|
||||
await expect(action).not.toBeEmpty()
|
||||
})
|
||||
|
||||
test('Show run progress bar action is visible', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="show-run-progress-bar-action"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clear history action is visible', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="clear-history-action"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking docked job history closes popover', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
const action = comfyPage.page.locator(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await expect(action).toBeVisible()
|
||||
await action.click()
|
||||
|
||||
await expect(action).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking show run progress bar toggles setting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingBefore = await comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.Queue.ShowRunProgressBar'
|
||||
)
|
||||
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
const action = comfyPage.page.locator(
|
||||
'[data-testid="show-run-progress-bar-action"]'
|
||||
)
|
||||
await action.click()
|
||||
|
||||
const settingAfter = await comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.Queue.ShowRunProgressBar'
|
||||
)
|
||||
expect(settingAfter).toBe(!settingBefore)
|
||||
})
|
||||
})
|
||||
111
browser_tests/tests/linearMode.spec.ts
Normal file
111
browser_tests/tests/linearMode.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function enterAppMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
// LinearControls requires hasOutputs to be true. Serialize the current
|
||||
// graph, inject linearData with output node IDs, then reload so the
|
||||
// appModeStore picks up the outputs via its activeWorkflow watcher.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
// Serialize, inject linearData, and reload to sync stores
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: [], outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Toggle to app mode via the command which sets canvasStore.linearMode
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function enterGraphMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await enterGraphMode(comfyPage)
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(comfyPage.canvas).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -38,7 +39,7 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/only_optional_inputs')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
|
||||
// If the node's multiline text widget is visible, then it was loaded successfully
|
||||
@@ -73,10 +74,6 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
|
||||
})
|
||||
test('unknown converted widget', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.ShowMissingNodesWarning',
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_nodes_converted_widget'
|
||||
)
|
||||
|
||||
110
browser_tests/tests/nodeLibraryEssentials.spec.ts
Normal file
110
browser_tests/tests/nodeLibraryEssentials.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Enable the essentials feature flag via the reactive serverFeatureFlags ref.
|
||||
// In production, this flag comes via WebSocket or remoteConfig (cloud only).
|
||||
// The localhost test server has neither, so we set it directly.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
node_library_essentials_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
// Register a mock essential node so the essentials tab has content.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
return window.app!.registerNodeDef('TestEssentialNode', {
|
||||
name: 'TestEssentialNode',
|
||||
display_name: 'Test Essential Node',
|
||||
category: 'essentials_test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false],
|
||||
output_node: false,
|
||||
python_module: 'comfy_essentials.nodes',
|
||||
description: 'Mock essential node for testing',
|
||||
essentials_category: 'Image Generation'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Node library opens via sidebar', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const sidebarContent = comfyPage.page.locator(
|
||||
'.comfy-vue-side-bar-container'
|
||||
)
|
||||
await expect(sidebarContent).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await expect(essentialsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking essentials tab shows essential node cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essential node cards have node names', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
const nodeName = await firstCard.getAttribute('data-node-name')
|
||||
expect(nodeName).toBeTruthy()
|
||||
expect(nodeName!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
|
||||
|
||||
await essentialsTab.click()
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
|
||||
await allNodesTab.click()
|
||||
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
})
|
||||
143
browser_tests/tests/nodeSearchBoxV2Extended.spec.ts
Normal file
143
browser_tests/tests/nodeSearchBoxV2Extended.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
})
|
||||
|
||||
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape closes search box without adding node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('Search clears when reopening', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.input).toHaveValue('')
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Category navigation updates results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const samplingResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
await searchBoxV2.categoryButton('loaders').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const loaderResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
expect(samplingResults).not.toEqual(loaderResults)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter workflow', () => {
|
||||
test('Filter chip removal restores results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Record initial result text for comparison
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Verify filter chip appeared and results changed
|
||||
const filterChip = searchBoxV2.dialog.locator(
|
||||
'[data-testid="filter-chip"]'
|
||||
)
|
||||
await expect(filterChip).toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const filteredResults = await searchBoxV2.results.allTextContents()
|
||||
expect(filteredResults).not.toEqual(unfilteredResults)
|
||||
|
||||
// Remove filter by clicking the chip delete button
|
||||
await filterChip.getByTestId('chip-delete').click()
|
||||
|
||||
// Filter chip should be removed
|
||||
await expect(filterChip).not.toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowUp on first item keeps first selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
|
||||
// First result should be selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// ArrowUp on first item should keep first selected
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
54
browser_tests/tests/pasteImageContextMenu.spec.ts
Normal file
54
browser_tests/tests/pasteImageContextMenu.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Paste Image context menu option', { tag: ['@node'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('shows Paste Image in LoadImage node context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
|
||||
const nodeEl = comfyPage.page.locator(
|
||||
`[data-node-id="${loadImageNode.id}"]`
|
||||
)
|
||||
await nodeEl.click({ button: 'right' })
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
const menuLabels = await menu
|
||||
.locator('[role="menuitem"] span.flex-1')
|
||||
.allInnerTexts()
|
||||
|
||||
expect(menuLabels).toContain('Paste Image')
|
||||
})
|
||||
|
||||
test('does not show Paste Image on output-only image nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_save_image_node')
|
||||
|
||||
const saveImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('SaveImage')
|
||||
)[0]
|
||||
|
||||
const nodeEl = comfyPage.page.locator(
|
||||
`[data-node-id="${saveImageNode.id}"]`
|
||||
)
|
||||
await nodeEl.click({ button: 'right' })
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
const menuLabels = await menu
|
||||
.locator('[role="menuitem"] span.flex-1')
|
||||
.allInnerTexts()
|
||||
|
||||
expect(menuLabels).not.toContain('Paste Image')
|
||||
expect(menuLabels).not.toContain('Open Image')
|
||||
})
|
||||
})
|
||||
@@ -222,6 +222,84 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('vue renderer large graph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('idle', async ({ comfyPage }) => {
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('vue-large-graph-idle')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Vue large graph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.domNodes} DOM nodes`
|
||||
)
|
||||
})
|
||||
|
||||
test('pan', async ({ comfyPage }) => {
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
const centerX = box.x + box.width / 2
|
||||
const centerY = box.y + box.height / 2
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await comfyPage.page.mouse.move(centerX + i * 5, centerY + i * 2)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('vue-large-graph-pan')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Vue large graph pan: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
test('zoom out culling', async ({ comfyPage }) => {
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Zoom out far enough that nodes become < 4px screen size
|
||||
// (triggers size-based culling in isNodeInViewport)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await comfyPage.canvasOps.zoom(100)
|
||||
}
|
||||
|
||||
// Verify we actually entered the culling regime.
|
||||
// isNodeTooSmall triggers when max(width, height) * scale < 4px.
|
||||
// Typical nodes are ~200px wide, so scale must be < 0.02.
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
expect(scale).toBeLessThan(0.02)
|
||||
|
||||
// Idle at extreme zoom-out — most nodes should be culled
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Zoom back in
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await comfyPage.canvasOps.zoom(-100)
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('vue-zoom-culling')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Vue zoom culling: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.frameDurationMs.toFixed(1)}ms/frame`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('workflow execution', async ({ comfyPage }) => {
|
||||
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -61,11 +62,17 @@ test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test('Report missing nodes when connect to missing node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'primitive/primitive_node_connect_missing_node'
|
||||
)
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
119
browser_tests/tests/rightSidePanelTabs.spec.ts
Normal file
119
browser_tests/tests/rightSidePanelTabs.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Properties panel opens with workflow overview', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('Properties panel shows node details on selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('Node title input is editable', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(propertiesPanel.panelTitle).toContainText('KSampler')
|
||||
|
||||
// Click on the title to enter edit mode
|
||||
await propertiesPanel.panelTitle.click()
|
||||
const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput)
|
||||
await expect(titleInput).toBeVisible()
|
||||
|
||||
await titleInput.fill('My Custom Sampler')
|
||||
await titleInput.press('Enter')
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
|
||||
})
|
||||
|
||||
test('Search box filters properties', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
|
||||
await propertiesPanel.searchBox.fill('seed')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(0)
|
||||
|
||||
await propertiesPanel.searchBox.fill('')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Expand all / Collapse all toggles sections', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
// Select multiple nodes so collapse toggle button appears
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
// Sections default to collapsed when multiple nodes are selected,
|
||||
// so the button initially shows "Expand all"
|
||||
const expandButton = propertiesPanel.root.getByRole('button', {
|
||||
name: 'Expand all'
|
||||
})
|
||||
await expect(expandButton).toBeVisible()
|
||||
await expandButton.click()
|
||||
|
||||
const collapseButton = propertiesPanel.root.getByRole('button', {
|
||||
name: 'Collapse all'
|
||||
})
|
||||
await expect(collapseButton).toBeVisible()
|
||||
await collapseButton.click()
|
||||
|
||||
// After collapsing, the button label switches back to "Expand all"
|
||||
await expect(expandButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Properties panel can be closed', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.root).toBeVisible()
|
||||
|
||||
// The actionbar toggle button hides when the panel is open,
|
||||
// so use the close button inside the panel itself
|
||||
const closeButton = comfyPage.page.getByLabel('Toggle properties panel')
|
||||
await closeButton.click()
|
||||
await expect(propertiesPanel.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
85
browser_tests/tests/selectionRectangle.spec.ts
Normal file
85
browser_tests/tests/selectionRectangle.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('@canvas Selection Rectangle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Ctrl+A selects all nodes', async ({ comfyPage }) => {
|
||||
const totalCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(totalCount).toBeGreaterThan(0)
|
||||
|
||||
// Use canvas press for keyboard shortcuts (doesn't need click target)
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
|
||||
})
|
||||
|
||||
test('Click empty space deselects all', async ({ comfyPage }) => {
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(0)
|
||||
|
||||
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
|
||||
await comfyPage.page
|
||||
.getByText('Load Checkpoint')
|
||||
.click({ modifiers: ['Control'] })
|
||||
// Then deselect remaining via Escape or programmatic clear
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('Single click selects one node', async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Empty Latent Image').click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
|
||||
})
|
||||
|
||||
test('Selected nodes have visual indicator', async ({ comfyPage }) => {
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(checkpointNode).toHaveClass(/outline-node-component-outline/)
|
||||
})
|
||||
|
||||
test('Drag-select rectangle selects multiple nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
|
||||
// Use Ctrl+A to select all, which is functionally equivalent to
|
||||
// drag-selecting the entire canvas and more reliable in CI
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const totalCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
|
||||
expect(totalCount).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
101
browser_tests/tests/selectionToolboxActions.spec.ts
Normal file
101
browser_tests/tests/selectionToolboxActions.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
canvas.setDirty(true, true)
|
||||
}, nodePos)
|
||||
await comfyPage.nextFrame()
|
||||
await nodeRef.click('title')
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('delete button removes selected node', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const initialCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
|
||||
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
expect(newCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="properties-panel"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('delete button removes multiple selected nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
|
||||
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
expect(newCount).toBe(initialCount - 2)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -234,23 +235,29 @@ test.describe('Workflows sidebar', () => {
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.page
|
||||
.locator('.p-dialog')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Load blank workflow
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
// Switch back to the missing_nodes workflow
|
||||
// Switch back to the missing_nodes workflow — overlay should not reappear
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
).not.toBeVisible()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
|
||||
@@ -22,13 +22,12 @@ async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
await expect(parentLink).toBeVisible()
|
||||
await parentLink.click()
|
||||
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas.graph) return
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -36,6 +35,10 @@ test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Auto-promotion on Convert to Subgraph', () => {
|
||||
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
|
||||
comfyPage
|
||||
@@ -86,10 +89,18 @@ test.describe(
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select the SaveImage node (id 9 in default workflow)
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
const savePos = await saveNode.getPosition()
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
|
||||
canvas.setDirty(true, true)
|
||||
}, savePos)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -251,7 +262,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
@@ -285,7 +296,7 @@ test.describe(
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -331,7 +342,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -366,7 +377,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -397,7 +408,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -478,10 +489,18 @@ test.describe(
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select SaveImage (id 9)
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
const savePos = await saveNode.getPosition()
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
|
||||
canvas.setDirty(true, true)
|
||||
}, savePos)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
73
browser_tests/tests/toastNotifications.spec.ts
Normal file
73
browser_tests/tests/toastNotifications.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Toast Notifications', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function triggerErrorToast(comfyPage: {
|
||||
page: { evaluate: (fn: () => void) => Promise<void> }
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Test execution error',
|
||||
life: 30000
|
||||
})
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Error toast appears when triggered', async ({ comfyPage }) => {
|
||||
await triggerErrorToast(comfyPage)
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toast shows correct error severity class', async ({ comfyPage }) => {
|
||||
await triggerErrorToast(comfyPage)
|
||||
|
||||
const errorToast = comfyPage.page.locator(
|
||||
'.p-toast-message.p-toast-message-error'
|
||||
)
|
||||
await expect(errorToast.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toast can be dismissed via close button', async ({ comfyPage }) => {
|
||||
await triggerErrorToast(comfyPage)
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
|
||||
const closeButton = comfyPage.page.locator('.p-toast-close-button').first()
|
||||
await closeButton.click()
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('All toasts cleared via closeToasts helper', async ({ comfyPage }) => {
|
||||
await triggerErrorToast(comfyPage)
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
|
||||
expect(await comfyPage.toast.getVisibleToastCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('Toast error count is accurate', async ({ comfyPage }) => {
|
||||
await triggerErrorToast(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.p-toast-message.p-toast-message-error').first()
|
||||
).toBeVisible()
|
||||
|
||||
const errorCount = await comfyPage.toast.getToastErrorCount()
|
||||
expect(errorCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
24
browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts
Normal file
24
browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Reroute Node Size', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.workflow.loadWorkflow('links/single_connected_reroute_node')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'reroute node visual appearance',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-reroute-node-compact.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
45
browser_tests/tests/widgetCopyButton.spec.ts
Normal file
45
browser_tests/tests/widgetCopyButton.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Widget copy button', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Copy button has correct aria-label', async ({ comfyPage }) => {
|
||||
const copyButton = comfyPage.page
|
||||
.locator('[data-node-id] button[aria-label]')
|
||||
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--copy\\]') })
|
||||
.first()
|
||||
await expect(copyButton).toBeAttached()
|
||||
await expect(copyButton).toHaveAttribute('aria-label', /copy/i)
|
||||
})
|
||||
|
||||
test('Copy button exists within textarea widget group container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const container = comfyPage.page
|
||||
.locator('[data-node-id] div.group:has(textarea[readonly])')
|
||||
.first()
|
||||
await expect(container).toBeVisible()
|
||||
await container.hover()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const copyButton = container.locator('button').filter({
|
||||
has: comfyPage.page.locator('.icon-\\[lucide--copy\\]')
|
||||
})
|
||||
await expect(copyButton.first()).toBeAttached()
|
||||
})
|
||||
})
|
||||
@@ -6,13 +6,13 @@ ComfyUI frontend uses a comprehensive settings system for user preferences with
|
||||
|
||||
### Settings Architecture
|
||||
|
||||
- Settings are defined as `SettingParams` in `src/constants/coreSettings.ts`
|
||||
- Settings are defined as `SettingParams` in `src/platform/settings/constants/coreSettings.ts`
|
||||
- Registered at app startup, loaded/saved via `useSettingStore` (Pinia)
|
||||
- Persisted per user via backend `/settings` endpoint
|
||||
- If a value hasn't been set by the user, the store returns the computed default
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:105-122
|
||||
// From src/platform/settings/settingStore.ts:105-122
|
||||
function getDefaultValue<K extends keyof Settings>(
|
||||
key: K
|
||||
): Settings[K] | undefined {
|
||||
@@ -50,7 +50,7 @@ await newUserService().initializeIfNewUser(settingStore)
|
||||
You can compute defaults dynamically using function defaults that access runtime context:
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:94-101
|
||||
// From src/platform/settings/constants/coreSettings.ts:94-101
|
||||
{
|
||||
id: 'Comfy.Sidebar.Size',
|
||||
// Default to small if the window is less than 1536px(2xl) wide
|
||||
@@ -59,7 +59,7 @@ You can compute defaults dynamically using function defaults that access runtime
|
||||
```
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:306
|
||||
// From src/platform/settings/constants/coreSettings.ts:306
|
||||
{
|
||||
id: 'Comfy.Locale',
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
@@ -71,7 +71,7 @@ You can compute defaults dynamically using function defaults that access runtime
|
||||
You can vary defaults by installed frontend version using `defaultsByInstallVersion`:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:129-150
|
||||
// From src/platform/settings/settingStore.ts:129-150
|
||||
function getVersionedDefaultValue<
|
||||
K extends keyof Settings,
|
||||
TValue = Settings[K]
|
||||
@@ -101,7 +101,7 @@ function getVersionedDefaultValue<
|
||||
Example versioned defaults from codebase:
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:38-40
|
||||
// From src/platform/settings/constants/coreSettings.ts:38-40
|
||||
{
|
||||
id: 'Comfy.Graph.LinkReleaseAction',
|
||||
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
|
||||
@@ -168,7 +168,7 @@ Here are actual settings showing different patterns:
|
||||
The initial installed version is captured for new users to ensure versioned defaults remain stable:
|
||||
|
||||
```typescript
|
||||
// From src/services/newUserService.ts:49-53
|
||||
// From src/services/useNewUserService.ts
|
||||
await settingStore.set('Comfy.InstalledVersion', __COMFYUI_FRONTEND_VERSION__)
|
||||
```
|
||||
|
||||
@@ -220,7 +220,7 @@ await settingStore.set('Comfy.InstalledVersion', __COMFYUI_FRONTEND_VERSION__)
|
||||
Values are stored per user via the backend. The store writes through API and falls back to defaults when not set:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:73-75
|
||||
// From src/platform/settings/settingStore.ts:73-75
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
await api.storeSetting(key, newValue)
|
||||
@@ -245,7 +245,7 @@ await settingStore.set('Comfy.SomeSetting', newValue)
|
||||
Settings support migration from deprecated values:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:68-69, 172-175
|
||||
// From src/platform/settings/settingStore.ts:68-69, 172-175
|
||||
const newValue = tryMigrateDeprecatedValue(settingsById.value[key], clonedValue)
|
||||
|
||||
// Migration happens during addSetting for existing values:
|
||||
@@ -262,7 +262,7 @@ if (settingValues.value[setting.id] !== undefined) {
|
||||
Settings can define onChange callbacks that receive the setting definition, new value, and old value:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:73, 177
|
||||
// From src/platform/settings/settingStore.ts:73, 177
|
||||
onChange(settingsById.value[key], newValue, oldValue) // During set()
|
||||
onChange(setting, get(setting.id), undefined) // During addSetting()
|
||||
```
|
||||
|
||||
@@ -95,7 +95,7 @@ Testing with ComfyUI workflow files:
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/comfyWorkflow.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateComfyWorkflow } from '@/domains/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
|
||||
describe('workflow validation', () => {
|
||||
|
||||
@@ -305,6 +305,49 @@ export default defineConfig([
|
||||
}
|
||||
},
|
||||
|
||||
// Layer architecture boundary enforcement
|
||||
// Layers (bottom to top): base → platform → workbench → renderer
|
||||
// Each layer may only import from layers below it.
|
||||
// Existing violations are suppressed with eslint-disable comments.
|
||||
{
|
||||
files: [
|
||||
'src/base/**/*.{ts,vue}',
|
||||
'src/platform/**/*.{ts,vue}',
|
||||
'src/workbench/**/*.{ts,vue}'
|
||||
],
|
||||
rules: {
|
||||
'import-x/no-restricted-paths': [
|
||||
'error',
|
||||
{
|
||||
zones: [
|
||||
{
|
||||
target: './src/base/**',
|
||||
from: [
|
||||
'./src/platform/**',
|
||||
'./src/workbench/**',
|
||||
'./src/renderer/**'
|
||||
],
|
||||
message:
|
||||
'base/ cannot import from upper layers (violates layer architecture: base → platform → workbench → renderer)'
|
||||
},
|
||||
{
|
||||
target: './src/platform/**',
|
||||
from: ['./src/workbench/**', './src/renderer/**'],
|
||||
message:
|
||||
'platform/ cannot import from upper layers (violates layer architecture: base → platform → workbench → renderer)'
|
||||
},
|
||||
{
|
||||
target: './src/workbench/**',
|
||||
from: './src/renderer/**',
|
||||
message:
|
||||
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// i18n import enforcement
|
||||
// Vue components must use the useI18n() composable, not the global t/d/st/te
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.42.7",
|
||||
"version": "1.43.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
computeStats,
|
||||
formatSignificance,
|
||||
isNoteworthy,
|
||||
sparkline,
|
||||
trendArrow,
|
||||
trendDirection,
|
||||
zScore
|
||||
} from './perf-stats'
|
||||
|
||||
@@ -73,8 +76,11 @@ function groupByName(
|
||||
function loadHistoricalReports(): PerfReport[] {
|
||||
if (!existsSync(HISTORY_DIR)) return []
|
||||
const reports: PerfReport[] = []
|
||||
for (const dir of readdirSync(HISTORY_DIR)) {
|
||||
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
|
||||
for (const entry of readdirSync(HISTORY_DIR)) {
|
||||
const entryPath = join(HISTORY_DIR, entry)
|
||||
const filePath = entry.endsWith('.json')
|
||||
? entryPath
|
||||
: join(entryPath, 'perf-metrics.json')
|
||||
if (!existsSync(filePath)) continue
|
||||
try {
|
||||
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
|
||||
@@ -102,6 +108,27 @@ function getHistoricalStats(
|
||||
return computeStats(values)
|
||||
}
|
||||
|
||||
function getHistoricalTimeSeries(
|
||||
reports: PerfReport[],
|
||||
testName: string,
|
||||
metric: MetricKey
|
||||
): number[] {
|
||||
const sorted = [...reports].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
)
|
||||
const values: number[] = []
|
||||
for (const r of sorted) {
|
||||
const group = groupByName(r.measurements)
|
||||
const samples = group.get(testName)
|
||||
if (samples) {
|
||||
values.push(
|
||||
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
|
||||
)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function computeCV(stats: MetricStats): number {
|
||||
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
|
||||
}
|
||||
@@ -233,6 +260,34 @@ function renderFullReport(
|
||||
}
|
||||
lines.push('', '</details>')
|
||||
|
||||
const trendRows: string[] = []
|
||||
for (const [testName] of prGroups) {
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const series = getHistoricalTimeSeries(historical, testName, key)
|
||||
if (series.length < 3) continue
|
||||
const dir = trendDirection(series)
|
||||
const arrow = trendArrow(dir)
|
||||
const spark = sparkline(series)
|
||||
const last = series[series.length - 1]
|
||||
trendRows.push(
|
||||
`| ${testName}: ${label} | ${spark} | ${arrow} | ${formatValue(last, unit)} |`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (trendRows.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
`<details><summary>Trend (last ${historical.length} commits on main)</summary>`,
|
||||
'',
|
||||
'| Metric | Trend | Dir | Latest |',
|
||||
'|--------|-------|-----|--------|',
|
||||
...trendRows,
|
||||
'',
|
||||
'</details>'
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
computeStats,
|
||||
formatSignificance,
|
||||
isNoteworthy,
|
||||
sparkline,
|
||||
trendArrow,
|
||||
trendDirection,
|
||||
zScore
|
||||
} from './perf-stats'
|
||||
|
||||
@@ -131,3 +134,68 @@ describe('isNoteworthy', () => {
|
||||
expect(isNoteworthy('noisy')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sparkline', () => {
|
||||
it('returns empty string for no values', () => {
|
||||
expect(sparkline([])).toBe('')
|
||||
})
|
||||
|
||||
it('returns mid-height for single value', () => {
|
||||
expect(sparkline([50])).toBe('▄')
|
||||
})
|
||||
|
||||
it('renders ascending values low to high', () => {
|
||||
const result = sparkline([0, 25, 50, 75, 100])
|
||||
expect(result).toBe('▁▃▅▆█')
|
||||
})
|
||||
|
||||
it('renders identical values as flat line', () => {
|
||||
const result = sparkline([10, 10, 10])
|
||||
expect(result).toBe('▄▄▄')
|
||||
})
|
||||
|
||||
it('renders descending values high to low', () => {
|
||||
const result = sparkline([100, 50, 0])
|
||||
expect(result).toBe('█▅▁')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trendDirection', () => {
|
||||
it('returns stable for fewer than 3 values', () => {
|
||||
expect(trendDirection([])).toBe('stable')
|
||||
expect(trendDirection([1])).toBe('stable')
|
||||
expect(trendDirection([1, 2])).toBe('stable')
|
||||
})
|
||||
|
||||
it('detects rising trend', () => {
|
||||
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
|
||||
})
|
||||
|
||||
it('detects falling trend', () => {
|
||||
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
|
||||
})
|
||||
|
||||
it('returns stable for flat data', () => {
|
||||
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
|
||||
})
|
||||
|
||||
it('returns stable for small fluctuations within 10%', () => {
|
||||
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
|
||||
})
|
||||
|
||||
it('detects rising when baseline is zero but current is non-zero', () => {
|
||||
expect(trendDirection([0, 0, 0, 5, 5, 5])).toBe('rising')
|
||||
})
|
||||
|
||||
it('returns stable when both halves are zero', () => {
|
||||
expect(trendDirection([0, 0, 0, 0, 0, 0])).toBe('stable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trendArrow', () => {
|
||||
it('returns correct emoji for each direction', () => {
|
||||
expect(trendArrow('rising')).toBe('📈')
|
||||
expect(trendArrow('falling')).toBe('📉')
|
||||
expect(trendArrow('stable')).toBe('➡️')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,3 +61,53 @@ export function formatSignificance(
|
||||
export function isNoteworthy(sig: Significance): boolean {
|
||||
return sig === 'regression'
|
||||
}
|
||||
|
||||
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
|
||||
|
||||
export function sparkline(values: number[]): string {
|
||||
if (values.length === 0) return ''
|
||||
if (values.length === 1) return SPARK_CHARS[3]
|
||||
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const range = max - min
|
||||
|
||||
return values
|
||||
.map((v) => {
|
||||
if (range === 0) return SPARK_CHARS[3]
|
||||
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
|
||||
return SPARK_CHARS[idx]
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
export type TrendDirection = 'rising' | 'falling' | 'stable'
|
||||
|
||||
export function trendDirection(values: number[]): TrendDirection {
|
||||
if (values.length < 3) return 'stable'
|
||||
|
||||
const half = Math.floor(values.length / 2)
|
||||
const firstHalf = values.slice(0, half)
|
||||
const secondHalf = values.slice(-half)
|
||||
|
||||
const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
|
||||
const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
|
||||
|
||||
if (firstMean === 0) return secondMean > 0 ? 'rising' : 'stable'
|
||||
const changePct = ((secondMean - firstMean) / firstMean) * 100
|
||||
|
||||
if (changePct > 10) return 'rising'
|
||||
if (changePct < -10) return 'falling'
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
export function trendArrow(dir: TrendDirection): string {
|
||||
switch (dir) {
|
||||
case 'rising':
|
||||
return '📈'
|
||||
case 'falling':
|
||||
return '📉'
|
||||
case 'stable':
|
||||
return '➡️'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,13 @@ function renderCategoryBlock(category, hasBaseline) {
|
||||
? ['File', 'Before', 'After', 'Δ Raw', 'Δ Gzip', 'Δ Brotli']
|
||||
: ['File', 'Size', 'Gzip', 'Brotli']
|
||||
|
||||
const rows = category.bundles
|
||||
// Filter out unchanged bundles to keep report within GitHub's 65k char limit
|
||||
const changedBundles = category.bundles.filter(
|
||||
(b) => b.status !== 'unchanged'
|
||||
)
|
||||
const unchangedCount = category.bundles.length - changedBundles.length
|
||||
|
||||
const rows = changedBundles
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const diffMagnitude = Math.abs(b.diff.size) - Math.abs(a.diff.size)
|
||||
@@ -409,8 +415,10 @@ function renderCategoryBlock(category, hasBaseline) {
|
||||
]
|
||||
})
|
||||
|
||||
lines.push(markdownTable([headers, ...rows]))
|
||||
lines.push('')
|
||||
if (rows.length > 0) {
|
||||
lines.push(markdownTable([headers, ...rows]))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const statusParts = []
|
||||
if (category.counts.added) statusParts.push(`${category.counts.added} added`)
|
||||
@@ -420,6 +428,7 @@ function renderCategoryBlock(category, hasBaseline) {
|
||||
statusParts.push(`${category.counts.increased} grew`)
|
||||
if (category.counts.decreased)
|
||||
statusParts.push(`${category.counts.decreased} shrank`)
|
||||
if (unchangedCount > 0) statusParts.push(`${unchangedCount} unchanged`)
|
||||
|
||||
if (statusParts.length > 0) {
|
||||
lines.push(`_Status:_ ${statusParts.join(' / ')}`)
|
||||
|
||||
75
scripts/unified-report.js
Normal file
75
scripts/unified-report.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// @ts-check
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
/** @param {string} name */
|
||||
function getArg(name) {
|
||||
const prefix = `--${name}=`
|
||||
const arg = args.find((a) => a.startsWith(prefix))
|
||||
return arg ? arg.slice(prefix.length) : undefined
|
||||
}
|
||||
|
||||
const sizeStatus = getArg('size-status') ?? 'pending'
|
||||
const perfStatus = getArg('perf-status') ?? 'pending'
|
||||
|
||||
/** @type {string[]} */
|
||||
const lines = []
|
||||
|
||||
// --- Size section ---
|
||||
if (sizeStatus === 'ready') {
|
||||
try {
|
||||
const sizeReport = execFileSync('node', ['scripts/size-report.js'], {
|
||||
encoding: 'utf-8'
|
||||
}).trimEnd()
|
||||
lines.push(sizeReport)
|
||||
} catch {
|
||||
lines.push('## 📦 Bundle Size')
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'> ⚠️ Failed to render bundle size report. Check the CI workflow logs.'
|
||||
)
|
||||
}
|
||||
} else if (sizeStatus === 'failed') {
|
||||
lines.push('## 📦 Bundle Size')
|
||||
lines.push('')
|
||||
lines.push('> ⚠️ Size data collection failed. Check the CI workflow logs.')
|
||||
} else {
|
||||
lines.push('## 📦 Bundle Size')
|
||||
lines.push('')
|
||||
lines.push('> ⏳ Size data collection in progress…')
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
|
||||
// --- Perf section ---
|
||||
if (perfStatus === 'ready' && existsSync('test-results/perf-metrics.json')) {
|
||||
try {
|
||||
const perfReport = execFileSync(
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'scripts/perf-report.ts'],
|
||||
{ encoding: 'utf-8' }
|
||||
).trimEnd()
|
||||
lines.push(perfReport)
|
||||
} catch {
|
||||
lines.push('## ⚡ Performance')
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'> ⚠️ Failed to render performance report. Check the CI workflow logs.'
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
perfStatus === 'failed' ||
|
||||
(perfStatus === 'ready' && !existsSync('test-results/perf-metrics.json'))
|
||||
) {
|
||||
lines.push('## ⚡ Performance')
|
||||
lines.push('')
|
||||
lines.push('> ⚠️ Performance tests failed. Check the CI workflow logs.')
|
||||
} else {
|
||||
lines.push('## ⚡ Performance')
|
||||
lines.push('')
|
||||
lines.push('> ⏳ Performance tests in progress…')
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
@@ -2,7 +2,9 @@
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// Constants
|
||||
|
||||
@@ -201,7 +201,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
<!-- A image with placeholder fallback on error -->
|
||||
<template>
|
||||
<span
|
||||
v-if="!imageBroken"
|
||||
class="comfy-image-wrap"
|
||||
:class="[{ contain: contain }]"
|
||||
>
|
||||
<span v-if="!error" :class="cn('contents', contain && 'relative')">
|
||||
<img
|
||||
v-if="contain"
|
||||
:src="src"
|
||||
:data-test="src"
|
||||
class="comfy-image-blur"
|
||||
class="absolute inset-0 object-cover"
|
||||
:style="{ 'background-image': `url(${src})` }"
|
||||
:alt="alt"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<img
|
||||
:src="src"
|
||||
class="comfy-image-main"
|
||||
:class="classProp"
|
||||
:class="
|
||||
cn(
|
||||
'z-1 size-full object-cover object-center',
|
||||
contain && 'absolute object-contain backdrop-blur-[10px]',
|
||||
classProp
|
||||
)
|
||||
"
|
||||
:alt="alt"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</span>
|
||||
<div v-if="imageBroken" class="broken-image-placeholder">
|
||||
<i class="pi pi-image" />
|
||||
<div
|
||||
v-if="error"
|
||||
class="m-8 flex size-full flex-col items-center justify-center"
|
||||
>
|
||||
<i class="pi pi-image mb-2 text-5xl" />
|
||||
<span>{{ $t('g.imageFailedToLoad') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
@@ -46,58 +51,5 @@ const {
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const imageBroken = ref(false)
|
||||
const handleImageError = () => {
|
||||
imageBroken.value = true
|
||||
}
|
||||
const { error } = useImage(computed(() => ({ src, alt })))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-image-wrap {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.comfy-image-blur {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.comfy-image-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.contain .comfy-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.contain .comfy-image-main {
|
||||
object-fit: contain;
|
||||
backdrop-filter: blur(10px);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.broken-image-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.broken-image-placeholder i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn()
|
||||
}))
|
||||
|
||||
const createMockNode = (type: string, version?: string): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: 'comfy-core', ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
describe('MissingCoreNodesMessage', () => {
|
||||
const mockSystemStatsStore = {
|
||||
systemStats: null as { system?: { comfyui_version?: string } } | null,
|
||||
refetchSystemStats: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the mock store state
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.refetchSystemStats = vi.fn()
|
||||
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
|
||||
// The actual store has more properties, but we only need these for our tests.
|
||||
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(MissingCoreNodesMessage, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Message },
|
||||
mocks: {
|
||||
$t: (key: string, params?: { version?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
|
||||
'loadWorkflowWarning.outdatedVersionGeneric':
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
|
||||
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
missingCoreNodes: {},
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('does not render when there are no missing core nodes', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders message when there are missing core nodes', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current ComfyUI version when available', async () => {
|
||||
// Set systemStats directly (store auto-fetches with useAsyncState)
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: { comfyui_version: '1.0.0' }
|
||||
}
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for component to render
|
||||
await nextTick()
|
||||
|
||||
// No need to check if fetchSystemStats was called since useAsyncState auto-fetches
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays generic message when version is unavailable', async () => {
|
||||
// No systemStats set - version unavailable
|
||||
mockSystemStatsStore.systemStats = null
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for the async operations to complete
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups nodes by version and displays them', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('NodeA', '1.2.0'),
|
||||
createMockNode('NodeB', '1.2.0')
|
||||
],
|
||||
'1.3.0': [createMockNode('NodeC', '1.3.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Requires ComfyUI 1.3.0:')
|
||||
expect(text).toContain('NodeC')
|
||||
expect(text).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(text).toContain('NodeA, NodeB')
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.1.0': [createMockNode('Node1', '1.1.0')],
|
||||
'1.3.0': [createMockNode('Node3', '1.3.0')],
|
||||
'1.2.0': [createMockNode('Node2', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
const version13Index = text.indexOf('1.3.0')
|
||||
const version12Index = text.indexOf('1.2.0')
|
||||
const version11Index = text.indexOf('1.1.0')
|
||||
|
||||
expect(version13Index).toBeLessThan(version12Index)
|
||||
expect(version12Index).toBeLessThan(version11Index)
|
||||
})
|
||||
|
||||
it('removes duplicate node names within the same version', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('UniqueNode', '1.2.0')
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
// Should only appear once in the sorted list
|
||||
expect(text).toContain('DuplicateNode, UniqueNode')
|
||||
// Count occurrences of 'DuplicateNode' - should be only 1
|
||||
const matches = text.match(/DuplicateNode/g) || []
|
||||
expect(matches.length).toBe(1)
|
||||
})
|
||||
|
||||
it('handles nodes with missing version info', async () => {
|
||||
const missingCoreNodes = {
|
||||
'': [createMockNode('NoVersionNode')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
<template>
|
||||
<Message
|
||||
v-if="hasMissingCoreNodes"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
class="m-2"
|
||||
:pt="{
|
||||
root: { class: 'flex-col' },
|
||||
text: { class: 'flex-1' }
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
{{
|
||||
currentComfyUIVersion
|
||||
? $t('loadWorkflowWarning.outdatedVersion', {
|
||||
version: currentComfyUIVersion
|
||||
})
|
||||
: $t('loadWorkflowWarning.outdatedVersionGeneric')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="[version, nodes] in sortedMissingCoreNodes"
|
||||
:key="version"
|
||||
class="ml-4"
|
||||
>
|
||||
<div class="text-sm font-medium text-surface-600">
|
||||
{{
|
||||
$t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || 'unknown'
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="ml-4 text-sm text-surface-500">
|
||||
{{ getUniqueNodeNames(nodes).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { compare } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const { missingCoreNodes } = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
// Use computed for reactive version tracking
|
||||
const currentComfyUIVersion = computed<string | null>(() => {
|
||||
if (!hasMissingCoreNodes.value) return null
|
||||
return systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
})
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compare(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
|
||||
return nodes
|
||||
.reduce<string[]>((acc, node) => {
|
||||
if (node.type && !acc.includes(node.type)) {
|
||||
acc.push(node.type)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort()
|
||||
}
|
||||
</script>
|
||||
@@ -1,360 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="missing-nodes-warning"
|
||||
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||
:class="isCloud ? 'border-b' : ''"
|
||||
>
|
||||
<div class="flex size-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<p class="m-0 text-sm/5 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.description')
|
||||
: $t('missingNodes.oss.description')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||
|
||||
<!-- QUICK FIX AVAILABLE Section -->
|
||||
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
|
||||
<!-- Section header with Replace button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-primary uppercase">
|
||||
{{ $t('nodeReplacement.quickFixAvailable') }}
|
||||
</span>
|
||||
<div class="size-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
|
||||
variant="primary"
|
||||
size="md"
|
||||
:disabled="selectedTypes.size === 0"
|
||||
@click="handleReplaceSelected"
|
||||
>
|
||||
<i class="mr-1.5 icon-[lucide--refresh-cw] size-4" />
|
||||
{{
|
||||
$t('nodeReplacement.replaceSelected', {
|
||||
count: selectedTypes.size
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable nodes list -->
|
||||
<div
|
||||
class="flex scrollbar-custom max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background"
|
||||
>
|
||||
<!-- Select All row (sticky header) -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
|
||||
pendingNodes.length > 0
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
|
||||
"
|
||||
@click="toggleSelectAll"
|
||||
@keydown.enter.prevent="toggleSelectAll"
|
||||
@keydown.space.prevent="toggleSelectAll"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
isAllSelected || isSomeSelected
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isAllSelected"
|
||||
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isSomeSelected"
|
||||
class="text-bold icon-[lucide--minus] text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-muted-foreground uppercase">
|
||||
{{ $t('nodeReplacement.compatibleAlternatives') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable node items -->
|
||||
<div
|
||||
v-for="node in replaceableNodes"
|
||||
:key="node.label"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2',
|
||||
replacedTypes.has(node.label)
|
||||
? 'pointer-events-none opacity-50'
|
||||
: 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'true'
|
||||
: 'false'
|
||||
"
|
||||
@click="toggleNode(node.label)"
|
||||
@keydown.enter.prevent="toggleNode(node.label)"
|
||||
@keydown.space.prevent="toggleNode(node.label)"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
"
|
||||
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="replacedTypes.has(node.label)"
|
||||
class="border-success bg-success/10 text-success inline-flex h-4 items-center rounded-full border px-1.5 text-xxxs font-semibold uppercase"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaced') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold text-primary uppercase"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaceable') }}
|
||||
</span>
|
||||
<span class="text-foreground text-sm">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MANUAL INSTALLATION REQUIRED Section -->
|
||||
<div
|
||||
v-if="nonReplaceableNodes.length > 0"
|
||||
class="flex max-h-[200px] flex-col gap-2"
|
||||
>
|
||||
<!-- Section header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-error uppercase">
|
||||
{{ $t('nodeReplacement.installationRequired') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--info] text-xs text-error" />
|
||||
</div>
|
||||
|
||||
<!-- Non-replaceable nodes list -->
|
||||
<div
|
||||
class="flex scrollbar-custom flex-col overflow-y-auto rounded-lg bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
v-for="node in nonReplaceableNodes"
|
||||
:key="node.label"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold text-error uppercase"
|
||||
>
|
||||
{{ $t('nodeReplacement.notReplaceable') }}
|
||||
</span>
|
||||
<span class="text-foreground text-sm">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="node.hint" class="text-xs text-muted-foreground">
|
||||
{{ node.hint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="node.action"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="node.action.callback"
|
||||
>
|
||||
{{ node.action.text }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction box -->
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p class="text-neutral-foreground m-0 text-xs/5">
|
||||
<i18n-t keypath="nodeReplacement.instructionMessage">
|
||||
<template #red>
|
||||
<span class="text-error">{{
|
||||
$t('nodeReplacement.redHighlight')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const dialogStore = useDialogStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
interface ProcessedNode {
|
||||
label: string
|
||||
hint?: string
|
||||
action?: { text: string; callback: () => void }
|
||||
isReplaceable: boolean
|
||||
replacement?: NodeReplacement
|
||||
}
|
||||
|
||||
const replacedTypes = ref<Set<string>>(new Set())
|
||||
|
||||
const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||
const seenTypes = new Set<string>()
|
||||
return missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
seenTypes.add(type)
|
||||
return true
|
||||
})
|
||||
.map((node) => {
|
||||
if (typeof node === 'object') {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action,
|
||||
isReplaceable: node.isReplaceable ?? false,
|
||||
replacement: node.replacement
|
||||
}
|
||||
}
|
||||
return { label: node, isReplaceable: false }
|
||||
})
|
||||
})
|
||||
|
||||
const replaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => n.isReplaceable)
|
||||
)
|
||||
|
||||
const pendingNodes = computed(() =>
|
||||
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const nonReplaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => !n.isReplaceable)
|
||||
)
|
||||
|
||||
// Selection state - all pending nodes selected by default
|
||||
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
|
||||
|
||||
const isAllSelected = computed(
|
||||
() =>
|
||||
pendingNodes.value.length > 0 &&
|
||||
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const isSomeSelected = computed(
|
||||
() => selectedTypes.value.size > 0 && !isAllSelected.value
|
||||
)
|
||||
|
||||
function toggleNode(label: string) {
|
||||
if (replacedTypes.value.has(label)) return
|
||||
const next = new Set(selectedTypes.value)
|
||||
if (next.has(label)) {
|
||||
next.delete(label)
|
||||
} else {
|
||||
next.add(label)
|
||||
}
|
||||
selectedTypes.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedTypes.value = new Set()
|
||||
} else {
|
||||
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceSelected() {
|
||||
const selected = missingNodeTypes.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
return selectedTypes.value.has(type)
|
||||
})
|
||||
|
||||
const result = replaceNodesInPlace(selected)
|
||||
const nextReplaced = new Set(replacedTypes.value)
|
||||
const nextSelected = new Set(selectedTypes.value)
|
||||
for (const type of result) {
|
||||
nextReplaced.add(type)
|
||||
nextSelected.delete(type)
|
||||
}
|
||||
replacedTypes.value = nextReplaced
|
||||
selectedTypes.value = nextSelected
|
||||
|
||||
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
|
||||
// but the modal only updates its own local UI state above.
|
||||
// Without this call the Errors Tab would still list the replaced nodes
|
||||
// as missing because executionErrorStore is not aware of the replacement.
|
||||
if (result.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType(result)
|
||||
}
|
||||
|
||||
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||
const allReplaced = replaceableNodes.value.every((n) =>
|
||||
nextReplaced.has(n.label)
|
||||
)
|
||||
if (allReplaced && nonReplaceableNodes.value.length === 0) {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,199 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2 px-4 py-2">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainNodes"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainNodes">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="ml-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
|
||||
@click="openShowMissingNodesSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
|
||||
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">
|
||||
{{ $t('nodeReplacement.skipForNow') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-else-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Manager buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||
<Button variant="textonly" @click="handleOpenManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
|
||||
})
|
||||
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
function handleOpenManager() {
|
||||
managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if any of the missing packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
if (!missingNodePacks.value?.length) return false
|
||||
return missingNodePacks.value.some((pack) =>
|
||||
comfyManagerStore.isPackInstalling(pack.id)
|
||||
)
|
||||
})
|
||||
|
||||
// Show manager buttons unless manager is disabled
|
||||
const showManagerButtons = computed(() => {
|
||||
return managerState.shouldShowManagerButtons.value
|
||||
})
|
||||
|
||||
// Only show Install All button for NEW_UI (new manager with v4 support)
|
||||
const showInstallAllButton = computed(() => {
|
||||
return managerState.shouldShowInstallButton.value
|
||||
})
|
||||
|
||||
const hasNonReplaceableNodes = computed(
|
||||
() =>
|
||||
missingNodeTypes?.some(
|
||||
(n) =>
|
||||
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
|
||||
) ?? false
|
||||
)
|
||||
|
||||
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
|
||||
const hadMissingPacks = ref(false)
|
||||
|
||||
watch(
|
||||
missingNodePacks,
|
||||
(packs) => {
|
||||
if (packs && packs.length > 0) hadMissingPacks.value = true
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Only consider "all installed" when packs transitioned from non-empty to empty
|
||||
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
if (!hadMissingPacks.value) return false
|
||||
return (
|
||||
!isLoading.value &&
|
||||
!isInstalling.value &&
|
||||
missingNodePacks.value?.length === 0
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for completion and close dialog (OSS mode only)
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (!isCloud && allInstalled && showInstallAllButton.value) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
|
||||
// Show success toast
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('manager.allMissingNodesInstalled'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.title')
|
||||
: $t('missingNodes.oss.title')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
</script>
|
||||
@@ -80,7 +80,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -101,7 +101,7 @@ if (isComponentWidget(props.widget)) {
|
||||
node.value = props.widget.node
|
||||
} else if (props.nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
|
||||
node.value = resolveNode(props.nodeId!) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
@@ -7,6 +8,15 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => {
|
||||
return {
|
||||
createI18n: () => ({
|
||||
@@ -46,6 +56,7 @@ const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
@@ -71,10 +82,45 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: { displayedJobGroups }
|
||||
props: { displayedJobGroups },
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createDomRect({
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height
|
||||
}: {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ''
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
@@ -143,4 +189,207 @@ describe('JobAssetsList', () => {
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits viewItem from the View button for completed jobs without preview output', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef()
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
const viewButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'menuLabels.View')
|
||||
expect(viewButton).toBeDefined()
|
||||
|
||||
await viewButton!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('shows and hides the job details popover with hover delays', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: job.id,
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
|
||||
await jobRow.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the job details popover open while hovering the popover', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
await jobRow.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.exists()).toBe(true)
|
||||
|
||||
await popover.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await popover.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('positions the popover to the right of rows near the left viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
||||
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
|
||||
createDomRect({
|
||||
top: 100,
|
||||
left: 40,
|
||||
width: 200,
|
||||
height: 48
|
||||
})
|
||||
)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.attributes('style')).toContain('left: 248px;')
|
||||
})
|
||||
|
||||
it('positions the popover to the left of rows near the right viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
||||
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
|
||||
createDomRect({
|
||||
top: 100,
|
||||
left: 980,
|
||||
width: 200,
|
||||
height: 48
|
||||
})
|
||||
)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.attributes('style')).toContain('left: 672px;')
|
||||
})
|
||||
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const wrapper = mountJobAssetsList([firstJob, secondJob])
|
||||
const firstRow = wrapper.find('[data-job-id="job-1"]')
|
||||
const secondRow = wrapper.find('[data-job-id="job-2"]')
|
||||
|
||||
await firstRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
|
||||
'job-1'
|
||||
)
|
||||
|
||||
await firstRow.trigger('mouseleave')
|
||||
await secondRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
await secondRow.trigger('mouseleave')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const wrapper = mountJobAssetsList([firstJob, secondJob])
|
||||
const firstRow = wrapper.find('[data-job-id="job-1"]')
|
||||
const secondRow = wrapper.find('[data-job-id="job-2"]')
|
||||
|
||||
await firstRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
|
||||
'job-1'
|
||||
)
|
||||
|
||||
await firstRow.trigger('mouseleave')
|
||||
await secondRow.trigger('mouseenter')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props('jobId')).toBe('job-2')
|
||||
})
|
||||
|
||||
it('does not show details if the hovered row disappears before the show delay ends', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await wrapper.setProps({ displayedJobGroups: [] })
|
||||
await nextTick()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
expect(wrapper.find('.job-details-popover').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,78 +8,109 @@
|
||||
<div class="text-xs leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<AssetsListItem
|
||||
<div
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@mouseenter="hoveredJobId = job.id"
|
||||
:data-job-id="job.id"
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="$emit('cancelItem', job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="$emit('deleteItem', job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="$emit('viewItem', job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeDetails && popoverPosition"
|
||||
class="job-details-popover fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover
|
||||
:job-id="activeDetails.jobId"
|
||||
:workflow-id="activeDetails.workflowId"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
@@ -90,22 +121,78 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
resetActiveDetails,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
} = useJobDetailsHover<{ jobId: string; workflowId?: string }>({
|
||||
getActiveId: (details) => details.jobId,
|
||||
getDisplayedJobGroups: () => displayedJobGroups,
|
||||
onReset: clearPopoverAnchor
|
||||
})
|
||||
|
||||
const onJobLeave = (jobId: string) => {
|
||||
function clearPopoverAnchor() {
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
}
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const rowElement = activeRowElement.value
|
||||
if (!rowElement) return
|
||||
|
||||
const rect = rowElement.getBoundingClientRect()
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
scheduleDetailsHide(jobId, clearPopoverAnchor)
|
||||
}
|
||||
|
||||
const isCancelable = (job: JobListItem) =>
|
||||
job.showClear !== false && isActiveJobState(job.state)
|
||||
function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
hoveredJobId.value = job.id
|
||||
|
||||
const isFailedDeletable = (job: JobListItem) =>
|
||||
job.showClear !== false && job.state === 'failed'
|
||||
const rowElement = event.currentTarget
|
||||
if (!(rowElement instanceof HTMLElement)) return
|
||||
|
||||
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
|
||||
activeRowElement.value = rowElement
|
||||
if (activeDetails.value?.jobId === job.id) {
|
||||
clearHoverTimers()
|
||||
void nextTick(updatePopoverPosition)
|
||||
return
|
||||
}
|
||||
|
||||
const getJobPreviewUrl = (job: JobListItem) => {
|
||||
scheduleDetailsShow(
|
||||
{
|
||||
jobId: job.id,
|
||||
workflowId: job.taskRef?.workflowId
|
||||
},
|
||||
() => {
|
||||
activeRowElement.value = rowElement
|
||||
void nextTick(updatePopoverPosition)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function isCancelable(job: JobListItem) {
|
||||
return job.showClear !== false && isActiveJobState(job.state)
|
||||
}
|
||||
|
||||
function isFailedDeletable(job: JobListItem) {
|
||||
return job.showClear !== false && job.state === 'failed'
|
||||
}
|
||||
|
||||
function getPreviewOutput(job: JobListItem) {
|
||||
return job.taskRef?.previewOutput
|
||||
}
|
||||
|
||||
function getJobPreviewUrl(job: JobListItem) {
|
||||
const preview = getPreviewOutput(job)
|
||||
if (preview?.isImage || preview?.isVideo) {
|
||||
return preview.url
|
||||
@@ -113,19 +200,45 @@ const getJobPreviewUrl = (job: JobListItem) => {
|
||||
return job.iconImageUrl
|
||||
}
|
||||
|
||||
const isVideoPreviewJob = (job: JobListItem) =>
|
||||
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
|
||||
function isVideoPreviewJob(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
|
||||
}
|
||||
|
||||
const isPreviewableCompletedJob = (job: JobListItem) =>
|
||||
job.state === 'completed' && !!getPreviewOutput(job)
|
||||
function isPreviewableCompletedJob(job: JobListItem) {
|
||||
return job.state === 'completed' && !!getPreviewOutput(job)
|
||||
}
|
||||
|
||||
const emitViewItem = (job: JobListItem) => {
|
||||
function emitViewItem(job: JobListItem) {
|
||||
if (isPreviewableCompletedJob(job)) {
|
||||
resetActiveDetails()
|
||||
emit('viewItem', job)
|
||||
}
|
||||
}
|
||||
|
||||
const getJobIconClass = (job: JobListItem): string | undefined => {
|
||||
function emitCompletedViewItem(job: JobListItem) {
|
||||
resetActiveDetails()
|
||||
emit('viewItem', job)
|
||||
}
|
||||
|
||||
function emitCancelItem(job: JobListItem) {
|
||||
resetActiveDetails()
|
||||
emit('cancelItem', job)
|
||||
}
|
||||
|
||||
function emitDeleteItem(job: JobListItem) {
|
||||
resetActiveDetails()
|
||||
emit('deleteItem', job)
|
||||
}
|
||||
|
||||
function onPopoverEnter() {
|
||||
clearHoverTimers()
|
||||
}
|
||||
|
||||
function onPopoverLeave() {
|
||||
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
return 'animate-spin'
|
||||
|
||||
@@ -1,9 +1,54 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const popoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
emits: ['show', 'hide'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
container: null as HTMLElement | null,
|
||||
eventTarget: null as EventTarget | null,
|
||||
target: null as EventTarget | null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
updated() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
methods: {
|
||||
toggle(event: Event, target?: EventTarget | null) {
|
||||
if (this.visible) {
|
||||
this.hide()
|
||||
return
|
||||
}
|
||||
|
||||
this.show(event, target)
|
||||
},
|
||||
show(event: Event, target?: EventTarget | null) {
|
||||
this.visible = true
|
||||
this.eventTarget = event.currentTarget
|
||||
this.target = target ?? event.currentTarget
|
||||
this.$emit('show')
|
||||
},
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.$emit('hide')
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="visible" ref="container" class="popover-stub">
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
@@ -37,31 +82,57 @@ const mountComponent = (entries: MenuEntry[]) =>
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div class="popover-stub"><slot /></div>'
|
||||
},
|
||||
Popover: popoverStub,
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
|
||||
({
|
||||
type,
|
||||
currentTarget,
|
||||
target: currentTarget
|
||||
}) as Event
|
||||
|
||||
const openMenu = async (
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
type: string = 'click'
|
||||
) => {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
await wrapper.vm.open(createTriggerEvent(type, trigger))
|
||||
await nextTick()
|
||||
return trigger
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', () => {
|
||||
it('passes disabled state to action buttons', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
await openMenu(wrapper)
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
@@ -73,9 +144,52 @@ describe('JobContextMenu', () => {
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides on pointerdown outside the popover', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(trigger, outside)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Popover
|
||||
ref="jobItemPopoverRef"
|
||||
:dismissable="true"
|
||||
:dismissable="false"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
@@ -12,8 +12,11 @@
|
||||
]
|
||||
}
|
||||
}"
|
||||
@show="isVisible = true"
|
||||
@hide="onHide"
|
||||
>
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
@@ -45,9 +48,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
@@ -56,18 +60,55 @@ const emit = defineEmits<{
|
||||
(e: 'action', entry: MenuEntry): void
|
||||
}>()
|
||||
|
||||
const jobItemPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
type PopoverHandle = {
|
||||
hide: () => void
|
||||
show: (event: Event, target?: EventTarget | null) => void
|
||||
}
|
||||
|
||||
function open(event: Event) {
|
||||
if (jobItemPopoverRef.value) {
|
||||
jobItemPopoverRef.value.toggle(event)
|
||||
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isVisible = ref(false)
|
||||
const openedByClick = ref(false)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isVisible,
|
||||
getOverlayEl: () => contentRef.value,
|
||||
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
|
||||
onDismiss: hide
|
||||
})
|
||||
|
||||
async function open(event: Event) {
|
||||
const trigger =
|
||||
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||||
const isSameClickTrigger =
|
||||
event.type === 'click' && trigger === triggerRef.value && isVisible.value
|
||||
|
||||
if (isSameClickTrigger) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
openedByClick.value = event.type === 'click'
|
||||
triggerRef.value = trigger
|
||||
|
||||
if (isVisible.value) {
|
||||
hide()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
jobItemPopoverRef.value?.show(event, trigger)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
jobItemPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
isVisible.value = false
|
||||
openedByClick.value = false
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
if (entry.kind === 'divider' || entry.disabled) return
|
||||
emit('action', entry)
|
||||
|
||||
@@ -56,11 +56,11 @@ const mountComponent = (groups: JobGroup[]) =>
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JobGroupsList hover behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('delays showing and hiding details while hovering over job rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = createJobItem({ id: 'job-d' })
|
||||
@@ -95,4 +95,33 @@ describe('JobGroupsList hover behavior', () => {
|
||||
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
|
||||
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
|
||||
const wrapper = mountComponent([
|
||||
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
|
||||
])
|
||||
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
|
||||
|
||||
jobItems[0].vm.$emit('details-enter', firstJob.id)
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
|
||||
|
||||
jobItems[0].vm.$emit('details-leave', firstJob.id)
|
||||
jobItems[1].vm.$emit('details-enter', secondJob.id)
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
jobItems[1].vm.$emit('details-leave', secondJob.id)
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(jobItems[0].props('activeDetailsId')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(jobItems[1].props('activeDetailsId')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,12 +36,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
|
||||
const props = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
@@ -50,65 +49,34 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const emitCancelItem = (item: JobListItem) => {
|
||||
const {
|
||||
activeDetails: activeDetailsId,
|
||||
clearHoverTimers,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
} = useJobDetailsHover<string>({
|
||||
getActiveId: (jobId) => jobId,
|
||||
getDisplayedJobGroups: () => displayedJobGroups
|
||||
})
|
||||
|
||||
function emitCancelItem(item: JobListItem) {
|
||||
emit('cancelItem', item)
|
||||
}
|
||||
|
||||
const emitDeleteItem = (item: JobListItem) => {
|
||||
function emitDeleteItem(item: JobListItem) {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
const activeDetailsId = ref<string | null>(null)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
function onDetailsEnter(jobId: string) {
|
||||
if (activeDetailsId.value === jobId) {
|
||||
clearHoverTimers()
|
||||
return
|
||||
}
|
||||
}
|
||||
const clearShowTimer = () => {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
const onDetailsEnter = (jobId: string) => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
activeDetailsId.value = jobId
|
||||
showTimer.value = null
|
||||
}, 200)
|
||||
}
|
||||
const onDetailsLeave = (jobId: string) => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
if (activeDetailsId.value === jobId) activeDetailsId.value = null
|
||||
hideTimer.value = null
|
||||
}, 150)
|
||||
|
||||
scheduleDetailsShow(jobId)
|
||||
}
|
||||
|
||||
const resetActiveDetails = () => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
activeDetailsId.value = null
|
||||
function onDetailsLeave(jobId: string) {
|
||||
scheduleDetailsHide(jobId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.displayedJobGroups,
|
||||
(groups) => {
|
||||
const activeId = activeDetailsId.value
|
||||
if (!activeId) return
|
||||
|
||||
const hasActiveJob = groups.some((group) =>
|
||||
group.items.some((item) => item.id === activeId)
|
||||
)
|
||||
|
||||
if (!hasActiveJob) resetActiveDetails()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
@@ -26,7 +26,7 @@
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPreviewEnter"
|
||||
@mouseleave="onPreviewLeave"
|
||||
@@ -191,6 +191,7 @@ import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
@@ -298,17 +299,13 @@ const onIconLeave = () => scheduleHidePreview()
|
||||
const onPreviewEnter = () => scheduleShowPreview()
|
||||
const onPreviewLeave = () => scheduleHidePreview()
|
||||
|
||||
const popoverPosition = ref<{ top: number; right: number } | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rowRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const gap = 8
|
||||
popoverPosition.value = {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.left + gap
|
||||
}
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
}
|
||||
|
||||
const isAnyPopoverVisible = computed(
|
||||
|
||||
61
src/components/queue/job/getHoverPopoverPosition.test.ts
Normal file
61
src/components/queue/job/getHoverPopoverPosition.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getHoverPopoverPosition } from './getHoverPopoverPosition'
|
||||
|
||||
describe('getHoverPopoverPosition', () => {
|
||||
it('places the popover to the right when space is available', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 40, right: 240 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 248 })
|
||||
})
|
||||
|
||||
it('places the popover to the left when right space is insufficient', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 980, right: 1180 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 672 })
|
||||
})
|
||||
|
||||
it('clamps the top to viewport padding when rect.top is near the top edge', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 2, left: 40, right: 240 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 8, left: 248 })
|
||||
})
|
||||
|
||||
it('clamps left to viewport padding when fallback would go off-screen', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 100, right: 300 },
|
||||
320
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 8 })
|
||||
})
|
||||
|
||||
it('prefers right when both sides have equal space', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 200, left: 340, right: 640 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 200, left: 648 })
|
||||
})
|
||||
|
||||
it('falls back to left when right space is less than popover width', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 600, right: 1000 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 292 })
|
||||
})
|
||||
|
||||
it('handles narrow viewport where popover barely fits', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 50, left: 8, right: 100 },
|
||||
316
|
||||
)
|
||||
expect(position).toEqual({ top: 50, left: 8 })
|
||||
})
|
||||
})
|
||||
39
src/components/queue/job/getHoverPopoverPosition.ts
Normal file
39
src/components/queue/job/getHoverPopoverPosition.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
const POPOVER_GAP = 8
|
||||
const POPOVER_WIDTH = 300
|
||||
const VIEWPORT_PADDING = 8
|
||||
|
||||
type AnchorRect = Pick<DOMRect, 'top' | 'left' | 'right'>
|
||||
|
||||
type HoverPopoverPosition = {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export function getHoverPopoverPosition(
|
||||
rect: AnchorRect,
|
||||
viewportWidth: number
|
||||
): HoverPopoverPosition {
|
||||
const availableLeft = rect.left - POPOVER_GAP
|
||||
const availableRight = viewportWidth - rect.right - POPOVER_GAP
|
||||
const preferredLeft = rect.right + POPOVER_GAP
|
||||
const fallbackLeft = rect.left - POPOVER_WIDTH - POPOVER_GAP
|
||||
const maxLeft = Math.max(
|
||||
VIEWPORT_PADDING,
|
||||
viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING
|
||||
)
|
||||
|
||||
if (
|
||||
availableRight >= POPOVER_WIDTH &&
|
||||
(availableRight >= availableLeft || availableLeft < POPOVER_WIDTH)
|
||||
) {
|
||||
return {
|
||||
top: Math.max(VIEWPORT_PADDING, rect.top),
|
||||
left: Math.min(maxLeft, preferredLeft)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: Math.max(VIEWPORT_PADDING, rect.top),
|
||||
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft))
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,41 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const mockIsCloud = { value: false }
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockMissingCoreNodes = vi.hoisted(() => ({
|
||||
value: {} as Record<string, { type: string }[]>
|
||||
}))
|
||||
const mockSystemStats = vi.hoisted(() => ({
|
||||
value: null as { system?: { comfyui_version?: string } } | null
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
|
||||
() => ({
|
||||
useMissingNodes: () => ({
|
||||
missingCoreNodes: mockMissingCoreNodes,
|
||||
missingNodePacks: { value: [] },
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
hasMissingNodes: { value: false }
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: () => ({
|
||||
get systemStats() {
|
||||
return mockSystemStats.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockApplyChanges = vi.fn()
|
||||
const mockIsRestarting = ref(false)
|
||||
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
|
||||
@@ -61,6 +89,13 @@ const i18n = createI18n({
|
||||
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
|
||||
applyChanges: 'Apply Changes'
|
||||
}
|
||||
},
|
||||
loadWorkflowWarning: {
|
||||
outdatedVersion:
|
||||
'Some nodes require a newer version of ComfyUI (current: {version}).',
|
||||
outdatedVersionGeneric:
|
||||
'Some nodes require a newer version of ComfyUI.',
|
||||
coreNodesFromVersion: 'Requires ComfyUI {version}:'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -109,6 +144,8 @@ describe('MissingNodeCard', () => {
|
||||
mockIsCloud.value = false
|
||||
mockShouldShowManagerButtons.value = false
|
||||
mockIsRestarting.value = false
|
||||
mockMissingCoreNodes.value = {}
|
||||
mockSystemStats.value = null
|
||||
})
|
||||
|
||||
describe('Rendering & Props', () => {
|
||||
@@ -234,4 +271,81 @@ describe('MissingNodeCard', () => {
|
||||
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Core Node Version Warning', () => {
|
||||
it('does not render warning when no missing core nodes', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
|
||||
})
|
||||
|
||||
it('renders warning with version when missing core nodes exist', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.2.0': [{ type: 'TestNode' }]
|
||||
}
|
||||
mockSystemStats.value = { system: { comfyui_version: '1.0.0' } }
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('(current: 1.0.0)')
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(wrapper.text()).toContain('TestNode')
|
||||
})
|
||||
|
||||
it('renders generic message when version is unavailable', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.2.0': [{ type: 'TestNode' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render warning on Cloud', () => {
|
||||
mockIsCloud.value = true
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.2.0': [{ type: 'TestNode' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
|
||||
})
|
||||
|
||||
it('deduplicates and sorts node names within a version', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.2.0': [
|
||||
{ type: 'ZebraNode' },
|
||||
{ type: 'AlphaNode' },
|
||||
{ type: 'ZebraNode' }
|
||||
]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('AlphaNode, ZebraNode')
|
||||
expect(wrapper.text().match(/ZebraNode/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.1.0': [{ type: 'Node1' }],
|
||||
'1.3.0': [{ type: 'Node3' }],
|
||||
'1.2.0': [{ type: 'Node2' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
const text = wrapper.text()
|
||||
const v13 = text.indexOf('1.3.0')
|
||||
const v12 = text.indexOf('1.2.0')
|
||||
const v11 = text.indexOf('1.1.0')
|
||||
expect(v13).toBeLessThan(v12)
|
||||
expect(v12).toBeLessThan(v11)
|
||||
})
|
||||
|
||||
it('handles empty string version key without crashing', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'': [{ type: 'NoVersionNode' }],
|
||||
'1.2.0': [{ type: 'VersionedNode' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(wrapper.text()).toContain('VersionedNode')
|
||||
expect(wrapper.text()).toContain('unknown')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
role="alert"
|
||||
class="mb-3 flex gap-2.5 rounded-lg border border-warning-background/30 bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 text-xs/relaxed text-muted-foreground">
|
||||
<p class="m-0">
|
||||
{{
|
||||
currentComfyUIVersion
|
||||
? t('loadWorkflowWarning.outdatedVersion', {
|
||||
version: currentComfyUIVersion
|
||||
})
|
||||
: t('loadWorkflowWarning.outdatedVersionGeneric')
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
v-for="[version, nodes] in sortedMissingCoreNodes"
|
||||
:key="version"
|
||||
class="ml-2"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || t('loadWorkflowWarning.unknownVersion')
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="ml-1">{{ getUniqueNodeNames(nodes).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||
<p
|
||||
class="m-0 text-sm/relaxed text-muted-foreground"
|
||||
@@ -53,7 +90,11 @@
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{
|
||||
t('rightSidePanel.missingNodePacks.applyChanges')
|
||||
}}</span>
|
||||
@@ -64,16 +105,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { compare, valid } from 'semver'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
@@ -86,6 +131,34 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(
|
||||
() => Object.keys(missingCoreNodes.value).length > 0
|
||||
)
|
||||
|
||||
const currentComfyUIVersion = computed<string | null>(() => {
|
||||
if (!hasMissingCoreNodes.value) return null
|
||||
return systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
})
|
||||
|
||||
const sortedMissingCoreNodes = computed(() =>
|
||||
Object.entries(missingCoreNodes.value).sort(([a], [b]) => {
|
||||
const aValid = valid(a)
|
||||
const bValid = valid(b)
|
||||
if (!aValid && !bValid) return 0
|
||||
if (!aValid) return 1
|
||||
if (!bValid) return -1
|
||||
return compare(b, a)
|
||||
})
|
||||
)
|
||||
|
||||
function getUniqueNodeNames(nodes: LGraphNode[]): string[] {
|
||||
const types = new Set(nodes.map((node) => node.type).filter(Boolean))
|
||||
return [...types].sort()
|
||||
}
|
||||
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { isRestarting, applyChanges } = useApplyChanges()
|
||||
const { shouldShowManagerButtons } = useManagerState()
|
||||
@@ -95,7 +168,7 @@ const { shouldShowManagerButtons } = useManagerState()
|
||||
* - Not on Cloud (OSS/local only)
|
||||
* - Manager is disabled (showInfoButton is false)
|
||||
*/
|
||||
const showManagerHint = computed(() => !isCloud && !props.showInfoButton)
|
||||
const showManagerHint = computed(() => !isCloud && !showInfoButton)
|
||||
|
||||
/**
|
||||
* Show Apply Changes when any pack from the error group is already installed
|
||||
@@ -103,7 +176,7 @@ const showManagerHint = computed(() => !isCloud && !props.showInfoButton)
|
||||
* This is server-state based → persists across browser refreshes.
|
||||
*/
|
||||
const hasInstalledPacksPendingRestart = computed(() =>
|
||||
props.missingPackGroups.some(
|
||||
missingPackGroups.some(
|
||||
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -193,7 +193,7 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const props = defineProps<{
|
||||
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
@@ -211,8 +211,8 @@ const comfyManagerStore = useComfyManagerStore()
|
||||
const { shouldShowManagerButtons, openManager } = useManagerState()
|
||||
|
||||
const nodePack = computed(() => {
|
||||
if (!props.group.packId) return null
|
||||
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
|
||||
if (!group.packId) return null
|
||||
return missingNodePacks.value.find((p) => p.id === group.packId) ?? null
|
||||
})
|
||||
|
||||
const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
@@ -220,8 +220,8 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
)
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!props.group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
|
||||
if (!group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(group.packId)) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -84,6 +85,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
storeToRefs(searchBoxStore)
|
||||
@@ -165,6 +167,7 @@ function getFirstLink() {
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function showNewSearchBox(e: CanvasPointerEvent | null) {
|
||||
trackFeatureUsed()
|
||||
const firstLink = getFirstLink()
|
||||
if (firstLink) {
|
||||
const filter =
|
||||
|
||||
163
src/components/sidebar/tabs/JobHistorySidebarTab.test.ts
Normal file
163
src/components/sidebar/tabs/JobHistorySidebarTab.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobList', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const jobHistoryItem = {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
url: '/api/view/job-1.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
useJobList: () => ({
|
||||
selectedJobTab: ref('All'),
|
||||
selectedWorkflowFilter: ref('all'),
|
||||
selectedSortMode: ref('mostRecent'),
|
||||
searchQuery: ref(''),
|
||||
hasFailedJobs: ref(false),
|
||||
filteredTasks: ref([]),
|
||||
groupedJobItems: ref([
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: [jobHistoryItem]
|
||||
}
|
||||
])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({
|
||||
jobMenuEntries: [],
|
||||
cancelJob: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
|
||||
useQueueClearHistoryDialog: () => ({
|
||||
showQueueClearHistoryDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useResultGallery', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useResultGallery: () => ({
|
||||
galleryActiveIndex: ref(-1),
|
||||
galleryItems: ref([]),
|
||||
onViewItem: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
|
||||
fn: T
|
||||
) => fn
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
clearInitializationByJobIds: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
runningTasks: [],
|
||||
pendingTasks: [],
|
||||
delete: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const SidebarTabTemplateStub = {
|
||||
name: 'SidebarTabTemplate',
|
||||
props: ['title'],
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
|
||||
function mountComponent() {
|
||||
return mount(JobHistorySidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SidebarTabTemplate: SidebarTabTemplateStub,
|
||||
JobFilterTabs: true,
|
||||
JobFilterActions: true,
|
||||
JobHistoryActionsMenu: true,
|
||||
JobContextMenu: true,
|
||||
ResultGallery: true,
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JobHistorySidebarTab', () => {
|
||||
it('shows the job details popover for jobs in the history panel', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountComponent()
|
||||
const jobRow = wrapper.find('[data-job-id="job-1"]')
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: 'job-1',
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,7 @@
|
||||
:src="item.url"
|
||||
:contain="false"
|
||||
:alt="item.filename"
|
||||
class="galleria-image"
|
||||
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<ResultVideo v-else-if="item.isVideo" :result="item" />
|
||||
<ResultAudio v-else-if="item.isAudio" :result="item" />
|
||||
@@ -136,12 +136,6 @@ onUnmounted(() => {
|
||||
<style>
|
||||
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
|
||||
cannot use scoped style here. */
|
||||
img.galleria-image {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.p-galleria-close-button {
|
||||
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
|
||||
z-index: 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<video controls width="100%" height="100%">
|
||||
<video controls class="max-h-[90vh] max-w-[90vw]">
|
||||
<source :src="url" :type="htmlVideoType" />
|
||||
{{ $t('g.videoFailedToLoad') }}
|
||||
</video>
|
||||
|
||||
@@ -63,6 +63,7 @@ const CORE_MENU_ITEMS = new Set([
|
||||
// Built-in node operations (node-specific)
|
||||
'Open Image',
|
||||
'Copy Image',
|
||||
'Paste Image',
|
||||
'Save Image',
|
||||
'Open in Mask Editor',
|
||||
'Edit Subgraph Widgets',
|
||||
@@ -241,6 +242,7 @@ const MENU_ORDER: string[] = [
|
||||
'Open in Mask Editor',
|
||||
'Open Image',
|
||||
'Copy Image',
|
||||
'Paste Image',
|
||||
'Save Image',
|
||||
'Copy (Clipspace)',
|
||||
'Paste (Clipspace)',
|
||||
|
||||
164
src/composables/graph/useImageMenuOptions.test.ts
Normal file
164
src/composables/graph/useImageMenuOptions.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key.split('.').pop() ?? key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
const img = new Image()
|
||||
img.src = 'http://localhost/test.png'
|
||||
Object.defineProperty(img, 'naturalWidth', { value: 100 })
|
||||
Object.defineProperty(img, 'naturalHeight', { value: 100 })
|
||||
|
||||
return createMockLGraphNode({
|
||||
imgs: [img],
|
||||
imageIndex: 0,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
it('includes Paste Image option when node supports paste', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
expect(labels).toContain('Paste Image')
|
||||
})
|
||||
|
||||
it('excludes Paste Image option when node does not support paste', () => {
|
||||
const node = createImageNode({ pasteFiles: undefined })
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
expect(labels).not.toContain('Paste Image')
|
||||
})
|
||||
|
||||
it('returns empty array when node has no images and no pasteFiles', () => {
|
||||
const node = createMockLGraphNode({ imgs: [] })
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
|
||||
expect(getImageMenuOptions(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only Paste Image when node has no images but supports paste', () => {
|
||||
const node = createMockLGraphNode({
|
||||
imgs: [],
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
expect(labels).toEqual(['Paste Image'])
|
||||
})
|
||||
|
||||
it('places Paste Image between Copy Image and Save Image', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
const copyIdx = labels.indexOf('Copy Image')
|
||||
const pasteIdx = labels.indexOf('Paste Image')
|
||||
const saveIdx = labels.indexOf('Save Image')
|
||||
|
||||
expect(copyIdx).toBeLessThan(pasteIdx)
|
||||
expect(pasteIdx).toBeLessThan(saveIdx)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteImage action', () => {
|
||||
it('reads clipboard and calls pasteFiles on the node', async () => {
|
||||
const node = createImageNode()
|
||||
const mockBlob = new Blob(['fake'], { type: 'image/png' })
|
||||
const mockClipboardItem = {
|
||||
types: ['image/png'],
|
||||
getType: vi.fn().mockResolvedValue(mockBlob)
|
||||
}
|
||||
|
||||
mockClipboard({
|
||||
read: vi.fn().mockResolvedValue([mockClipboardItem])
|
||||
} as unknown as Clipboard)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
await pasteOption!.action!()
|
||||
|
||||
expect(navigator.clipboard.read).toHaveBeenCalled()
|
||||
expect(node.pasteFile).toHaveBeenCalledWith(expect.any(File))
|
||||
expect(node.pasteFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.any(File)])
|
||||
)
|
||||
})
|
||||
|
||||
it('handles missing clipboard API gracefully', async () => {
|
||||
const node = createImageNode()
|
||||
mockClipboard({ read: undefined } as unknown as Clipboard)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
await expect(pasteOption!.action!()).resolves.toBeUndefined()
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles clipboard with no image data gracefully', async () => {
|
||||
const node = createImageNode()
|
||||
const mockClipboardItem = {
|
||||
types: ['text/plain'],
|
||||
getType: vi.fn()
|
||||
}
|
||||
|
||||
mockClipboard({
|
||||
read: vi.fn().mockResolvedValue([mockClipboardItem])
|
||||
} as unknown as Clipboard)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
await pasteOption!.action!()
|
||||
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,36 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
function canPasteImage(node?: LGraphNode): boolean {
|
||||
return typeof node?.pasteFiles === 'function'
|
||||
}
|
||||
|
||||
async function pasteClipboardImageToNode(node: LGraphNode): Promise<void> {
|
||||
if (!navigator.clipboard?.read) {
|
||||
console.warn('Clipboard API not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read()
|
||||
for (const item of clipboardItems) {
|
||||
const imageType = item.types.find((type) => type.startsWith('image/'))
|
||||
if (!imageType) continue
|
||||
|
||||
const blob = await item.getType(imageType)
|
||||
const ext = imageType.split('/')[1] ?? 'png'
|
||||
const file = new File([blob], `pasted-image.${ext}`, {
|
||||
type: imageType
|
||||
})
|
||||
node.pasteFile?.(file)
|
||||
node.pasteFiles?.([file])
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to paste image from clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for image-related menu operations
|
||||
*/
|
||||
@@ -78,29 +108,48 @@ export function useImageMenuOptions() {
|
||||
}
|
||||
|
||||
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
|
||||
if (!node?.imgs?.length) return []
|
||||
const hasImages = !!node?.imgs?.length
|
||||
const canPaste = canPasteImage(node)
|
||||
if (!hasImages && !canPaste) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Open in Mask Editor'),
|
||||
action: () => openMaskEditor()
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Open Image'),
|
||||
icon: 'icon-[lucide--external-link]',
|
||||
action: () => openImage(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Copy Image'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
action: () => copyImage(node)
|
||||
},
|
||||
{
|
||||
const options: MenuOption[] = []
|
||||
|
||||
if (hasImages) {
|
||||
options.push(
|
||||
{
|
||||
label: t('contextMenu.Open in Mask Editor'),
|
||||
action: () => openMaskEditor()
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Open Image'),
|
||||
icon: 'icon-[lucide--external-link]',
|
||||
action: () => openImage(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Copy Image'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
action: () => copyImage(node)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (canPaste) {
|
||||
options.push({
|
||||
label: t('contextMenu.Paste Image'),
|
||||
icon: 'icon-[lucide--clipboard-paste]',
|
||||
action: () => pasteClipboardImageToNode(node)
|
||||
})
|
||||
}
|
||||
|
||||
if (hasImages) {
|
||||
options.push({
|
||||
label: t('contextMenu.Save Image'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
action: () => saveImage(node)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
107
src/composables/queue/useJobDetailsHover.ts
Normal file
107
src/composables/queue/useJobDetailsHover.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { JobGroup } from '@/composables/queue/useJobList'
|
||||
|
||||
const DETAILS_SHOW_DELAY_MS = 200
|
||||
const DETAILS_HIDE_DELAY_MS = 150
|
||||
|
||||
interface UseJobDetailsHoverOptions<TActive> {
|
||||
getActiveId: (active: TActive) => string
|
||||
getDisplayedJobGroups: () => JobGroup[]
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
export function useJobDetailsHover<TActive>({
|
||||
getActiveId,
|
||||
getDisplayedJobGroups,
|
||||
onReset
|
||||
}: UseJobDetailsHoverOptions<TActive>) {
|
||||
const activeDetails = ref<TActive | null>(null)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const hideTimerJobId = ref<string | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
|
||||
function clearHideTimer() {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
hideTimerJobId.value = null
|
||||
}
|
||||
|
||||
function clearShowTimer() {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearHoverTimers() {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
}
|
||||
|
||||
function resetActiveDetails() {
|
||||
clearHoverTimers()
|
||||
activeDetails.value = null
|
||||
onReset?.()
|
||||
}
|
||||
|
||||
function hasDisplayedJob(jobId: string) {
|
||||
return getDisplayedJobGroups().some((group) =>
|
||||
group.items.some((item) => item.id === jobId)
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleDetailsShow(nextActive: TActive, onShow?: () => void) {
|
||||
const nextActiveId = getActiveId(nextActive)
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
showTimer.value = null
|
||||
if (!hasDisplayedJob(nextActiveId)) return
|
||||
|
||||
activeDetails.value = nextActive
|
||||
onShow?.()
|
||||
}, DETAILS_SHOW_DELAY_MS)
|
||||
}
|
||||
|
||||
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
|
||||
if (!jobId) return
|
||||
|
||||
clearShowTimer()
|
||||
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
|
||||
return
|
||||
}
|
||||
|
||||
clearHideTimer()
|
||||
hideTimerJobId.value = jobId
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
const currentActive = activeDetails.value
|
||||
if (currentActive && getActiveId(currentActive) === jobId) {
|
||||
activeDetails.value = null
|
||||
onHide?.()
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
}, DETAILS_HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
watch(getDisplayedJobGroups, () => {
|
||||
const currentActive = activeDetails.value
|
||||
if (!currentActive) return
|
||||
|
||||
if (!hasDisplayedJob(getActiveId(currentActive))) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
|
||||
return {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
resetActiveDetails,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
}
|
||||
}
|
||||
108
src/composables/useDismissableOverlay.test.ts
Normal file
108
src/composables/useDismissableOverlay.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { EffectScope, Ref } from 'vue'
|
||||
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
|
||||
describe('useDismissableOverlay', () => {
|
||||
let scope: EffectScope | undefined
|
||||
let isOpen: Ref<boolean>
|
||||
let overlayEl: HTMLElement
|
||||
let triggerEl: HTMLElement
|
||||
let outsideEl: HTMLElement
|
||||
let dismissCount: number
|
||||
|
||||
const mountComposable = ({
|
||||
dismissOnScroll = false,
|
||||
getTriggerEl
|
||||
}: {
|
||||
dismissOnScroll?: boolean
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
} = {}) => {
|
||||
scope = effectScope()
|
||||
scope.run(() =>
|
||||
useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl: () => overlayEl,
|
||||
getTriggerEl,
|
||||
onDismiss: () => {
|
||||
dismissCount += 1
|
||||
},
|
||||
dismissOnScroll
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isOpen = ref(true)
|
||||
overlayEl = document.createElement('div')
|
||||
triggerEl = document.createElement('button')
|
||||
outsideEl = document.createElement('div')
|
||||
dismissCount = 0
|
||||
document.body.append(overlayEl, triggerEl, outsideEl)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('dismisses on outside pointerdown', () => {
|
||||
mountComposable()
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the overlay', () => {
|
||||
mountComposable()
|
||||
|
||||
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the trigger', () => {
|
||||
mountComposable({
|
||||
getTriggerEl: () => triggerEl
|
||||
})
|
||||
|
||||
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('dismisses on scroll when enabled', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores scroll inside the overlay', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
overlayEl.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not dismiss when closed', () => {
|
||||
isOpen.value = false
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
})
|
||||
60
src/composables/useDismissableOverlay.ts
Normal file
60
src/composables/useDismissableOverlay.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface UseDismissableOverlayOptions {
|
||||
isOpen: MaybeRefOrGetter<boolean>
|
||||
getOverlayEl: () => HTMLElement | null
|
||||
onDismiss: () => void
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
dismissOnScroll?: boolean
|
||||
}
|
||||
|
||||
const isNode = (value: EventTarget | null | undefined): value is Node =>
|
||||
value instanceof Node
|
||||
|
||||
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
|
||||
!!element?.contains(target)
|
||||
|
||||
export function useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl,
|
||||
onDismiss,
|
||||
getTriggerEl,
|
||||
dismissOnScroll = false
|
||||
}: UseDismissableOverlayOptions) {
|
||||
const dismissIfOutside = (event: Event) => {
|
||||
if (!toValue(isOpen)) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlay = getOverlayEl()
|
||||
if (!overlay) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isNode(event.target)) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isInside(event.target, overlay) ||
|
||||
isInside(event.target, getTriggerEl?.())
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
|
||||
|
||||
if (dismissOnScroll) {
|
||||
useEventListener(window, 'scroll', dismissIfOutside, {
|
||||
capture: true,
|
||||
passive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
type ResizeDirection =
|
||||
| 'top'
|
||||
@@ -558,10 +558,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
|
||||
const initialize = () => {
|
||||
if (nodeId != null) {
|
||||
node.value =
|
||||
app.canvas?.graph?.getNodeById(nodeId) ||
|
||||
app.rootGraph?.getNodeById(nodeId) ||
|
||||
null
|
||||
node.value = resolveNode(nodeId) ?? null
|
||||
}
|
||||
|
||||
updateImageUrl()
|
||||
|
||||
@@ -35,7 +35,8 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
removeEventListener: vi.fn(),
|
||||
getServerFeature: vi.fn(() => false)
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraConfig,
|
||||
@@ -514,19 +515,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
|
||||
if (load3d) {
|
||||
if (load3d && api.getServerFeature('assets', false)) {
|
||||
const node = nodeRef.value
|
||||
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value === 'string') {
|
||||
void Load3dUtils.generateThumbnailIfNeeded(
|
||||
load3d,
|
||||
value,
|
||||
isPreview.value ? 'output' : 'input'
|
||||
)
|
||||
if (typeof value === 'string' && value) {
|
||||
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
||||
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(modelName, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue'
|
||||
import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue'
|
||||
import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-missing-nodes'
|
||||
|
||||
export function useMissingNodesDialog() {
|
||||
const { showSmallLayoutDialog } = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(props: ComponentAttrs<typeof MissingNodesContent>) {
|
||||
showSmallLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
headerComponent: MissingNodesHeader,
|
||||
footerComponent: MissingNodesFooter,
|
||||
component: MissingNodesContent,
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
return { show, hide }
|
||||
}
|
||||
@@ -20,6 +20,25 @@ import {
|
||||
type UpDirection
|
||||
} from './interfaces'
|
||||
|
||||
function positionThumbnailCamera(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
model: THREE.Object3D
|
||||
) {
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const distance = maxDim * 1.5
|
||||
|
||||
camera.position.set(
|
||||
center.x + distance * 0.7,
|
||||
center.y + distance * 0.5,
|
||||
center.z + distance * 0.7
|
||||
)
|
||||
camera.lookAt(center)
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
@@ -781,25 +800,18 @@ class Load3d {
|
||||
this.cameraManager.toggleCamera('perspective')
|
||||
}
|
||||
|
||||
const box = new THREE.Box3().setFromObject(this.modelManager.currentModel)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const distance = maxDim * 1.5
|
||||
|
||||
const cameraPosition = new THREE.Vector3(
|
||||
center.x - distance * 0.8,
|
||||
center.y + distance * 0.4,
|
||||
center.z + distance * 0.3
|
||||
positionThumbnailCamera(
|
||||
this.cameraManager.perspectiveCamera,
|
||||
this.modelManager.currentModel
|
||||
)
|
||||
|
||||
this.cameraManager.perspectiveCamera.position.copy(cameraPosition)
|
||||
this.cameraManager.perspectiveCamera.lookAt(center)
|
||||
this.cameraManager.perspectiveCamera.updateProjectionMatrix()
|
||||
|
||||
if (this.controlsManager.controls) {
|
||||
this.controlsManager.controls.target.copy(center)
|
||||
const box = new THREE.Box3().setFromObject(
|
||||
this.modelManager.currentModel
|
||||
)
|
||||
this.controlsManager.controls.target.copy(
|
||||
box.getCenter(new THREE.Vector3())
|
||||
)
|
||||
this.controlsManager.controls.update()
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user