mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-24 14:27:32 +00:00
Compare commits
126 Commits
v1.42.6
...
test/defau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790ecb9dbf | ||
|
|
4bfc730a0e | ||
|
|
001916edf6 | ||
|
|
66daa6d645 | ||
|
|
32a53eeaee | ||
|
|
7c6ab19484 | ||
|
|
8f9fe3b21e | ||
|
|
4411d9a417 | ||
|
|
98d56bdada | ||
|
|
b4bb6d9d13 | ||
|
|
6a9fb4e1d5 | ||
|
|
2838edbb53 | ||
|
|
657ae6a6c3 | ||
|
|
f37b0daa3a | ||
|
|
debbdfd1f7 | ||
|
|
9a5ee462f1 | ||
|
|
cff6a2255c | ||
|
|
860d049487 | ||
|
|
2d88720d80 | ||
|
|
818db0d49e | ||
|
|
cd45efa983 | ||
|
|
bd322314bc | ||
|
|
b33c8c2d30 | ||
|
|
aa407e7cd4 | ||
|
|
a9dce7aa20 | ||
|
|
20738b6349 | ||
|
|
477f9c7631 | ||
|
|
dee494f019 | ||
|
|
39864b67d8 | ||
|
|
32bd570855 | ||
|
|
811d58aef7 | ||
|
|
7d9fa2bfc5 | ||
|
|
2b51babbcd | ||
|
|
cc0ba2d471 | ||
|
|
c90a5402b4 | ||
|
|
7501a3eefc | ||
|
|
3b85227089 | ||
|
|
944f78adf4 | ||
|
|
0106372201 | ||
|
|
e2491fe32e | ||
|
|
8aab2889b7 | ||
|
|
4d57c41fdb | ||
|
|
6dfc7b4306 | ||
|
|
28a91fa8b6 | ||
|
|
4eded7c82b | ||
|
|
57783fffcf | ||
|
|
be6c64c75b | ||
|
|
43ba0a9a14 | ||
|
|
157f0598d4 | ||
|
|
3ed88fbe68 | ||
|
|
77ddda9d3c | ||
|
|
3591579141 | ||
|
|
d17810440f | ||
|
|
6c14802425 | ||
|
|
4ba85dd3ef | ||
|
|
2d44e9d2c0 | ||
|
|
efa1b68c38 | ||
|
|
0af1ffdf1a | ||
|
|
dadffa10ea | ||
|
|
4cf42de4f9 | ||
|
|
e7384ab2da | ||
|
|
190bbf0ac2 | ||
|
|
a3cf6fcde0 | ||
|
|
942938d058 | ||
|
|
af7bc38e31 | ||
|
|
3c9b048974 | ||
|
|
0e5bd539ec | ||
|
|
c2cf8e4fb1 | ||
|
|
5ae526dc39 | ||
|
|
a00e4b6421 | ||
|
|
2e5e04efd5 | ||
|
|
55f1081874 | ||
|
|
f9b0f277bf | ||
|
|
34a77e5016 | ||
|
|
b696b2f2e1 | ||
|
|
15442e7ff8 | ||
|
|
6a8e6ad254 | ||
|
|
a405a992af | ||
|
|
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 | ||
|
|
0c44129bb4 | ||
|
|
513510104c | ||
|
|
f6bc0ff1ee | ||
|
|
8851ab1821 | ||
|
|
06f7e13957 | ||
|
|
e31d98b743 |
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
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ issue_enrichment:
|
||||
enabled: true
|
||||
reviews:
|
||||
high_level_summary: false
|
||||
request_changes_workflow: true
|
||||
auto_review:
|
||||
drafts: true
|
||||
ignore_title_keywords:
|
||||
@@ -13,10 +14,14 @@ reviews:
|
||||
- github-actions
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: warning
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
|
||||
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
|
||||
@@ -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
|
||||
|
||||
3
.github/workflows/release-version-bump.yaml
vendored
3
.github/workflows/release-version-bump.yaml
vendored
@@ -30,6 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -142,7 +143,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
|
||||
|
||||
|
||||
3
.github/workflows/weekly-docs-check.yaml
vendored
3
.github/workflows/weekly-docs-check.yaml
vendored
@@ -18,6 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -28,7 +29,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
|
||||
|
||||
|
||||
@@ -97,6 +97,13 @@
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
|
||||
57
CODEOWNERS
57
CODEOWNERS
@@ -1,61 +1,58 @@
|
||||
# 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
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
export class MaintenanceTaskRunner {
|
||||
class MaintenanceTaskRunner {
|
||||
constructor(readonly task: MaintenanceTask) {}
|
||||
|
||||
private _state?: MaintenanceTaskState
|
||||
|
||||
@@ -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
|
||||
|
||||
169
browser_tests/assets/links/duplicate_links_slot_drift.json
Normal file
169
browser_tests/assets/links/duplicate_links_slot_drift.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"pos": [400, 300],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 120,
|
||||
"lastLinkId": 276,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Slot Drift Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [0, 300, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 300, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 120,
|
||||
"type": "ComfySwitchNode",
|
||||
"title": "Switch (CFG)",
|
||||
"pos": [100, 100],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [257, 271, 276]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "ComfySwitchNode" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 50],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 276 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 350],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 271 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 257,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 271,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 86,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 276,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] },
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
|
||||
"pos": [689.0083557128902, 467.9999999999997],
|
||||
"size": [431.8999938964844, 206.60000610351562],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "text", "2"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [330, 367, 120, 40]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [983, 367, 120, 40]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [510, 166],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["11111111111"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [523, 438],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["22222222222"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [467, 446, 120, 40]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [932, 446, 120, 40]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
|
||||
"pos": [647, 389],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "text"],
|
||||
["2", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 2.0975,
|
||||
"offset": [-581.4780189305006, -356.3000030517576]
|
||||
},
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
407
browser_tests/assets/subgraphs/test-values-input-subgraph.json
Normal file
407
browser_tests/assets/subgraphs/test-values-input-subgraph.json
Normal file
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
|
||||
"revision": 0,
|
||||
"last_node_id": 19,
|
||||
"last_link_id": 24,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 14,
|
||||
"type": "CLIPLoader",
|
||||
"pos": [143.16716182216328, 290.16372862874033],
|
||||
"size": [270, 117.3125],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [21]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPLoader"
|
||||
},
|
||||
"widgets_values": [null, "stable_diffusion", "default"]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1305.1455526601603, 472.17095792625025],
|
||||
"size": [225, 48],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 24
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"pos": [794.198171390827, 452.45433419677147],
|
||||
"size": [225, 172],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "renamed_clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 21
|
||||
},
|
||||
{
|
||||
"label": "renamed_seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"label": "renamed_vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 23
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [24]
|
||||
}
|
||||
],
|
||||
"title": "Input Test Subgraph",
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["12", "seed"],
|
||||
["15", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [155.04048166054417, 773.3816055422594],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [22]
|
||||
}
|
||||
],
|
||||
"title": "Seed Int",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAELoader",
|
||||
"pos": [163.6043676075426, 543.9624492717659],
|
||||
"size": [270, 82.65625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [23]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": ["pixel_space"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[21, 14, 0, 19, 0, "CLIP"],
|
||||
[22, 13, 0, 19, 1, "INT"],
|
||||
[23, 17, 0, 19, 2, "VAE"],
|
||||
[24, 19, 0, 18, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 19,
|
||||
"lastLinkId": 24,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Input Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
358.8694807105848, 439.23932667242485, 123.14453125,
|
||||
99.99999999999994
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [16],
|
||||
"localized_name": "clip",
|
||||
"label": "renamed_clip",
|
||||
"pos": [462.0140119605848, 459.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"localized_name": "seed",
|
||||
"label": "renamed_seed",
|
||||
"pos": [462.0140119605848, 479.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"linkIds": [19],
|
||||
"localized_name": "vae",
|
||||
"label": "renamed_vae",
|
||||
"pos": [462.0140119605848, 499.23932667242485]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [20],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1428.5510580294986, 483.2512895126797]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "KSampler",
|
||||
"pos": [769.2424728654022, 512.726159169824],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 17
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [18]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1208.5510580294986, 469.21581253470083],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 18
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [20]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [681.4596332342014, 243.17567172890932],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"label": "renamed_from_sidepanel",
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [17]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": 15,
|
||||
"origin_slot": 0,
|
||||
"target_id": 12,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": 12,
|
||||
"origin_slot": 0,
|
||||
"target_id": 16,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 15,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 12,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 16,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"origin_id": 16,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6727925600199565,
|
||||
"offset": [446.69747171876463, 99.95078257277316]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -25,12 +25,15 @@ 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'
|
||||
import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
@@ -174,6 +177,7 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly appMode: AppModeHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
public readonly canvasOps: CanvasHelper
|
||||
public readonly nodeOps: NodeOperationsHelper
|
||||
@@ -184,9 +188,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[] = []
|
||||
@@ -217,19 +223,22 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.appMode = new AppModeHelper(this)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
this.settings = new SettingsHelper(page)
|
||||
this.keyboard = new KeyboardHelper(page, this.canvas)
|
||||
this.clipboard = new ClipboardHelper(this.keyboard)
|
||||
this.clipboard = new ClipboardHelper(this.keyboard, page)
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
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() {
|
||||
@@ -281,9 +290,7 @@ export class ComfyPage {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
} = {}) {
|
||||
await this.goto()
|
||||
|
||||
// Mock release endpoint to prevent changelog popups
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
if (mockReleases) {
|
||||
await this.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
@@ -303,12 +310,16 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
if (clearStorage) {
|
||||
// Navigate to a lightweight same-origin endpoint to obtain a page
|
||||
// context for clearing storage without loading the full frontend app.
|
||||
await this.page.goto(`${this.url}/api/users`)
|
||||
await this.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
|
||||
201
browser_tests/fixtures/helpers/AppModeHelper.ts
Normal file
201
browser_tests/fixtures/helpers/AppModeHelper.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class AppModeHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
private get builderToolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Exit builder mode via the footer "Exit app builder" button. */
|
||||
async exitBuilder() {
|
||||
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Inputs" step in the builder toolbar. */
|
||||
async goToInputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Outputs" step in the builder toolbar. */
|
||||
async goToOutputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Preview" step in the builder toolbar. */
|
||||
async goToPreview() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Next" button in the builder footer. */
|
||||
async next() {
|
||||
await this.page.getByRole('button', { name: 'Next' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Back" button in the builder footer. */
|
||||
async back() {
|
||||
await this.page.getByRole('button', { name: 'Back' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Toggle app mode (linear view) on/off. */
|
||||
async toggleAppMode() {
|
||||
await this.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject linearData into the current graph and enter app mode.
|
||||
*
|
||||
* Serializes the graph, injects linearData with the given inputs and
|
||||
* auto-detected output node IDs, then reloads so the appModeStore
|
||||
* picks up the data via its activeWorkflow watcher.
|
||||
*
|
||||
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
||||
*/
|
||||
async enterAppModeWithInputs(inputs: [string, string][]) {
|
||||
await this.page.evaluate(async (inputTuples) => {
|
||||
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))
|
||||
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
}, inputs)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
*/
|
||||
getAppModeWidgetMenu(widgetName: string): Locator {
|
||||
return this.linearWidgets
|
||||
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder input-select
|
||||
* sidebar (IoItem).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getBuilderInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({ hasText: title })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder preview/arrange
|
||||
* sidebar (AppModeWidgetList with builderMode).
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.locator(`[aria-label="${ariaLabel}"]`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,19 @@ export class CanvasHelper {
|
||||
await this.page.mouse.move(10, 10)
|
||||
}
|
||||
|
||||
async isReadOnly(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.state.readOnly
|
||||
})
|
||||
}
|
||||
|
||||
async getOffset(): Promise<Position> {
|
||||
return this.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { x: ds.offset[0], y: ds.offset[1] }
|
||||
})
|
||||
}
|
||||
|
||||
async getScale(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.ds.scale
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from './KeyboardHelper'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(private readonly keyboard: KeyboardHelper) {}
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
private readonly page: Page
|
||||
) {}
|
||||
|
||||
async copy(locator?: Locator | null): Promise<void> {
|
||||
await this.keyboard.ctrlSend('KeyC', locator ?? null)
|
||||
@@ -12,4 +19,44 @@ export class ClipboardHelper {
|
||||
async paste(locator?: Locator | null): Promise<void> {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(
|
||||
@@ -48,19 +49,8 @@ export class DragDropHelper {
|
||||
const filePath = this.assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
|
||||
const getFileType = (fileName: string) => {
|
||||
if (fileName.endsWith('.png')) return 'image/png'
|
||||
if (fileName.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
if (fileName.endsWith('.avif')) return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getFileType(fileName)
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.graph` (the root workflow graph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => window.app!.graph.nodes.length)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
@@ -6,6 +7,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '../utils/litegraphUtils'
|
||||
|
||||
@@ -322,4 +324,93 @@ export class SubgraphHelper {
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async isInSubgraph(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async exitViaBreadcrumb(): Promise<void> {
|
||||
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
if (await parentLink.isVisible()) {
|
||||
await parentLink.click()
|
||||
} else {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph
|
||||
if (!graph) return
|
||||
canvas.setGraph(graph.rootGraph)
|
||||
})
|
||||
}
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.reduce((count, node) => {
|
||||
const proxyWidgets = node.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) return count
|
||||
|
||||
return (
|
||||
count +
|
||||
proxyWidgets.filter(
|
||||
(entry) =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[1] === 'string' &&
|
||||
entry[1].startsWith('$$')
|
||||
).length
|
||||
)
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
13
browser_tests/fixtures/helpers/mimeTypeUtil.ts
Normal file
13
browser_tests/fixtures/helpers/mimeTypeUtil.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function getMimeType(fileName: string): string {
|
||||
const name = fileName.toLowerCase()
|
||||
if (name.endsWith('.png')) return 'image/png'
|
||||
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
|
||||
if (name.endsWith('.webp')) return 'image/webp'
|
||||
if (name.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
@@ -27,9 +27,19 @@ export const TestIds = {
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
missingNodes: 'missing-nodes-warning',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
},
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
@@ -57,6 +67,11 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
},
|
||||
@@ -66,6 +81,10 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -77,15 +96,18 @@ export type TestIdValue =
|
||||
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
|
||||
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
|
||||
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
|
||||
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
|
||||
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -281,6 +281,14 @@ export class NodeReference {
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async centerOnNode(): Promise<void> {
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found`)
|
||||
window.app!.canvas.centerOnNode(node)
|
||||
}, this.id)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
|
||||
@@ -29,7 +29,8 @@ export const webSocketFixture = base.extend<{
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.toString())
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
export interface PromotedWidgetSnapshot {
|
||||
proxyWidgets: PromotedWidgetEntry[]
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
@@ -32,6 +37,28 @@ export async function getPromotedWidgets(
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetSnapshot> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return {
|
||||
proxyWidgets: node?.properties?.proxyWidgets ?? [],
|
||||
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
|
||||
}
|
||||
}, nodeId)
|
||||
|
||||
return {
|
||||
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
|
||||
widgetNames: Array.isArray(raw.widgetNames)
|
||||
? raw.widgetNames.filter(
|
||||
(name): name is string => typeof name === 'string'
|
||||
)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -48,6 +75,26 @@ export async function getPromotedWidgetCount(
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
return entry[1].startsWith('$$')
|
||||
}
|
||||
|
||||
export async function getPseudoPreviewWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter(isPseudoPreviewEntry)
|
||||
}
|
||||
|
||||
export async function getNonPreviewPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import { isSubgraph } from '../../src/utils/typeGuardUtil'
|
||||
|
||||
@@ -14,3 +16,30 @@ export function assertSubgraph(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget-input slot Y position and the node title height
|
||||
* for the promoted "text" input on the SubgraphNode.
|
||||
*
|
||||
* The slot Y should be at the widget row, not the header. A value near
|
||||
* zero or negative indicates the slot is positioned at the header (the bug).
|
||||
*/
|
||||
export function getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
* All widgets from the default graph are selected so the panel scrolls,
|
||||
* pushing the last widget's dropdown to the clipping boundary.
|
||||
*/
|
||||
const DEFAULT_INPUTS: [string, string][] = [
|
||||
['4', 'ckpt_name'],
|
||||
['6', 'text'],
|
||||
['7', 'text'],
|
||||
['5', 'width'],
|
||||
['5', 'height'],
|
||||
['5', 'batch_size'],
|
||||
['3', 'seed'],
|
||||
['3', 'steps'],
|
||||
['3', 'cfg'],
|
||||
['3', 'sampler_name'],
|
||||
['3', 'scheduler'],
|
||||
['3', 'denoise'],
|
||||
['9', 'filename_prefix']
|
||||
]
|
||||
|
||||
function isClippedByAnyAncestor(el: Element): boolean {
|
||||
const child = el.getBoundingClientRect()
|
||||
let parent = el.parentElement
|
||||
|
||||
while (parent) {
|
||||
const overflow = getComputedStyle(parent).overflow
|
||||
if (overflow !== 'visible') {
|
||||
const p = parent.getBoundingClientRect()
|
||||
if (
|
||||
child.top < p.top ||
|
||||
child.bottom > p.bottom ||
|
||||
child.left < p.left ||
|
||||
child.right > p.right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Add a node to the graph by type and return its ID. */
|
||||
async function addNode(page: Page, nodeType: string): Promise<string> {
|
||||
return page.evaluate((type) => {
|
||||
const node = window.app!.graph.add(
|
||||
window.LiteGraph!.createNode(type, undefined, {})
|
||||
)
|
||||
return String(node!.id)
|
||||
}, nodeType)
|
||||
}
|
||||
|
||||
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Select dropdown is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[saveVideoId, 'codec']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
await codecSelect.click()
|
||||
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[loadImageId, 'image']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
)
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
187
browser_tests/tests/appModeWidgetRename.spec.ts
Normal file
187
browser_tests/tests/appModeWidgetRename.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Go back to inputs step where IoItems are shown
|
||||
await appMode.goToInputs()
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
appMode.linearWidgets.getByText('Builder Input Seed')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToPreview()
|
||||
|
||||
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Preview Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-preview`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from app mode', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Enter app mode from builder
|
||||
await appMode.exitBuilder()
|
||||
await appMode.toggleAppMode()
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const menu = appMode.getAppModeWidgetMenu('seed')
|
||||
await appMode.renameWidget(menu, 'App Mode Seed')
|
||||
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
|
||||
// Verify persistence after save/reload
|
||||
await appMode.toggleAppMode()
|
||||
const workflowName = `${new Date().getTime()} app-mode`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -129,4 +129,74 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(undoCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test(
|
||||
'Copy paste node, image paste onto LoadImage, image paste on empty canvas',
|
||||
{ tag: ['@node'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
|
||||
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clipboard.paste()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(3)
|
||||
|
||||
// Step 2: Paste image onto selected LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
await comfyPage.clipboard.pasteFile(
|
||||
comfyPage.assetPath('image32x32.webp')
|
||||
)
|
||||
await uploadPromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const fileWidget = await loadImageNodes[0].getWidget(0)
|
||||
return fileWidget.getValue()
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toContain('image32x32')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
// Step 3: Click empty canvas area, paste image → creates new LoadImage
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise2 = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
await comfyPage.clipboard.pasteFile(
|
||||
comfyPage.assetPath('image32x32.webp')
|
||||
)
|
||||
await uploadPromise2
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(4)
|
||||
const allLoadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(allLoadImageNodes).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
318
browser_tests/tests/defaultKeybindings.spec.ts
Normal file
318
browser_tests/tests/defaultKeybindings.spec.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
async function pressKeyAndExpectRequest(
|
||||
comfyPage: ComfyPage,
|
||||
key: string,
|
||||
urlPattern: string,
|
||||
method: string = 'POST'
|
||||
) {
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes(urlPattern) && req.method() === method,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await comfyPage.page.keyboard.press(key)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test.describe('Sidebar Toggle Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const sidebarTabs = [
|
||||
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
|
||||
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
|
||||
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
|
||||
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
|
||||
] as const
|
||||
|
||||
for (const { key, tabId, label } of sidebarTabs) {
|
||||
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
|
||||
const selectedButton = comfyPage.page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Canvas View Controls', () => {
|
||||
test("'Alt+=' zooms in", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Equal')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeGreaterThan(initialScale)
|
||||
})
|
||||
|
||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Minus')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test("'.' fits view to nodes", async ({ comfyPage }) => {
|
||||
// Set scale very small so fit-view will zoom back to fit nodes
|
||||
await comfyPage.canvasOps.setScale(0.1)
|
||||
const scaleBefore = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleBefore).toBeCloseTo(0.1, 1)
|
||||
|
||||
// Click canvas to ensure focus is within graph-canvas-container
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.press('Period')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfter = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfter).toBeGreaterThan(scaleBefore)
|
||||
})
|
||||
|
||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||
// Lock first
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node State Toggles', () => {
|
||||
test("'Alt+c' collapses and expands selected nodes", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await node.isCollapsed()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Normal mode is ALWAYS (0)
|
||||
const getMode = () =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
||||
}, node.id)
|
||||
|
||||
expect(await getMode()).toBe(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
// NEVER (2) = muted
|
||||
expect(await getMode()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getMode()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mode and Panel Toggles', () => {
|
||||
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Set up linearData so app mode has something to show
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
// Toggle off with Alt+m
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
|
||||
|
||||
// Toggle on again
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Queue and Execution', () => {
|
||||
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Enter',
|
||||
'/prompt',
|
||||
'POST'
|
||||
)
|
||||
expect(request.url()).toContain('/prompt')
|
||||
})
|
||||
|
||||
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Shift+Enter',
|
||||
'/prompt',
|
||||
'POST'
|
||||
)
|
||||
const body = request.postDataJSON()
|
||||
expect(body.front).toBe(true)
|
||||
})
|
||||
|
||||
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Alt+Enter',
|
||||
'/interrupt',
|
||||
'POST'
|
||||
)
|
||||
expect(request.url()).toContain('/interrupt')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('File Operations', () => {
|
||||
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
|
||||
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
|
||||
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
|
||||
// Detect the file input click via an event listener.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.TestCommand = false
|
||||
const fileInputs =
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
|
||||
for (const input of fileInputs) {
|
||||
input.addEventListener('click', () => {
|
||||
window.TestCommand = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+o')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graph Operations', () => {
|
||||
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBeGreaterThan(1)
|
||||
|
||||
// Select all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After conversion, node count should decrease
|
||||
// (multiple nodes replaced by single subgraph node)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
test("'r' refreshes node definitions", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'KeyR',
|
||||
'/object_info',
|
||||
'GET'
|
||||
)
|
||||
expect(request.url()).toContain('/object_info')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,63 +9,118 @@ 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 = errorOverlay.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 = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.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
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.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,11 +141,51 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for error overlay and click "See Errors"
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify Find on GitHub button is present in the error card
|
||||
const findOnGithubButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorCardFindOnGithub
|
||||
)
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -110,10 +205,12 @@ 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/)
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -124,10 +221,12 @@ 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/)
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -138,11 +237,10 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -152,7 +250,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()
|
||||
})
|
||||
})
|
||||
@@ -23,4 +23,85 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
// conversion) caused the wrong link to survive during deduplication.
|
||||
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
|
||||
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
|
||||
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
|
||||
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
|
||||
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(result.cfg85Linked).toBe(true)
|
||||
expect(result.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(result.cfg85LinkValid).toBe(true)
|
||||
expect(result.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(result.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(result.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.describe('Restore all open workflows on reload', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Restore workflow tabs after browser restart', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
workflowA = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
workflowB = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage fallback pointers to be written
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Simulate browser restart: clear sessionStorage (lost on close)
|
||||
// but keep localStorage (survives browser restart)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
test('Restores topbar workflow tabs after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
// Wait for both restored tabs to render (localStorage fallback is async)
|
||||
await expect(
|
||||
comfyPage.page.locator('.workflow-tabs .workflow-label', {
|
||||
hasText: workflowA
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
test('Restores sidebar workflows after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
const openWorkflows =
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.EnableWorkflowViewRestore',
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
257
browser_tests/tests/keybindingPresets.spec.ts
Normal file
257
browser_tests/tests/keybindingPresets.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
const TEST_PRESET = {
|
||||
name: 'test-preset',
|
||||
newBindings: [
|
||||
{
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
combo: { key: 'a', ctrl: true, shift: true },
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
],
|
||||
unsetBindings: [
|
||||
{
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
combo: { key: 'a', ctrl: true },
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function importPreset(page: Page, preset: typeof TEST_PRESET) {
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
await menuButton.click()
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('menuitem', { name: /Import preset/i }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
|
||||
const presetPath = path.join(os.tmpdir(), 'test-preset.json')
|
||||
fs.writeFileSync(presetPath, JSON.stringify(preset))
|
||||
await fileChooser.setFiles(presetPath)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.request.fetch(
|
||||
`${comfyPage.url}/api/userdata/keybindings%2Ftest-preset.json`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Keybinding.CurrentPreset',
|
||||
'default'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
test('Can import a preset, use remapped keybinding, and switch back to default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
|
||||
// Verify default Ctrl+A select-all works
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss, then close settings via Escape
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Load workflow again, use new keybind Ctrl+Shift+A
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvas.press('Control+Shift+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to default preset
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await presetTrigger.click()
|
||||
await page.getByRole('option', { name: /Default Preset/i }).click()
|
||||
|
||||
// Handle unsaved changes dialog if the preset was marked as modified
|
||||
const discardButton = page.getByRole('button', {
|
||||
name: /Discard and Switch/i
|
||||
})
|
||||
if (await discardButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await discardButton.click()
|
||||
}
|
||||
|
||||
await expect(presetTrigger).toContainText('Default Preset')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
})
|
||||
|
||||
test('Can export a preset and re-import it', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Export via ellipsis menu
|
||||
await menuButton.click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('menuitem', { name: /Export preset/i }).click()
|
||||
const download = await downloadPromise
|
||||
|
||||
// Verify filename contains test-preset
|
||||
expect(download.suggestedFilename()).toContain('test-preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Verify the downloaded file is valid JSON with correct structure
|
||||
const downloadPath = await download.path()
|
||||
expect(downloadPath).toBeTruthy()
|
||||
const content = fs.readFileSync(downloadPath!, 'utf-8')
|
||||
const parsed = JSON.parse(content) as {
|
||||
name: string
|
||||
newBindings: unknown[]
|
||||
unsetBindings: unknown[]
|
||||
}
|
||||
expect(parsed).toHaveProperty('name')
|
||||
expect(parsed).toHaveProperty('newBindings')
|
||||
expect(parsed).toHaveProperty('unsetBindings')
|
||||
expect(parsed.name).toBe('test-preset')
|
||||
})
|
||||
|
||||
test('Can delete an imported preset', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Delete via ellipsis menu
|
||||
await menuButton.click()
|
||||
await page.getByRole('menuitem', { name: /Delete preset/i }).click()
|
||||
|
||||
// Confirm deletion in the dialog
|
||||
const confirmDialog = page.getByRole('dialog', {
|
||||
name: /Delete the current preset/i
|
||||
})
|
||||
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
|
||||
|
||||
// Verify preset trigger now shows Default Preset
|
||||
await expect(presetTrigger).toContainText('Default Preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
})
|
||||
|
||||
test('Can save modifications as a new preset', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Save as new preset via ellipsis menu
|
||||
await menuButton.click()
|
||||
await page.getByRole('menuitem', { name: /Save as new preset/i }).click()
|
||||
|
||||
// Fill in the preset name in the prompt dialog
|
||||
const promptInput = page.locator('.prompt-dialog-content input')
|
||||
await promptInput.fill('my-custom-preset')
|
||||
await promptInput.press('Enter')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Verify preset trigger shows my-custom-preset
|
||||
await expect(presetTrigger).toContainText('my-custom-preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Cleanup: delete the my-custom-preset file
|
||||
await comfyPage.request.fetch(
|
||||
`${comfyPage.url}/api/userdata/keybindings%2Fmy-custom-preset.json`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
})
|
||||
})
|
||||
61
browser_tests/tests/linearMode.spec.ts
Normal file
61
browser_tests/tests/linearMode.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
})
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
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 comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(comfyPage.canvas).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,20 @@ test.describe(
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const minimapButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(minimapButton).toBeVisible()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-graph-canvas-toolbar.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -79,6 +79,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
'Can auto link batch moved node',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
|
||||
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
|
||||
|
||||
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
70
browser_tests/tests/resultGallery.spec.ts
Normal file
70
browser_tests/tests/resultGallery.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function runAndOpenGallery(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
// Wait for SaveImage node to produce output
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
// Open Assets sidebar tab and wait for it to load
|
||||
await comfyPage.page.locator('.assets-tab-button').click()
|
||||
await comfyPage.page
|
||||
.locator('.sidebar-content-container')
|
||||
.waitFor({ state: 'visible' })
|
||||
|
||||
// Wait for any asset card to appear (may contain img or video)
|
||||
const assetCard = comfyPage.page
|
||||
.locator('[role="button"]')
|
||||
.filter({ has: comfyPage.page.locator('img, video') })
|
||||
.first()
|
||||
|
||||
await expect(assetCard).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// Hover to reveal zoom button, then click it
|
||||
await assetCard.hover()
|
||||
await assetCard.getByLabel('Zoom in').click()
|
||||
|
||||
const gallery = comfyPage.page.getByRole('dialog')
|
||||
await expect(gallery).toBeVisible()
|
||||
|
||||
return { gallery }
|
||||
}
|
||||
|
||||
test('opens gallery and shows dialog with close button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { gallery } = await runAndOpenGallery(comfyPage)
|
||||
await expect(gallery.getByLabel('Close')).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes gallery on Escape key', async ({ comfyPage }) => {
|
||||
await runAndOpenGallery(comfyPage)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes gallery when clicking close button', async ({ comfyPage }) => {
|
||||
const { gallery } = await runAndOpenGallery(comfyPage)
|
||||
|
||||
await gallery.getByLabel('Close').click()
|
||||
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -123,6 +123,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
})
|
||||
|
||||
test('Can pin and unpin', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick,
|
||||
button: 'right'
|
||||
|
||||
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 }) => {
|
||||
@@ -231,26 +232,33 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual('workflow1')
|
||||
})
|
||||
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
test('Reports missing nodes warning again when switching back to workflow', 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 reappear
|
||||
// so users can install missing node packs without a page reload
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
).not.toBeVisible()
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
|
||||
@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
86
browser_tests/tests/subgraph-promoted-slot-position.spec.ts
Normal file
86
browser_tests/tests/subgraph-promoted-slot-position.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
57
browser_tests/tests/subgraph-promoted-widget-dom.spec.ts
Normal file
57
browser_tests/tests/subgraph-promoted-widget-dom.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
|
||||
// Widget top should be below the header bottom
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -631,6 +632,51 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
@@ -744,11 +790,9 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
// In the subgraph navigation breadcrumbs, the home/top level
|
||||
// breadcrumb is just the workflow name without the folder path
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
117
browser_tests/tests/subgraphInputSlotRename.spec.ts
Normal file
117
browser_tests/tests/subgraphInputSlotRename.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph input slot rename propagation',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
const headerBox = await sgNode
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
const headerAfter = await sgNodeAfter
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetAfter = await seedWidgetAfter.boundingBox()
|
||||
expect(headerAfter).not.toBeNull()
|
||||
expect(widgetAfter).not.toBeNull()
|
||||
expect(widgetAfter!.y).toBeGreaterThan(
|
||||
headerAfter!.y + headerAfter!.height
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
347
browser_tests/tests/subgraphLifecycle.spec.ts
Normal file
347
browser_tests/tests/subgraphLifecycle.spec.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Lifecycle Edge Behaviors',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
const serialized1 = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized1 as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
const serialized2 = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized2 as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const hostNode = window.app!.canvas.graph!.getNodeById('11')
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return {
|
||||
proxyWidgetCount: Array.isArray(proxyWidgets)
|
||||
? proxyWidgets.length
|
||||
: 0,
|
||||
firstWidgetType: hostNode?.widgets?.[0]?.type
|
||||
}
|
||||
})
|
||||
})
|
||||
.toEqual({
|
||||
proxyWidgetCount: initialWidgets.length,
|
||||
firstWidgetType: 'button'
|
||||
})
|
||||
})
|
||||
|
||||
test('Promoted widget disappears from DOM after interior node deletion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Non-preview widgets coexist with pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
|
||||
comfyPage,
|
||||
'5'
|
||||
)
|
||||
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.filter((n) => n.isSubgraphNode()).length
|
||||
})
|
||||
expect(subgraphNodeCount).toBe(0)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('5')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
|
||||
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
|
||||
expect(firstNodeBefore.length).toBeGreaterThan(0)
|
||||
expect(secondNodeBefore.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('7')
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.graph!.getNodeById('7')
|
||||
})
|
||||
expect(firstNodeExists).toBe(false)
|
||||
|
||||
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
expect(secondNodeAfter).toEqual(secondNodeBefore)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
110
browser_tests/tests/subgraphNestedConfigureOrder.spec.ts
Normal file
110
browser_tests/tests/subgraphNestedConfigureOrder.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const warnings: string[] = []
|
||||
comfyPage.page.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (
|
||||
text.includes('No link found') ||
|
||||
text.includes('Failed to resolve legacy -1') ||
|
||||
text.includes('No inner link found')
|
||||
) {
|
||||
warnings.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
141
browser_tests/tests/subgraphNestedDuplicateWidgetNames.spec.ts
Normal file
141
browser_tests/tests/subgraphNestedDuplicateWidgetNames.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
expect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
|
||||
expect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
expect(values).toContain('11111111111')
|
||||
expect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await expect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -73,5 +73,59 @@ test.describe(
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return subgraphNode ? String(subgraphNode.id) : null
|
||||
})
|
||||
expect(subgraphNodeId).not.toBeNull()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId!)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
|
||||
subgraphNodeId!
|
||||
)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
@@ -12,30 +11,14 @@ import {
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Check whether we're currently in a subgraph.
|
||||
*/
|
||||
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
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 +69,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()
|
||||
@@ -179,7 +170,7 @@ test.describe(
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||
@@ -251,7 +242,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
@@ -285,7 +276,7 @@ test.describe(
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -331,7 +322,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -366,7 +357,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -397,7 +388,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -478,10 +469,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()
|
||||
@@ -545,6 +544,30 @@ test.describe(
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
}, serialized as ComfyWorkflowJSON)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -702,6 +725,44 @@ test.describe(
|
||||
expect(nodeExists).toBe(false)
|
||||
})
|
||||
|
||||
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(initialNames.length).toBeGreaterThan(0)
|
||||
|
||||
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await outerSubgraph.navigateIntoSubgraph()
|
||||
|
||||
const removedSlotName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
return graph.inputs?.[0]?.name ?? null
|
||||
})
|
||||
expect(removedSlotName).not.toBeNull()
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
const expectedNames = [...initialNames]
|
||||
const removedIndex = expectedNames.indexOf(removedSlotName!)
|
||||
expect(removedIndex).toBeGreaterThanOrEqual(0)
|
||||
expectedNames.splice(removedIndex, 1)
|
||||
|
||||
expect(finalNames).toEqual(expectedNames)
|
||||
})
|
||||
|
||||
test('Removing I/O slot removes associated promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -724,15 +785,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Widget count should be reduced
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
@@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test(
|
||||
'select components in filter bar render correctly',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for filter bar select components to render
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
|
||||
await expect(sortBySelect).toBeVisible()
|
||||
|
||||
// Screenshot the filter bar containing MultiSelect and SingleSelect
|
||||
const filterBar = sortBySelect.locator(
|
||||
'xpath=ancestor::div[contains(@class, "justify-between")]'
|
||||
)
|
||||
await expect(filterBar).toHaveScreenshot(
|
||||
'template-filter-bar-select-components.png',
|
||||
{
|
||||
mask: [comfyPage.page.locator('.p-toast')]
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'template cards descriptions adjust height dynamically',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
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)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,116 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../fixtures/ComfyPage'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
type NodeGroupCenteringError = {
|
||||
horizontal: number
|
||||
vertical: number
|
||||
}
|
||||
|
||||
type NodeGroupCenteringErrors = {
|
||||
innerGroup: NodeGroupCenteringError
|
||||
outerGroup: NodeGroupCenteringError
|
||||
}
|
||||
|
||||
const LEGACY_VUE_CENTERING_BASELINE: NodeGroupCenteringErrors = {
|
||||
innerGroup: {
|
||||
horizontal: 16.308832840862777,
|
||||
vertical: 17.390899314547084
|
||||
},
|
||||
outerGroup: {
|
||||
horizontal: 20.30164329441476,
|
||||
vertical: 42.196324096481476
|
||||
}
|
||||
} as const
|
||||
|
||||
const CENTERING_TOLERANCE = {
|
||||
innerGroup: 6,
|
||||
outerGroup: 12
|
||||
} as const
|
||||
|
||||
function expectWithinBaseline(
|
||||
actual: number,
|
||||
baseline: number,
|
||||
tolerance: number
|
||||
) {
|
||||
expect(Math.abs(actual - baseline)).toBeLessThan(tolerance)
|
||||
}
|
||||
|
||||
async function getNodeGroupCenteringErrors(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeGroupCenteringErrors> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
type GraphNode = {
|
||||
id: number | string
|
||||
pos: ReadonlyArray<number>
|
||||
}
|
||||
type GraphGroup = {
|
||||
title: string
|
||||
pos: ReadonlyArray<number>
|
||||
size: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
const app = window.app!
|
||||
const node = app.graph.nodes[0] as GraphNode | undefined
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Expected a node in the loaded workflow')
|
||||
}
|
||||
|
||||
const nodeElement = document.querySelector<HTMLElement>(
|
||||
`[data-node-id="${node.id}"]`
|
||||
)
|
||||
|
||||
if (!nodeElement) {
|
||||
throw new Error(`Vue node element not found for node ${node.id}`)
|
||||
}
|
||||
|
||||
const groups = app.graph.groups as GraphGroup[]
|
||||
const innerGroup = groups.find((group) => group.title === 'Inner Group')
|
||||
const outerGroup = groups.find((group) => group.title === 'Outer Group')
|
||||
|
||||
if (!innerGroup || !outerGroup) {
|
||||
throw new Error('Expected both Inner Group and Outer Group in graph')
|
||||
}
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect()
|
||||
|
||||
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
|
||||
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
|
||||
group.pos[0],
|
||||
group.pos[1]
|
||||
])
|
||||
const [groupEndX, groupEndY] = app.canvasPosToClientPos([
|
||||
group.pos[0] + group.size[0],
|
||||
group.pos[1] + group.size[1]
|
||||
])
|
||||
|
||||
const groupLeft = Math.min(groupStartX, groupEndX)
|
||||
const groupRight = Math.max(groupStartX, groupEndX)
|
||||
const groupTop = Math.min(groupStartY, groupEndY)
|
||||
const groupBottom = Math.max(groupStartY, groupEndY)
|
||||
|
||||
const leftGap = nodeRect.left - groupLeft
|
||||
const rightGap = groupRight - nodeRect.right
|
||||
const topGap = nodeRect.top - groupTop
|
||||
const bottomGap = groupBottom - nodeRect.bottom
|
||||
|
||||
return {
|
||||
horizontal: Math.abs(leftGap - rightGap),
|
||||
vertical: Math.abs(topGap - bottomGap)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
innerGroup: getCenteringError(innerGroup),
|
||||
outerGroup: getCenteringError(outerGroup)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -74,4 +181,45 @@ test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
|
||||
const workflowRendererVersion = await comfyPage.page.evaluate(() => {
|
||||
const extra = window.app!.graph.extra as
|
||||
| { workflowRendererVersion?: string }
|
||||
| undefined
|
||||
return extra?.workflowRendererVersion
|
||||
})
|
||||
|
||||
expect(workflowRendererVersion).toMatch(/^Vue/)
|
||||
|
||||
await expect(async () => {
|
||||
const centeringErrors = await getNodeGroupCenteringErrors(comfyPage)
|
||||
|
||||
expectWithinBaseline(
|
||||
centeringErrors.innerGroup.horizontal,
|
||||
LEGACY_VUE_CENTERING_BASELINE.innerGroup.horizontal,
|
||||
CENTERING_TOLERANCE.innerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.innerGroup.vertical,
|
||||
LEGACY_VUE_CENTERING_BASELINE.innerGroup.vertical,
|
||||
CENTERING_TOLERANCE.innerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.outerGroup.horizontal,
|
||||
LEGACY_VUE_CENTERING_BASELINE.outerGroup.horizontal,
|
||||
CENTERING_TOLERANCE.outerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.outerGroup.vertical,
|
||||
LEGACY_VUE_CENTERING_BASELINE.outerGroup.vertical,
|
||||
CENTERING_TOLERANCE.outerGroup
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
|
||||
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
|
||||
|
||||
// The small movement should have selected the node, not dragged it
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('should move node when pointer moves beyond drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move 50px — well beyond the 3px drag threshold
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
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: 52 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user