Compare commits

...

17 Commits

Author SHA1 Message Date
Christian Byrne
39d81834d4 fix: merge main and pass target input name to asset browser (#8596)
- Merge latest main to resolve conflicts
- Fix asset browser filtering: pass target input name (e.g.,
'ckpt_name') instead of PrimitiveNode's widget name ('value')

**Changes:**
- Add `inputNameForBrowser` param to `createAssetWidget`
- Pass `targetInputName` from `PrimitiveNode._createAssetWidget`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8596-fix-merge-main-and-pass-target-input-name-to-asset-browser-2fd6d73d36508112bb17cf5d3fe54687)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: guill <jacob.e.segal@gmail.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-03 16:46:17 -08:00
bymyself
f6172d6466 fix: address CodeRabbit review - add toast notifications and remove todo.md
- Add toast notifications for asset validation errors (surfacing to user)
- Add i18n translations for invalidAsset and invalidFilename errors
- Remove todo.md that was accidentally committed

Amp-Thread-ID: https://ampcode.com/threads/T-019c0c78-3249-72eb-9c45-0db1bf7067d6
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:02:34 -08:00
bymyself
edd2724a12 fix: address CodeRabbit review feedback
- Add Comfy.Assets.UseAssetAPI toggle check (matches useComboWidget behavior)
- Sync existing target widget value to asset widget (fixes placeholder issue)

Amp-Thread-ID: https://ampcode.com/threads/T-019c0839-bbdc-754a-9d3b-151417058ded
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:02:33 -08:00
bymyself
6cedce7ea9 refactor: extract createAssetWidget to shared factory, remove # privates
- Extract createAssetWidget factory to src/platform/assets/utils/
- Refactor useComboWidget.ts to use the shared factory
- Simplify PrimitiveNode to use shared factory
- Convert JS # privates to underscore convention
- Add knip ignore for isAssetWidget (litegraph public API)

Amp-Thread-ID: https://ampcode.com/threads/T-019c0839-bbdc-754a-9d3b-151417058ded
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:02:33 -08:00
bymyself
5f79057389 feat(cloud): add asset widget support for PrimitiveNode model selection
On Comfy Cloud, PrimitiveNode now creates asset widgets (opening Asset Browser)
instead of combo widgets for model-eligible inputs like checkpoints, LoRAs, etc.

- Add cloud asset widget creation in #createWidget() using isAssetBrowserEligible()
- Add #createAssetWidget() helper following useComboWidget.ts pattern
- Add #finalizeWidget() helper to DRY up widget sizing/callback setup
- Pass target node's comfyClass to Asset Browser for correct model filtering

Amp-Thread-ID: https://ampcode.com/threads/T-019c0839-bbdc-754a-9d3b-151417058ded
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:02:32 -08:00
Simula_r
fe7d89d1b1 fix: move WorkspaceAuthGate to LayoutDefault for proper re-login hand… (#8381)
## Summary

- Move WorkspaceAuthGate from App.vue to LayoutDefault.vue so it only
wraps authenticated routes
- Change initialize() to run in onMounted() for proper Vue lifecycle
- Restore immediate: true in cloudRemoteConfig watcher as backup
- Gate now mounts fresh after login, fixing re-login feature flag issue

The root cause was a race condition: after logout + page reload, the
cloudRemoteConfig watcher could be set up after the user already logged
in, missing the isLoggedIn change and never calling /features endpoint.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8381-fix-move-WorkspaceAuthGate-to-LayoutDefault-for-proper-re-login-hand-2f66d73d36508182a3dec09a49214a00)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 18:33:44 -08:00
pythongosssss
e4f43d5cc4 Add color picker widget using native HTML5 input element (#8384)
## Summary

Adds support for the color picker widget when using Litegraph nodes, it
is already supported in Nodes 2.0

## Changes

- **What**: Add custom drawing of color picker widget using HTML 5
native color input element
- This enables us to add a core node using the COLOR type that works on
both legacy and Nodes 2.0

## Screenshots (if applicable)
Chrome Windows:
<img width="743" height="493" alt="image"
src="https://github.com/user-attachments/assets/b2e421e0-3a1e-4b72-8856-ae5e40ac0661"
/>

Firefox Windows:
<img width="606" height="447" alt="image"
src="https://github.com/user-attachments/assets/27db5552-6ba2-4de0-af26-ea1727808b4b"
/>

Nodes 2.0 (unchanged):
<img width="597" height="258" alt="image"
src="https://github.com/user-attachments/assets/8bfcf408-e11b-481e-b78f-208b4db80f05"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8384-Add-color-picker-widget-using-native-HTML5-input-element-2f76d73d365081c69fe2f39f01fff539)
by [Unito](https://www.unito.io)
2026-01-28 17:55:12 -08:00
AustinMroz
d5e9be6a64 Additional linear tweaks (#8375)
- Textareas have a fixed (larger) height. This was requested by design
and I thought I fixed it while back.
  | Before | After |
  | ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/35ecfa42-4812-43b3-9844-4ef1f757ae40"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/8d13e114-6524-4f3e-ae61-0d31d754466c"
/>|
- Since the typeform survey popover doesn't work well on mobile, the
button will instead open the survey in a new tab for mobile users.
- Workaround for the first linear output on local not being
automatically selected for display
- Add the linear mode toggle to the bottom left of mobile layout
<img width="634" height="90" alt="image"
src="https://github.com/user-attachments/assets/571c672c-5913-4dc9-84f9-d16c49b4a587"
/>
- Adds a hamburger menu to the mobile layout providing buttons for
opening workflows, templates, and an additional exit linear option.
- Button takes up a full line of space, so it doesn't provide much space
savings currently. May need some design iteration.
<img width="635" height="225" alt="image"
src="https://github.com/user-attachments/assets/a305e795-db0d-4265-b64b-04326a69216d"
/>

And an unrelated tweak requested by Comfy: when opening templates, the
searchbar is autofocused.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8375-Additional-linear-tweaks-2f66d73d36508172a5e7e716d0cba873)
by [Unito](https://www.unito.io)
2026-01-28 15:22:41 -08:00
Alexander Brown
8aca2ed197 feat: Change the card description to the filename (#8348)
## Summary

No longer duplicates the badge info for the Model type.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8348-feat-Change-the-card-description-to-the-filename-2f66d73d3650818d99e1de479d1f8486)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 14:25:07 -08:00
Benjamin Lu
cbd073f89d Add inline queue progress bar and text summary (#8271)
Add inline queue progress bar and summary per the new designs.

This adds an inline 3px progress bar in the actionbar container (docked
or floating) and a compact summary line below the top menu that follows
when floating, both gated by the QPO V2 flag and hidden while the
overlay is expanded.


https://github.com/user-attachments/assets/da8ec7b7-35f4-4d52-a83b-15c21b484eba

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8271-Add-inline-queue-progress-bar-and-summary-for-QPO-V2-2f16d73d36508132a6dff247f71e11e4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-28 12:20:13 -08:00
Alexander Brown
e44b411ff6 test: simplify test file mocking patterns (#8320)
Simplifies test mocking patterns across multiple test files.

- Removes redundant `vi.hoisted()` calls
- Cleans up mock implementations
- Removes unused imports and variables

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8320-test-simplify-test-file-mocking-patterns-2f46d73d36508150981bd8ecb99a6a11)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 12:17:16 -08:00
Terry Jia
3e2352423b fix: remove redundant forceRender call and add ResizeObserver guard (#8372)
## Summary
- Remove duplicate forceRender() in ResizeObserver callback since
handleResize() already calls it
- Add guard for environments without ResizeObserver support
- Disconnect existing observer before reassigning to prevent leaks

requested by @DrJKL in
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8351

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8372-fix-remove-redundant-forceRender-call-and-add-ResizeObserver-guard-2f66d73d3650811bb3a6de5c59b3c1fb)
by [Unito](https://www.unito.io)
2026-01-28 11:51:40 -08:00
Christian Byrne
3720b3e794 feat: make invalid URL error message more actionable (#8368)
## Summary

Updates the error message shown when users enter an unsupported URL in
the BYOM (Bring Your Own Model) upload dialog.

**Before:** "Only URLs from Civitai, Hugging Face are supported"
**After:** "Please check the link format. Only URLs from Civitai,
Hugging Face are supported."

This provides more actionable guidance by suggesting users verify their
link format before listing the supported sources.

## Changes

- Updated `unsupportedUrlSource` i18n key in `src/locales/en/main.json`

## Testing

- `pnpm typecheck` 
- `pnpm lint` 
- Manual: Enter invalid URL (e.g.,
`https://example.com/model.safetensors`) in model upload dialog

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8368-feat-make-invalid-URL-error-message-more-actionable-2f66d73d3650810bbcc1e9fa3d1cd962)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 02:15:09 -08:00
Terry Jia
26eb3eff4d fix: add ResizeObserver to fix Preview3D initial render stretch (#8351)
## Summary

When Preview3D node was added to canvas, the Three.js scene would
stretch outside the node bounds until mouse hover. This happened because
the container size was not stable during initialization.

Add ResizeObserver to Load3d class to automatically refresh viewport
when container size changes, ensuring correct render dimensions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8351-fix-add-ResizeObserver-to-fix-Preview3D-initial-render-stretch-2f66d73d3650810cbd1fc64dde9ddc17)
by [Unito](https://www.unito.io)
2026-01-28 05:11:54 -05:00
Rizumu Ayaka
dd3e4d3edc fix: hide label of textarea in right side panel + align switch to the left (#8279)
<img width="350" alt="image"
src="https://github.com/user-attachments/assets/3ff3f83c-163b-44df-8b68-7fe18c3266c4"
/>
 | 
<img width="350" alt="CleanShot 2026-01-23 at 21 25 04@2x"
src="https://github.com/user-attachments/assets/c2c630f3-6990-4a55-aa8f-a19297ffee52"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8279-fix-hide-label-of-textarea-in-right-side-panel-align-switch-to-the-left-2f16d73d365081e9b932fb2be873b660)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-27 23:21:03 -08:00
Alexander Brown
8b514463b3 CI: Add formatting after generating locales. (#8360)
## Summary

Hopefully prevent thrashing from formatting disagreements.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8360-CI-Add-formatting-after-generating-locales-2f66d73d36508143a2f6d5ba90056110)
by [Unito](https://www.unito.io)
2026-01-27 22:31:54 -08:00
Comfy Org PR Bot
608ad1d74c 1.39.0 (#8336)
Minor version increment to 1.39.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8336-1-39-0-2f56d73d365081c3a03bd0381a1a7ddc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-01-27 21:56:40 -08:00
627 changed files with 22654 additions and 8654 deletions

View File

@@ -391,7 +391,7 @@ echo "Last stable release: $LAST_STABLE"
```bash
# Trigger the workflow
gh workflow run version-bump.yaml -f version_type=${VERSION_TYPE}
gh workflow run release-version-bump.yaml -f version_type=${VERSION_TYPE}
# Workflow runs quickly - usually creates PR within 30 seconds
echo "Workflow triggered. Waiting for PR creation..."
@@ -443,28 +443,21 @@ echo "Workflow triggered. Waiting for PR creation..."
gh pr view ${PR_NUMBER} --json labels | jq -r '.labels[].name' | grep -q "Release" || \
echo "ERROR: Release label missing! Add it immediately!"
```
2. Check for update-locales commits:
```bash
# WARNING: update-locales may add [skip ci] which blocks release workflow!
gh pr view ${PR_NUMBER} --json commits | grep -q "skip ci" && \
echo "WARNING: [skip ci] detected - release workflow may not trigger!"
```
3. Verify version number in package.json
4. Review all changed files
5. Ensure no unintended changes included
6. Wait for required PR checks:
2. Verify version number in package.json
3. Review all changed files
4. Ensure no unintended changes included
5. Wait for required PR checks:
```bash
gh pr checks ${PR_NUMBER} --watch
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
6. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
3. **CRITICAL WARNING**: If update-locales adds [skip ci], the release workflow won't trigger!
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
2. Monitor CI checks
3. Check no new commits to main since PR creation
4. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release

6
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: true
reviews:
auto_review:
drafts: true

15
.gitattributes vendored
View File

@@ -1,16 +1,5 @@
# Default
* text=auto
# Force TS to LF to make the unixy scripts not break on Windows
*.cjs text eol=lf
*.js text eol=lf
*.json text eol=lf
*.mjs text eol=lf
*.mts text eol=lf
*.snap text eol=lf
*.ts text eol=lf
*.vue text eol=lf
*.yaml text eol=lf
# Force all text files to use LF line endings
* text=auto eol=lf
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true

View File

@@ -10,10 +10,7 @@ body:
options:
- label: I am running the latest version of ComfyUI
required: true
- label: I have searched existing issues to make sure this isn't a duplicate
required: true
- label: I have tested with all custom nodes disabled ([see how](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled))
required: true
- label: I have custom nodes enabled
- type: textarea
id: description

View File

@@ -4,13 +4,6 @@ labels: []
type: Feature
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the problem you're experiencing, and that it's not addressed in a recent build/commit.
options:
- label: I have searched the existing issues and checked the recent builds/commits
required: true
- type: markdown
attributes:
value: |

View File

@@ -104,14 +104,14 @@ runs:
- name: Find existing comment
id: find
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: ${{ steps.build.outputs.marker_search }}
- name: Post or update comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}

View File

@@ -16,7 +16,7 @@ runs:
# Checkout ComfyUI repo, install the dev_tools node and start server
- name: Checkout ComfyUI
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI'
@@ -33,7 +33,7 @@ runs:
fi
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.10'

View File

@@ -12,29 +12,17 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'
# Restore tool caches before running any build/lint operations
- name: Restore tool output cache
uses: actions/cache/restore@v4
with:
path: |
./.cache
./tsconfig.tsbuildinfo
key: tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-${{ hashFiles('./src/**/*.{ts,vue,js,mts}', './*.config.*') }}
restore-keys: |
tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-
tool-cache-${{ runner.os }}-
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile

View File

@@ -11,7 +11,7 @@ runs:
echo "playwright-version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
- name: Cache Playwright Browsers
uses: actions/cache@v4
uses: actions/cache@v5 # v5.0.2
id: cache-playwright-browsers
with:
path: '~/.cache/ms-playwright'

View File

@@ -13,15 +13,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'pnpm'
@@ -36,7 +36,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -18,15 +18,15 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'pnpm'
@@ -35,7 +35,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager
@@ -86,7 +86,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'

View File

@@ -17,15 +17,15 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'pnpm'
@@ -34,7 +34,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: Comfy-Org/comfy-api
path: comfy-api
@@ -87,7 +87,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -13,6 +13,6 @@ jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -18,23 +18,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run ESLint with auto-fix
run: pnpm lint:fix
@@ -73,7 +63,7 @@ jobs:
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({
@@ -86,7 +76,7 @@ jobs:
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'

View File

@@ -17,21 +17,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build project
run: pnpm build
@@ -46,7 +35,7 @@ jobs:
echo ${{ github.base_ref }} > ./temp/size/base.txt
- name: Upload size data
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: size-data
path: temp/size

View File

@@ -31,11 +31,11 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get PR Number
id: pr
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
@@ -68,7 +68,7 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -5,8 +5,8 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
branches-ignore: [wip/*, draft/*, temp/*]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
@@ -25,7 +25,7 @@ jobs:
# Upload only built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: frontend-dist
path: dist/
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -51,9 +51,9 @@ jobs:
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: dist/
@@ -72,7 +72,7 @@ jobs:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
- name: Upload blob report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: blob-report-chromium-${{ matrix.shardIndex }}
@@ -85,7 +85,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -98,9 +98,9 @@ jobs:
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: dist/
@@ -128,7 +128,7 @@ jobs:
pnpm exec playwright merge-reports --reporter=json ./blob-report
- name: Upload Playwright report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report-${{ matrix.browser }}
@@ -141,16 +141,13 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Download blob reports
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: ./all-blob-reports
pattern: blob-report-chromium-*
@@ -165,7 +162,7 @@ jobs:
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: playwright-report-chromium
path: ./playwright-report/
@@ -183,7 +180,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get start time
id: start-time
@@ -210,10 +207,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download all playwright reports
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
pattern: playwright-report-*
path: reports

View File

@@ -31,11 +31,11 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get PR Number
id: pr
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
@@ -68,7 +68,7 @@ jobs:
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Post starting comment
env:
@@ -36,21 +36,10 @@ jobs:
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
@@ -69,7 +58,7 @@ jobs:
- name: Upload Storybook build
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: storybook-static
path: storybook-static/
@@ -86,27 +75,16 @@ jobs:
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for Chromatic baseline
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook and run Chromatic
id: chromatic
uses: chromaui/action@latest
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
@@ -136,11 +114,11 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: storybook-static
path: storybook-static
@@ -170,7 +148,7 @@ jobs:
pull-requests: write
steps:
- name: Update comment with Chromatic URLs
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';

View File

@@ -16,21 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests
run: pnpm test:unit

View File

@@ -0,0 +1,21 @@
name: Validate Action SHA Pins
on:
pull_request:
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.pinact.yaml'
permissions:
contents: read
jobs:
validate-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: suzuki-shunsuke/pinact-action@3d49c6412901042473ffa78becddab1aea46bbea # v1.3.1
with:
skip_push: 'true'

View File

@@ -17,10 +17,10 @@ jobs:
yaml-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.x'

View File

@@ -18,12 +18,12 @@ jobs:
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -16,7 +16,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
token: ${{ secrets.PR_GH_TOKEN }}
# Setup playwright environment
- name: Setup ComfyUI Frontend
@@ -41,7 +43,7 @@ jobs:
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
run: pnpm locale && pnpm format
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
@@ -36,7 +36,7 @@ jobs:
# Install the custom node repository
- name: Checkout custom node repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
@@ -113,7 +113,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4 # v2.7.0
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
@@ -40,7 +40,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'Update locales for node definitions'

View File

@@ -64,7 +64,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -23,18 +23,18 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
@@ -44,7 +44,7 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@v1.0.6
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
with:
label_trigger: 'claude-review'
prompt: |

View File

@@ -33,24 +33,13 @@ jobs:
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download size data
uses: dawidd6/action-download-artifact@v11
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 }}
@@ -75,7 +64,7 @@ jobs:
fi
- name: Download previous size data
uses: dawidd6/action-download-artifact@v11
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: ci-size-data.yaml
@@ -89,12 +78,12 @@ jobs:
- name: Read size report
id: size-report
uses: juliangruber/read-file-action@v1
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./size-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@v3
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}

View File

@@ -38,7 +38,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: 'find-update-comment'
with:
issue-number: ${{ steps.pr-info.outputs.pr-number }}
@@ -46,7 +46,7 @@ jobs:
body-includes: 'Updating Playwright Expectations'
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ steps.pr-info.outputs.pr-number }}
@@ -56,7 +56,7 @@ jobs:
reactions: eyes
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.pr-info.outputs.branch }}
- name: Setup frontend
@@ -66,7 +66,7 @@ jobs:
# Upload built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: frontend-dist
path: dist/
@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -91,11 +91,11 @@ jobs:
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
- name: Download built frontend
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: dist/
@@ -149,7 +149,7 @@ jobs:
# Upload ONLY the changed files from this shard
- name: Upload changed snapshots
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: steps.changed-snapshots.outputs.has-changes == 'true'
with:
name: snapshots-shard-${{ matrix.shardIndex }}
@@ -157,7 +157,7 @@ jobs:
retention-days: 1
- name: Upload test report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report-shard-${{ matrix.shardIndex }}
@@ -170,17 +170,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
# Download all changed snapshot files from shards
- name: Download snapshot artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
pattern: snapshots-shard-*
path: ./downloaded-snapshots
merge-multiple: false
merge-multiple: true
- name: List downloaded files
run: |
@@ -206,13 +207,13 @@ jobs:
echo "MERGING CHANGED SNAPSHOTS"
echo "=========================================="
# Check if any artifacts were downloaded
# Check if any artifacts were downloaded (merge-multiple puts files directly in path)
if [ ! -d "./downloaded-snapshots" ]; then
echo "No snapshot artifacts to merge"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: 0"
echo "Files merged: 0"
exit 0
fi
@@ -222,37 +223,29 @@ jobs:
exit 1
fi
merged_count=0
# Count files to merge
file_count=$(find ./downloaded-snapshots -type f | wc -l)
# For each shard's changed files, copy them directly
for shard_dir in ./downloaded-snapshots/snapshots-shard-*/; do
if [ ! -d "$shard_dir" ]; then
continue
fi
if [ "$file_count" -eq 0 ]; then
echo "No snapshot files found in downloaded artifacts"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Files merged: 0"
exit 0
fi
shard_name=$(basename "$shard_dir")
file_count=$(find "$shard_dir" -type f | wc -l)
echo "Merging $file_count snapshot file(s)..."
if [ "$file_count" -eq 0 ]; then
echo " $shard_name: no files"
continue
fi
echo "Processing $shard_name ($file_count file(s))..."
# Copy files directly, preserving directory structure
# Since files are already in correct structure (no browser_tests/ prefix), just copy them all
cp -v -r "$shard_dir"* browser_tests/ 2>&1 | sed 's/^/ /'
merged_count=$((merged_count + 1))
echo " ✓ Merged"
echo ""
done
# Copy all files directly, preserving directory structure
# With merge-multiple: true, files are directly in ./downloaded-snapshots/ without shard subdirs
cp -v -r ./downloaded-snapshots/* browser_tests/ 2>&1 | sed 's/^/ /'
echo ""
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: $merged_count"
echo "Files merged: $file_count"
- name: Show changes
run: |
@@ -301,7 +294,7 @@ jobs:
echo "✓ Commit and push successful"
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: github.event_name == 'issue_comment' && steps.commit.outputs.has-changes == 'true'
with:
comment-id: ${{ needs.setup.outputs.comment-id }}

View File

@@ -20,13 +20,13 @@ jobs:
dist_tag: ${{ steps.dist.outputs.dist_tag }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '24.x'
@@ -71,7 +71,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2

View File

@@ -77,19 +77,19 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '24.x'
cache: 'pnpm'

View File

@@ -61,13 +61,13 @@ jobs:
steps:
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
path: frontend
- name: Checkout ComfyUI (sparse)
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: Comfy-Org/ComfyUI
sparse-checkout: |
@@ -75,12 +75,12 @@ jobs:
path: comfyui
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
@@ -169,7 +169,7 @@ jobs:
steps:
- name: Checkout ComfyUI fork
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
token: ${{ secrets.PR_GH_TOKEN }}

View File

@@ -18,13 +18,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'

View File

@@ -19,12 +19,12 @@ jobs:
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'
@@ -50,12 +50,13 @@ jobs:
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: dist-files
path: |
@@ -66,16 +67,13 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Create release
id: create_release
uses: >-
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -98,13 +96,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install build dependencies
@@ -119,8 +117,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: >-
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
@@ -147,7 +144,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2

View File

@@ -69,18 +69,18 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'

View File

@@ -15,12 +15,12 @@ jobs:
version: ${{ steps.current_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'
@@ -40,7 +40,7 @@ jobs:
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: dist-files
path: |
@@ -52,13 +52,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install build dependencies
@@ -73,7 +73,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -65,7 +65,7 @@ jobs:
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ github.token }}
script: |
@@ -118,7 +118,7 @@ jobs:
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.prepared-inputs.outputs.branch }}
fetch-depth: 0
@@ -142,12 +142,12 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
@@ -180,7 +180,7 @@ jobs:
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Increment version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
@@ -51,12 +51,12 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '24.x'
cache: 'pnpm'
@@ -79,7 +79,7 @@ jobs:
echo "capitalised=${VERSION_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Increment desktop-ui to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -22,18 +22,18 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-depth: 50
ref: main
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
@@ -49,7 +49,7 @@ jobs:
fi
- name: Run Claude Documentation Review
uses: anthropics/claude-code-action@v1.0.6
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
with:
prompt: |
Is all documentation still 100% accurate?
@@ -130,7 +130,7 @@ jobs:
- name: Create or Update Pull Request
if: steps.check_changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'docs: weekly documentation accuracy update'

View File

@@ -110,6 +110,17 @@
"rules": {
"no-console": "allow"
}
},
{
"files": ["browser_tests/**/*.ts"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error"
}
}
]
}

24
.pinact.yaml Normal file
View File

@@ -0,0 +1,24 @@
# pinact configuration
# https://github.com/suzuki-shunsuke/pinact
version: 3
files:
- pattern: .github/workflows/*.yaml
- pattern: .github/actions/**/*.yaml
# Actions that don't need SHA pinning (official GitHub actions are trusted)
ignore_actions:
- name: actions/cache
ref: v5
- name: actions/checkout
ref: v6
- name: actions/setup-node
ref: v6
- name: actions/setup-python
ref: v6
- name: actions/upload-artifact
ref: v6
- name: actions/download-artifact
ref: v7
- name: actions/github-script
ref: v8

View File

@@ -8,3 +8,6 @@ rules:
line-length: disable
document-start: disable
truthy: disable
comments:
min-spaces-from-content: 1

View File

@@ -21,7 +21,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
- i18n: `src/i18n.ts`,
- Entry Point: `src/main.ts`.
- Tests:
- unit/component in `tests-ui/` and `src/**/*.test.ts`
- unit/component in `src/**/*.test.ts`
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
- Public assets: `public/`
- Build output: `dist/`
@@ -44,7 +44,7 @@ The project uses **Nx** for build orchestration and task management
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm typecheck`: Vue TSC type checking
@@ -264,7 +264,7 @@ A particular type of complexity is over-engineering, where developers have made
## Repository Navigation
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
- Check README files in key folders (browser_tests, composables, etc.)
- Prefer running single tests for performance
- Use --help for unfamiliar CLI tools

View File

@@ -6,3 +6,9 @@ See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded fo
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
- Run `pnpm exec eslint browser_tests/path/to/file.ts` to lint specific files
- Run `pnpm exec oxlint browser_tests/path/to/file.ts` to check with oxlint

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
*/
import type { Locator, Page } from '@playwright/test'
import { TestIds } from './selectors'
import { VueNodeFixture } from './utils/vueNodeFixtures'
export class VueNodeHelpers {
@@ -148,9 +149,9 @@ export class VueNodeHelpers {
* Get a specific widget by node title and widget name
*/
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
return this.getNodeByTitle(nodeTitle).locator(
`_vue=[widget.name="${widgetName}"]`
)
return this.getNodeByTitle(nodeTitle).getByLabel(widgetName, {
exact: true
})
}
/**
@@ -159,8 +160,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
incrementButton: widget.getByTestId(TestIds.widgets.increment)
}
}
@@ -170,7 +171,7 @@ export class VueNodeHelpers {
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId('subgraph-enter-button')
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
await editButton.click()
}
}

View File

@@ -0,0 +1,31 @@
import type { Locator, Page } from '@playwright/test'
export class BaseDialog {
readonly root: Locator
readonly closeButton: Locator
constructor(
public readonly page: Page,
testId?: string
) {
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
this.closeButton = this.root.getByRole('button', { name: 'Close' })
}
async isVisible(): Promise<boolean> {
return this.root.isVisible()
}
async waitForVisible(): Promise<void> {
await this.root.waitFor({ state: 'visible' })
}
async waitForHidden(): Promise<void> {
await this.root.waitFor({ state: 'hidden' })
}
async close(): Promise<void> {
await this.closeButton.click({ force: true })
await this.waitForHidden()
}
}

View File

@@ -0,0 +1,35 @@
import type { Locator, Page } from '@playwright/test'
class ShortcutsTab {
readonly essentialsTab: Locator
readonly viewControlsTab: Locator
readonly manageButton: Locator
readonly keyBadges: Locator
readonly subcategoryTitles: Locator
constructor(readonly page: Page) {
this.essentialsTab = page.getByRole('tab', { name: /Essential/i })
this.viewControlsTab = page.getByRole('tab', { name: /View Controls/i })
this.manageButton = page.getByRole('button', { name: /Manage Shortcuts/i })
this.keyBadges = page.locator('.key-badge')
this.subcategoryTitles = page.locator('.subcategory-title')
}
}
export class BottomPanel {
readonly root: Locator
readonly keyboardShortcutsButton: Locator
readonly toggleButton: Locator
readonly shortcuts: ShortcutsTab
constructor(readonly page: Page) {
this.root = page.locator('.bottom-panel')
this.keyboardShortcutsButton = page.getByRole('button', {
name: /Keyboard Shortcuts/i
})
this.toggleButton = page.getByRole('button', {
name: /Toggle Bottom Panel/i
})
this.shortcuts = new ShortcutsTab(page)
}
}

View File

@@ -30,7 +30,7 @@ export class ComfyNodeSearchFilterSelectionPanel {
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('button:has-text("Add")').click()
await this.page.getByRole('button', { name: 'Add', exact: true }).click()
}
}

View File

@@ -0,0 +1,54 @@
import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
constructor(public readonly page: Page) {}
get primeVueMenu() {
return this.page.locator('.p-contextmenu, .p-menu')
}
get litegraphMenu() {
return this.page.locator('.litemenu')
}
get menuItems() {
return this.page.locator('.p-menuitem, .litemenu-entry')
}
async clickMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
}
async clickLitegraphMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
}
async isVisible(): Promise<boolean> {
const primeVueVisible = await this.primeVueMenu
.isVisible()
.catch(() => false)
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()
if (count > 0) {
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
console.warn(
`[waitForHidden] ${menuName} waitFor failed:`,
error.message
)
})
}
}
await Promise.all([
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
waitIfExists(this.litegraphMenu, 'litegraphMenu')
])
}
}

View File

@@ -1,20 +1,20 @@
import type { Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { BaseDialog } from './BaseDialog'
export class SettingDialog {
export class SettingDialog extends BaseDialog {
constructor(
public readonly page: Page,
page: Page,
public readonly comfyPage: ComfyPage
) {}
get root() {
return this.page.locator('div.settings-container')
) {
super(page, TestIds.dialogs.settings)
}
async open() {
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
await this.page.waitForSelector('div.settings-container')
await this.comfyPage.command.executeCommand('Comfy.ShowSettingsDialog')
await this.waitForVisible()
}
/**
@@ -41,8 +41,9 @@ export class SettingDialog {
}
async goToAboutPanel() {
const aboutButton = this.page.locator('li[aria-label="About"]')
await aboutButton.click()
await this.page.waitForSelector('div.about-container')
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
await this.page
.getByTestId(TestIds.dialogs.about)
.waitFor({ state: 'visible' })
}
}

View File

@@ -1,5 +1,8 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { TestIds } from '../selectors'
class SidebarTab {
constructor(
public readonly page: Page,
@@ -31,16 +34,16 @@ class SidebarTab {
}
export class NodeLibrarySidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
constructor(public override readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.locator('.node-lib-search-box input[type="text"]')
return this.page.getByPlaceholder('Search Nodes...')
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree-explorer')
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
}
get nodePreview() {
@@ -55,12 +58,12 @@ export class NodeLibrarySidebarTab extends SidebarTab {
return this.tabContainer.locator('.new-folder-button')
}
async open() {
override async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async close() {
override async close() {
if (!this.tabButton.isVisible()) {
return
}
@@ -69,30 +72,40 @@ export class NodeLibrarySidebarTab extends SidebarTab {
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
}
getFolder(folderName: string) {
return this.page.locator(this.folderSelector(folderName))
}
nodeSelector(nodeName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
return this.page.locator(
`[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
)
}
getNode(nodeName: string) {
return this.page.locator(this.nodeSelector(nodeName))
return this.page.locator(
`[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`
)
}
nodeSelector(nodeName: string): string {
return `[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`
}
folderSelector(folderName: string): string {
return `[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
}
getNodeInFolder(nodeName: string, folderName: string) {
return this.getFolder(folderName)
.locator('xpath=ancestor::li')
.locator(`[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`)
}
}
export class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
constructor(public override readonly page: Page) {
super(page, 'workflows')
}
get root() {
return this.page.locator('.workflows-sidebar-tab')
return this.page.getByTestId(TestIds.sidebar.workflows)
}
async getOpenedWorkflowNames() {
@@ -140,7 +153,9 @@ export class WorkflowsSidebarTab extends SidebarTab {
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() => !window['app']?.extensionManager?.workflow?.isBusy,
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout: 3000 }
)

View File

@@ -1,5 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
export class Topbar {
private readonly menuLocator: Locator
@@ -57,7 +58,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
}
getSaveDialog(): Locator {
@@ -86,7 +87,7 @@ export class Topbar {
// Wait for workflow service to finish saving
await this.page.waitForFunction(
() => !window['app'].extensionManager.workflow.isBusy,
() => !(window.app!.extensionManager as WorkspaceStore).workflow.isBusy,
undefined,
{ timeout: 3000 }
)
@@ -122,7 +123,7 @@ export class Topbar {
*/
async closeTopbarMenu() {
await this.page.locator('body').click({ position: { x: 300, y: 10 } })
await expect(this.menuLocator).not.toBeVisible()
await this.menuLocator.waitFor({ state: 'hidden' })
}
/**

View File

@@ -0,0 +1,51 @@
import type { Position } from './types'
/**
* Hardcoded positions for the default graph loaded in tests.
* These coordinates are specific to the default workflow viewport.
*/
export const DefaultGraphPositions = {
// Node click positions
textEncodeNode1: { x: 618, y: 191 },
textEncodeNode2: { x: 622, y: 400 },
textEncodeNodeToggler: { x: 430, y: 171 },
emptySpaceClick: { x: 35, y: 31 },
// Slot positions
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
clipTextEncodeNode2InputSlot: { x: 422, y: 402 },
clipTextEncodeNode2InputLinkPath: { x: 395, y: 422 },
loadCheckpointNodeClipOutputSlot: { x: 332, y: 509 },
emptySpace: { x: 427, y: 98 },
// Widget positions
emptyLatentWidgetClick: { x: 724, y: 645 },
// Node positions and sizes for resize operations
ksampler: {
pos: { x: 863, y: 156 },
size: { width: 315, height: 292 }
},
loadCheckpoint: {
pos: { x: 26, y: 444 },
size: { width: 315, height: 127 }
},
emptyLatent: {
pos: { x: 473, y: 579 },
size: { width: 315, height: 136 }
}
} as const satisfies {
textEncodeNode1: Position
textEncodeNode2: Position
textEncodeNodeToggler: Position
emptySpaceClick: Position
clipTextEncodeNode1InputSlot: Position
clipTextEncodeNode2InputSlot: Position
clipTextEncodeNode2InputLinkPath: Position
loadCheckpointNodeClipOutputSlot: Position
emptySpace: Position
emptyLatentWidgetClick: Position
ksampler: { pos: Position; size: { width: number; height: number } }
loadCheckpoint: { pos: Position; size: { width: number; height: number } }
emptyLatent: { pos: Position; size: { width: number; height: number } }
}

View File

@@ -0,0 +1,9 @@
export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -0,0 +1,184 @@
import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position } from '../types'
export class CanvasHelper {
constructor(
private page: Page,
private canvas: Locator,
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
await this.page.mouse.move(10, 10)
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
safeSpot = safeSpot || { x: 10, y: 10 }
await this.page.mouse.move(safeSpot.x, safeSpot.y)
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
safeSpot = safeSpot || { x: 10, y: 10 }
const client = await this.page.context().newCDPSession(this.page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [safeSpot]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: offset.x + safeSpot.x, y: offset.y + safeSpot.y }]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
}
async moveMouseToEmptyArea(): Promise<void> {
await this.page.mouse.move(10, 10)
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale
})
}
async setScale(scale: number): Promise<void> {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await this.nextFrame()
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
return this.page.evaluate((pos) => {
return window.app!.canvas.ds.convertOffsetToCanvas(pos)
}, pos)
}
async getNodeCenterByTitle(title: string): Promise<Position | null> {
return this.page.evaluate((title) => {
const app = window.app!
const node = app.graph.nodes.find(
(n: { title: string }) => n.title === title
)
if (!node) return null
const centerX = node.pos[0] + node.size[0] / 2
const centerY = node.pos[1] + node.size[1] / 2
const [clientX, clientY] = app.canvasPosToClientPos([centerX, centerY])
return { x: clientX, y: clientY }
}, title)
}
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window.app!.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window.app!
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {
const { reverse = false } = options
const start = reverse
? DefaultGraphPositions.clipTextEncodeNode1InputSlot
: DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
const end = reverse
? DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
: DefaultGraphPositions.clipTextEncodeNode1InputSlot
await this.dragAndDrop(start, end)
}
}

View File

@@ -0,0 +1,15 @@
import type { Locator } from '@playwright/test'
import type { KeyboardHelper } from './KeyboardHelper'
export class ClipboardHelper {
constructor(private readonly keyboard: KeyboardHelper) {}
async copy(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyC', locator ?? null)
}
async paste(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
}

View File

@@ -0,0 +1,76 @@
import type { Page } from '@playwright/test'
import type { KeyCombo } from '../../../src/platform/keybindings/types'
export class CommandHelper {
constructor(private readonly page: Page) {}
async executeCommand(commandId: string): Promise<void> {
await this.page.evaluate((id: string) => {
return window.app!.extensionManager.command.execute(id)
}, commandId)
}
async registerCommand(
commandId: string,
command: (() => void) | (() => Promise<void>)
): Promise<void> {
// SECURITY: eval() is intentionally used here to deserialize/execute functions
// passed from controlled test code across the Node/Playwright browser boundary.
// Execution happens in isolated Playwright browser contexts with test-only data.
// This pattern is unsafe for production and must not be copied elsewhere.
await this.page.evaluate(
({ commandId, commandStr }) => {
const app = window.app!
const randomSuffix = Math.random().toString(36).substring(2, 8)
const extensionName = `TestExtension_${randomSuffix}`
app.registerExtension({
name: extensionName,
commands: [
{
id: commandId,
function: eval(commandStr)
}
]
})
},
{ commandId, commandStr: command.toString() }
)
}
async registerKeybinding(
keyCombo: KeyCombo,
command: () => void
): Promise<void> {
// SECURITY: eval() is intentionally used here to deserialize/execute functions
// passed from controlled test code across the Node/Playwright browser boundary.
// Execution happens in isolated Playwright browser contexts with test-only data.
// This pattern is unsafe for production and must not be copied elsewhere.
await this.page.evaluate(
({ keyCombo, commandStr }) => {
const app = window.app!
const randomSuffix = Math.random().toString(36).substring(2, 8)
const extensionName = `TestExtension_${randomSuffix}`
const commandId = `TestCommand_${randomSuffix}`
app.registerExtension({
name: extensionName,
keybindings: [
{
combo: keyCombo,
commandId: commandId
}
],
commands: [
{
id: commandId,
function: eval(commandStr)
}
]
})
},
{ keyCombo, commandStr: command.toString() }
)
}
}

View File

@@ -0,0 +1,167 @@
import type { Locator, Page, TestInfo } from '@playwright/test'
import type { Position } from '../types'
export interface DebugScreenshotOptions {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
export class DebugHelper {
constructor(
private page: Page,
private canvas: Locator
) {}
async addMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
const existing = document.getElementById(markerId)
if (existing) existing.remove()
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
async removeMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
async attachScreenshot(
testInfo: TestInfo,
name: string,
options?: DebugScreenshotOptions
): Promise<void> {
if (options?.markers) {
for (const marker of options.markers) {
await this.addMarker(marker.position, marker.id)
}
}
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
if (options?.markers) {
await this.removeMarkers()
}
}
async saveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
}
async getCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
async showCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
async hideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
}

View File

@@ -0,0 +1,161 @@
import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '../types'
export class DragDropHelper {
constructor(
private readonly page: Page,
private readonly assetPath: (fileName: string) => string
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
async dragAndDropExternalResource(
options: {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
} = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const evaluateParams: {
dropPosition: Position
fileName?: string
fileType?: string
buffer?: Uint8Array | number[]
url?: string
} = { dropPosition }
if (fileName) {
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.buffer = [...new Uint8Array(buffer)]
}
if (url) evaluateParams.url = url
const uploadResponsePromise = waitForUpload
? this.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10000 }
)
: null
await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer()
if (params.buffer && params.fileName && params.fileType) {
const file = new File(
[new Uint8Array(params.buffer)],
params.fileName,
{
type: params.fileType
}
)
dataTransfer.items.add(file)
}
if (params.url) {
dataTransfer.setData('text/uri-list', params.url)
dataTransfer.setData('text/x-moz-url', params.url)
}
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
throw new Error(
`No element found at drop position: (${params.dropPosition.x}, ${params.dropPosition.y}). ` +
`document.elementFromPoint returned null. Ensure the target is visible and not obscured.`
)
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
}
}
}, evaluateParams)
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropURL(
url: string,
options: { dropPosition?: Position } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}
}

View File

@@ -0,0 +1,45 @@
import type { Locator, Page } from '@playwright/test'
export class KeyboardHelper {
constructor(
private readonly page: Page,
private readonly canvas: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
}
async ctrlSend(
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
}
async selectAll(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyA', locator)
}
async bypass(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyB', locator)
}
async undo(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyZ', locator)
}
async redo(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyY', locator)
}
async moveUp(locator?: Locator | null): Promise<void> {
await this.ctrlSend('ArrowUp', locator)
}
async moveDown(locator?: Locator | null): Promise<void> {
await this.ctrlSend('ArrowDown', locator)
}
}

View File

@@ -0,0 +1,182 @@
import type { Locator } from '@playwright/test'
import type {
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position, Size } from '../types'
import { NodeReference } from '../utils/litegraphUtils'
export class NodeOperationsHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {
return this.comfyPage.page
}
async getGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return window.app?.graph?.nodes?.length || 0
})
}
async getSelectedGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return (
window.app?.graph?.nodes?.filter(
(node: LGraphNode) => node.is_selected === true
).length || 0
)
})
}
async getNodes(): Promise<LGraphNode[]> {
return await this.page.evaluate(() => {
return window.app!.graph.nodes
})
}
async waitForGraphNodes(count: number): Promise<void> {
await this.page.waitForFunction((count) => {
return window.app?.canvas.graph?.nodes?.length === count
}, count)
}
async getFirstNodeRef(): Promise<NodeReference | null> {
const id = await this.page.evaluate(() => {
return window.app!.graph.nodes[0]?.id
})
if (!id) return null
return this.getNodeRefById(id)
}
async getNodeRefById(id: NodeId): Promise<NodeReference> {
return new NodeReference(id, this.comfyPage)
}
async getNodeRefsByType(
type: string,
includeSubgraph: boolean = false
): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate(
({ type, includeSubgraph }) => {
const graph = (
includeSubgraph ? window.app!.canvas.graph : window.app!.graph
) as LGraph
const nodes = graph.nodes
return nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
},
{ type, includeSubgraph }
)
).map((id: NodeId) => this.getNodeRefById(id))
)
}
async getNodeRefsByTitle(title: string): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate((title) => {
return window
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
.map((n: LGraphNode) => n.id)
}, title)
).map((id: NodeId) => this.getNodeRefById(id))
)
}
async selectNodes(nodeTitles: string[]): Promise<void> {
await this.page.keyboard.down('Control')
try {
for (const nodeTitle of nodeTitles) {
const nodes = await this.getNodeRefsByTitle(nodeTitle)
for (const node of nodes) {
await node.click('title')
}
}
} finally {
await this.page.keyboard.up('Control')
await this.comfyPage.nextFrame()
}
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
ratioX: number,
ratioY: number,
revertAfter: boolean = false
): Promise<void> {
const bottomRight = {
x: nodePos.x + nodeSize.width,
y: nodePos.y + nodeSize.height
}
const target = {
x: nodePos.x + nodeSize.width * ratioX,
y: nodePos.y + nodeSize.height * ratioY
}
// -1 to be inside the node. -2 because nodes currently get an arbitrary +1 to width.
await this.comfyPage.canvasOps.dragAndDrop(
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.comfyPage.nextFrame()
if (revertAfter) {
await this.comfyPage.canvasOps.dragAndDrop(
{ x: target.x - 2, y: target.y - 1 },
bottomRight
)
await this.comfyPage.nextFrame()
}
}
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
await this.comfyPage.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
if (!node) {
throw new Error('No nodes found to convert')
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
}
get promptDialogInput(): Locator {
return this.page.locator('.p-dialog-content input[type="text"]')
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')
await this.promptDialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
async dragTextEncodeNode2(): Promise<void> {
await this.comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.textEncodeNode2,
{
x: DefaultGraphPositions.textEncodeNode2.x,
y: 300
}
)
await this.comfyPage.nextFrame()
}
async adjustEmptyLatentWidth(): Promise<void> {
await this.page.locator('#graph-canvas').click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
const dialogInput = this.page.locator('.graphdialog input[type="text"]')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from '@playwright/test'
export class SettingsHelper {
constructor(private readonly page: Page) {}
async setSetting(settingId: string, settingValue: unknown): Promise<void> {
await this.page.evaluate(
async ({ id, value }) => {
await window.app!.extensionManager.setting.set(id, value)
},
{ id: settingId, value: settingValue }
)
}
async getSetting<T = unknown>(settingId: string): Promise<T> {
return (await this.page.evaluate(async (id) => {
return await window.app!.extensionManager.setting.get(id)
}, settingId)) as T
}
}

View File

@@ -0,0 +1,325 @@
import type { Page } from '@playwright/test'
import type {
CanvasPointerEvent,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
export class SubgraphHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/**
* Core helper method for interacting with subgraph I/O slots.
* Handles both input/output slots and both right-click/double-click actions.
*
* @param slotType - 'input' or 'output'
* @param action - 'rightClick' or 'doubleClick'
* @param slotName - Optional specific slot name to target
*/
private async interactWithSubgraphSlot(
slotType: 'input' | 'output',
action: 'rightClick' | 'doubleClick',
slotName?: string
): Promise<void> {
const foundSlot = await this.page.evaluate(
async (params) => {
const { slotType, action, targetSlotName } = params
const app = window.app!
const currentGraph = app.canvas!.graph!
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const subgraph = currentGraph as Subgraph
// Get the appropriate node and slots
const node =
slotType === 'input' ? subgraph.inputNode : subgraph.outputNode
const slots = slotType === 'input' ? subgraph.inputs : subgraph.outputs
if (!node) {
throw new Error(`No ${slotType} node found in subgraph`)
}
if (!slots || slots.length === 0) {
throw new Error(`No ${slotType} slots found in subgraph`)
}
// Filter slots based on target name and action type
const slotsToTry = targetSlotName
? slots.filter((slot) => slot.name === targetSlotName)
: action === 'rightClick'
? slots
: [slots[0]] // Right-click tries all, double-click uses first
if (slotsToTry.length === 0) {
throw new Error(
targetSlotName
? `${slotType} slot '${targetSlotName}' not found`
: `No ${slotType} slots available to try`
)
}
// Handle the interaction based on action type
if (action === 'rightClick') {
// Right-click: try each slot until one works
for (const slot of slotsToTry) {
if (!slot.pos) continue
const event = {
canvasX: slot.pos[0],
canvasY: slot.pos[1],
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
return {
success: true,
slotName: slot.name,
x: slot.pos[0],
y: slot.pos[1]
}
}
}
} else if (action === 'doubleClick') {
// Double-click: use first slot with bounding rect center
const slot = slotsToTry[0]
if (!slot.boundingRect) {
throw new Error(`${slotType} slot bounding rect not found`)
}
const rect = slot.boundingRect
const testX = rect[0] + rect[2] / 2 // x + width/2
const testY = rect[1] + rect[3] / 2 // y + height/2
const event = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(
event as unknown as CanvasPointerEvent
)
}
}
return { success: true, slotName: slot.name, x: testX, y: testY }
}
return { success: false }
},
{ slotType, action, targetSlotName: slotName }
)
if (!foundSlot.success) {
const actionText =
action === 'rightClick' ? 'open context menu for' : 'double-click'
throw new Error(
slotName
? `Could not ${actionText} ${slotType} slot '${slotName}'`
: `Could not find any ${slotType} slot to ${actionText}`
)
}
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
} else {
await this.comfyPage.nextFrame()
}
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
*
* This method uses the actual slot positions from the subgraph.inputs array,
* which contain the correct coordinates for each input slot. These positions
* are different from the visual node positions and are specifically where
* the slots are rendered on the input node.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries all available input slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'rightClick', inputName)
}
/**
* Right-clicks on a subgraph output slot to open the context menu.
* Must be called when inside a subgraph.
*
* Similar to rightClickInputSlot but for output slots.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries all available output slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'rightClick', outputName)
}
/**
* Double-clicks on a subgraph input slot to rename it.
* Must be called when inside a subgraph.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries the first available input slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'doubleClick', inputName)
}
/**
* Double-clicks on a subgraph output slot to rename it.
* Must be called when inside a subgraph.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries the first available output slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'doubleClick', outputName)
}
/**
* Get a reference to a subgraph input slot
*/
getInputSlot(slotName?: string): SubgraphSlotReference {
return new SubgraphSlotReference('input', slotName || '', this.comfyPage)
}
/**
* Get a reference to a subgraph output slot
*/
getOutputSlot(slotName?: string): SubgraphSlotReference {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
*/
async connectToInput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetInputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = this.getInputSlot(targetInputName)
const targetPosition = targetInputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
await sourceSlot.getPosition(),
targetPosition
)
await this.comfyPage.nextFrame()
}
/**
* Connect a subgraph input to a regular node input.
* This creates a new input slot on the subgraph if sourceInputName is not provided.
*/
async connectFromInput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceInputName?: string
): Promise<void> {
const sourceSlot = this.getInputSlot(sourceInputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceInputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
const targetPosition = await targetSlot.getPosition()
await this.comfyPage.canvasOps.dragAndDrop(sourcePosition, targetPosition)
await this.comfyPage.nextFrame()
}
/**
* Connect a regular node output to a subgraph output.
* This creates a new output slot on the subgraph if targetOutputName is not provided.
*/
async connectToOutput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetOutputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = this.getOutputSlot(targetOutputName)
const targetPosition = targetOutputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
await sourceSlot.getPosition(),
targetPosition
)
await this.comfyPage.nextFrame()
}
/**
* Connect a subgraph output to a regular node input.
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
*/
async connectFromOutput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceOutputName?: string
): Promise<void> {
const sourceSlot = this.getOutputSlot(sourceOutputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceOutputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
sourcePosition,
await targetSlot.getPosition()
)
await this.comfyPage.nextFrame()
}
}

View File

@@ -0,0 +1,39 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ToastHelper {
constructor(private readonly page: Page) {}
get visibleToasts(): Locator {
return this.page.locator('.p-toast-message:visible')
}
async getToastErrorCount(): Promise<number> {
return await this.page
.locator('.p-toast-message.p-toast-message-error')
.count()
}
async getVisibleToastCount(): Promise<number> {
return await this.visibleToasts.count()
}
async closeToasts(requireCount = 0): Promise<void> {
if (requireCount) {
await this.visibleToasts
.nth(requireCount - 1)
.waitFor({ state: 'visible' })
}
// Clear all toasts
const toastCloseButtons = await this.page
.locator('.p-toast-close-button')
.all()
for (const button of toastCloseButtons) {
await button.click()
}
// Assert all toasts are closed
await expect(this.visibleToasts).toHaveCount(0, { timeout: 1000 })
}
}

View File

@@ -0,0 +1,126 @@
import { readFileSync } from 'fs'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { WorkspaceStore } from '../../types/globals'
import type { ComfyPage } from '../ComfyPage'
type FolderStructure = {
[key: string]: FolderStructure | string
}
export class WorkflowHelper {
constructor(private readonly comfyPage: ComfyPage) {}
convertLeafToContent(structure: FolderStructure): FolderStructure {
const result: FolderStructure = {}
for (const [key, value] of Object.entries(structure)) {
if (typeof value === 'string') {
const filePath = this.comfyPage.assetPath(value)
result[key] = readFileSync(filePath, 'utf-8')
} else {
result[key] = this.convertLeafToContent(value)
}
}
return result
}
async setupWorkflowsDirectory(structure: FolderStructure) {
const resp = await this.comfyPage.request.post(
`${this.comfyPage.url}/api/devtools/setup_folder_structure`,
{
data: {
tree_structure: this.convertLeafToContent(structure),
base_path: `user/${this.comfyPage.id}/workflows`
}
}
)
if (resp.status() !== 200) {
throw new Error(
`Failed to setup workflows directory: ${await resp.text()}`
)
}
await this.comfyPage.page.evaluate(async () => {
await (
window.app!.extensionManager as WorkspaceStore
).workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.comfyPage.nextFrame()
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
this.comfyPage.assetPath(`${workflowName}.json`)
)
await this.comfyPage.nextFrame()
}
async deleteWorkflow(
workflowName: string,
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
) {
// Open workflows tab
const { workflowsTab } = this.comfyPage.menu
await workflowsTab.open()
// Action to take if workflow missing
if (whenMissing === 'ignoreMissing') {
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
if (!workflows.includes(workflowName)) return
}
// Delete workflow
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
await this.comfyPage.contextMenu.clickMenuItem('Delete')
await this.comfyPage.nextFrame()
await this.comfyPage.confirmDialog.delete.click()
// Clear toast & close tab
await this.comfyPage.toast.closeToasts(1)
await workflowsTab.close()
}
async getUndoQueueSize(): Promise<number | undefined> {
return this.comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
async getRedoQueueSize(): Promise<number | undefined> {
return this.comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
async getExportedWorkflow(options?: {
api?: false
}): Promise<ComfyWorkflowJSON>
async getExportedWorkflow(options?: {
api?: boolean
}): Promise<ComfyWorkflowJSON | ComfyApiWorkflow> {
const api = options?.api ?? false
return this.comfyPage.page.evaluate(async (api) => {
return (await window.app!.graphToPrompt())[api ? 'output' : 'workflow']
}, api)
}
}

View File

@@ -0,0 +1,77 @@
/**
* Centralized test selectors for browser tests.
* Use data-testid attributes for stable selectors.
*/
export const TestIds = {
sidebar: {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
tree: {
folder: 'tree-folder',
leaf: 'tree-leaf',
node: 'tree-node'
},
canvas: {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
},
dialogs: {
settings: 'settings-dialog',
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
},
topbar: {
queueButton: 'queue-button',
saveButton: 'save-workflow-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel'
},
node: {
titleInput: 'node-title-input'
},
widgets: {
decrement: 'decrement',
increment: 'increment',
subgraphEnterButton: 'subgraph-enter-button'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
},
user: {
currentUserIndicator: 'current-user-indicator'
}
} as const
/**
* Helper type for accessing nested TestIds (excludes function values)
*/
export type TestIdValue =
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (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.widgets)[keyof typeof TestIds.widgets]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
@@ -22,10 +23,10 @@ export class SubgraphSlotReference {
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type, slotName]) => {
const currentGraph = window['app'].canvas.graph
const currentGraph = window.app!.canvas.graph!
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
// Check if we're in a subgraph (subgraphs have inputNode property)
if (!('inputNode' in currentGraph)) {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
@@ -51,7 +52,7 @@ export class SubgraphSlotReference {
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
const canvasPos = window.app!.canvas.ds.convertOffsetToCanvas([
slot.pos[0],
slot.pos[1]
])
@@ -69,9 +70,10 @@ export class SubgraphSlotReference {
async getOpenSlotPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type]) => {
const currentGraph = window['app'].canvas.graph
const currentGraph = window.app!.canvas.graph!
if (currentGraph.constructor.name !== 'Subgraph') {
// Check if we're in a subgraph (subgraphs have inputNode property)
if (!('inputNode' in currentGraph)) {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
@@ -85,7 +87,7 @@ export class SubgraphSlotReference {
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
const canvasPos = window.app!.canvas.ds.convertOffsetToCanvas([
node.emptySlot.pos[0],
node.emptySlot.pos[1]
])
@@ -111,12 +113,12 @@ class NodeSlotReference {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
const node = window['app'].canvas.graph.getNodeById(id)
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const rawPos = node.getConnectionPos(type === 'input', index)
const convertedPos =
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility
console.warn(
@@ -126,7 +128,7 @@ class NodeSlotReference {
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window['app'].canvas.graph.constructor.name
currentGraphType: window.app!.canvas.graph!.constructor.name
}
)
@@ -142,7 +144,7 @@ class NodeSlotReference {
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
@@ -155,7 +157,7 @@ class NodeSlotReference {
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
@@ -180,15 +182,15 @@ class NodeWidgetReference {
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
const widget = node.widgets![index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvasPosToClientPos([
const [x, y, w, _h] = node.getBounding()
return window.app!.canvasPosToClientPos([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
y + window.LiteGraph!['NODE_TITLE_HEIGHT'] + widget.last_y! + 1
])
},
[this.node.id, this.index] as const
@@ -205,9 +207,9 @@ class NodeWidgetReference {
async getSocketPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
const widget = node.widgets![index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const slot = node.inputs.find(
@@ -216,9 +218,9 @@ class NodeWidgetReference {
if (!slot) throw new Error(`Socket ${widget.name} not found.`)
const [x, y] = node.getBounding()
return window['app'].canvasPosToClientPos([
x + slot.pos[0],
y + slot.pos[1] + window['LiteGraph']['NODE_TITLE_HEIGHT']
return window.app!.canvasPosToClientPos([
x + slot.pos![0],
y + slot.pos![1] + window.LiteGraph!['NODE_TITLE_HEIGHT']
])
},
[this.node.id, this.index] as const
@@ -239,7 +241,7 @@ class NodeWidgetReference {
const pos = await this.getPosition()
const canvas = this.node.comfyPage.canvas
const canvasPos = (await canvas.boundingBox())!
await this.node.comfyPage.dragAndDrop(
await this.node.comfyPage.canvasOps.dragAndDrop(
{
x: canvasPos.x + pos.x,
y: canvasPos.y + pos.y
@@ -254,9 +256,9 @@ class NodeWidgetReference {
async getValue() {
return await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
const widget = node.widgets![index]
if (!widget) throw new Error(`Widget ${index} not found.`)
return widget.value
},
@@ -271,7 +273,7 @@ export class NodeReference {
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window.app!.canvas.graph!.getNodeById(id)
return !!node
}, this.id)
}
@@ -279,7 +281,7 @@ export class NodeReference {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
@@ -288,12 +290,11 @@ export class NodeReference {
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
const [x, y, width, height] = await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
return [...node.getBounding()] as [number, number, number, number]
}, this.id)
return {
x,
y,
@@ -311,6 +312,11 @@ export class NodeReference {
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async getTitlePosition(): Promise<Position> {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
@@ -323,9 +329,9 @@ export class NodeReference {
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
return (node as unknown as Record<string, T>)[prop]
},
[this.id, prop] as const
)
@@ -343,16 +349,16 @@ export class NodeReference {
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
clickPos = await this.getTitlePosition()
break
case 'collapse':
case 'collapse': {
const nodePos = await this.getPosition()
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
break
}
default:
throw new Error(`Invalid click position ${position}`)
}
@@ -369,12 +375,12 @@ export class NodeReference {
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.moveMouseToEmptyArea()
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.ctrlC()
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async connectWidget(
@@ -384,7 +390,7 @@ export class NodeReference {
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await this.comfyPage.canvasOps.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getSocketPosition()
)
@@ -397,7 +403,7 @@ export class NodeReference {
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await this.comfyPage.canvasOps.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
@@ -415,9 +421,9 @@ export class NodeReference {
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.fillPromptDialog(groupNodeName)
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
@@ -428,7 +434,8 @@ export class NodeReference {
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
const nodes =
await this.comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
if (nodes.length !== 1) {
throw new Error(
`Did not find single subgraph node (found=${nodes.length})`
@@ -446,7 +453,7 @@ export class NodeReference {
}
async navigateIntoSubgraph() {
const titleHeight = await this.comfyPage.page.evaluate(() => {
return window['LiteGraph']['NODE_TITLE_HEIGHT']
return window.LiteGraph!['NODE_TITLE_HEIGHT']
})
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
@@ -458,13 +465,14 @@ export class NodeReference {
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
let isInSubgraph = false
let attempts = 0
const maxAttempts = 3
while (!isInSubgraph && attempts < maxAttempts) {
attempts++
const checkIsInSubgraph = async () => {
return this.comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
}
await expect(async () => {
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
@@ -477,24 +485,9 @@ export class NodeReference {
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
// Check if we successfully entered the subgraph
isInSubgraph = await this.comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
if (isInSubgraph) break
if (await checkIsInSubgraph()) return
}
if (!isInSubgraph && attempts < maxAttempts) {
await this.comfyPage.page.waitForTimeout(500)
}
}
if (!isInSubgraph) {
throw new Error(
'Failed to navigate into subgraph after ' + attempts + ' attempts'
)
}
throw new Error('Not in subgraph yet')
}).toPass({ timeout: 5000, intervals: [100, 200, 500] })
}
}

View File

@@ -1,4 +1,3 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
@@ -40,7 +39,7 @@ export class VueNodeFixture {
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.waitFor({ state: 'visible' })
await input.fill(value)
await input.press('Enter')
}
@@ -48,7 +47,7 @@ export class VueNodeFixture {
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.waitFor({ state: 'visible' })
await input.press('Escape')
}

View File

@@ -1,7 +1,7 @@
import { test as base } from '@playwright/test'
export const webSocketFixture = base.extend<{
ws: { trigger(data: any, url?: string): Promise<void> }
ws: { trigger(data: unknown, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {
@@ -10,7 +10,7 @@ export const webSocketFixture = base.extend<{
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
const store: Record<string, WebSocket> = (window.__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
@@ -34,7 +34,7 @@ export const webSocketFixture = base.extend<{
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = (window as any).__ws__[url]
const ws: WebSocket = window.__ws__![url]
ws.dispatchEvent(
new MessageEvent('message', {
data

View File

@@ -5,7 +5,7 @@ import { backupPath } from './utils/backupUtils'
dotenv.config()
export default function globalSetup(config: FullConfig) {
export default function globalSetup(_config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])

View File

@@ -5,7 +5,7 @@ import { restorePath } from './utils/backupUtils'
dotenv.config()
export default function globalTeardown(config: FullConfig) {
export default function globalTeardown(_config: FullConfig) {
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])

View File

@@ -1,6 +1,8 @@
import type { Locator, Page } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore'
import { TestIds } from '../fixtures/selectors'
import type { WorkspaceStore } from '../types/globals'
export class ComfyActionbar {
public readonly root: Locator
@@ -26,7 +28,7 @@ class ComfyQueueButton {
public readonly primaryButton: Locator
public readonly dropdownButton: Locator
constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId('queue-button')
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
@@ -42,13 +44,14 @@ class ComfyQueueButtonOptions {
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {
window['app'].extensionManager.queueSettings.mode = mode
;(window.app!.extensionManager as WorkspaceStore).queueSettings.mode =
mode
}, mode)
}
public async getMode() {
return await this.page.evaluate(() => {
return window['app'].extensionManager.queueSettings.mode
return (window.app!.extensionManager as WorkspaceStore).queueSettings.mode
})
}
}

View File

@@ -23,7 +23,7 @@ export async function fitToViewInstant(
{ selectionOnly: boolean }
>(
({ selectionOnly }) => {
const app = window['app']
const app = window.app
if (!app?.canvas) return null
const canvas = app.canvas
@@ -90,7 +90,7 @@ export async function fitToViewInstant(
await comfyPage.page.evaluate(
({ bounds, zoom }) => {
const app = window['app']
const app = window.app
if (!app?.canvas) return
const canvas = app.canvas

View File

@@ -0,0 +1,16 @@
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
/**
* Assertion helper for tests where being in a subgraph is a precondition.
* Throws a clear error if the graph is not a Subgraph.
*/
export function assertSubgraph(
graph: LGraph | Subgraph | null | undefined
): asserts graph is Subgraph {
if (!isSubgraph(graph)) {
throw new Error(
'Expected to be in a subgraph context, but graph is not a Subgraph'
)
}
}

View File

@@ -6,18 +6,19 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '../../src/platform/workflow/templates/types/template'
import { TestIds } from '../fixtures/selectors'
export class ComfyTemplates {
readonly content: Locator
readonly allTemplateCards: Locator
constructor(readonly page: Page) {
this.content = page.getByTestId('template-workflows-content')
this.content = page.getByTestId(TestIds.templates.content)
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
}
async waitForMinimumCardCount(count: number) {
return await expect(async () => {
async expectMinimumCardCount(count: number) {
await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
@@ -26,14 +27,16 @@ export class ComfyTemplates {
}
async loadTemplate(id: string) {
const templateCard = this.content.getByTestId(`template-workflow-${id}`)
const templateCard = this.content.getByTestId(
TestIds.templates.workflowCard(id)
)
await templateCard.scrollIntoViewIfNeeded()
await templateCard.getByRole('img').click()
}
async getAllTemplates(): Promise<TemplateInfo[]> {
const templates: WorkflowTemplates[] = await this.page.evaluate(() =>
window['app'].api.getCoreWorkflowTemplates()
window.app!.api.getCoreWorkflowTemplates()
)
return templates.flatMap((t) => t.templates)
}

View File

@@ -1,15 +1,16 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage.ts'
import { webSocketFixture } from '../fixtures/ws.ts'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WorkspaceStore } from '../types/globals'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Actionbar', () => {
test.describe('Actionbar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
/**
@@ -49,13 +50,14 @@ test.describe('Actionbar', () => {
// Find and set the width on the latent node
const triggerChange = async (value: number) => {
return await comfyPage.page.evaluate((value) => {
const node = window['app'].graph._nodes.find(
const node = window.app!.graph!._nodes.find(
(n) => n.type === 'EmptyLatentImage'
)
node.widgets[0].value = value
window[
'app'
].extensionManager.workflow.activeWorkflow.changeTracker.checkState()
node!.widgets![0].value = value
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.checkState()
}, value)
}

View File

@@ -3,18 +3,18 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test.afterEach(async ({ comfyPage }) => {
// Clean up background image setting after each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test('should show background image upload component in settings', async ({
@@ -34,16 +34,18 @@ test.describe('Background Image Upload', () => {
await expect(backgroundImageSetting).toBeVisible()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
@@ -63,9 +65,9 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
@@ -76,15 +78,17 @@ test.describe('Background Image Upload', () => {
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.getSetting(
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
@@ -107,18 +111,20 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.getSetting(
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
@@ -130,7 +136,10 @@ test.describe('Background Image Upload', () => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', testImageUrl)
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
testImageUrl
)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
@@ -144,11 +153,13 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Click the clear button
@@ -161,7 +172,7 @@ test.describe('Background Image Upload', () => {
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.getSetting(
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
@@ -182,9 +193,9 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await uploadButton.hover()
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
@@ -194,12 +205,14 @@ test.describe('Background Image Upload', () => {
await comfyPage.page.locator('body').hover()
// Set a background to enable clear button
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await clearButton.hover()
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
@@ -220,8 +233,10 @@ test.describe('Background Image Upload', () => {
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
const urlInput = backgroundImageSetting.getByRole('textbox')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()

View File

@@ -2,55 +2,35 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Bottom Panel Shortcuts', () => {
test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
const { bottomPanel } = comfyPage
// Click shortcuts toggle button in sidebar
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Click toggle button again to hide
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Panel should be hidden again
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
await expect(bottomPanel.root).not.toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible()
})
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Essentials tab should be visible and active by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toBeVisible()
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await bottomPanel.keyboardShortcutsButton.click()
// Should display shortcut categories
await expect(
comfyPage.page.locator('.subcategory-title').first()
).toBeVisible()
await expect(bottomPanel.shortcuts.essentialsTab).toBeVisible()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
// Should display some keyboard shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
await expect(bottomPanel.shortcuts.subcategoryTitles.first()).toBeVisible()
await expect(bottomPanel.shortcuts.keyBadges.first()).toBeVisible()
// Should have workflow, node, and queue sections
await expect(
comfyPage.page.getByRole('heading', { name: 'Workflow' })
).toBeVisible()
@@ -63,23 +43,18 @@ test.describe('Bottom Panel Shortcuts', () => {
})
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.viewControlsTab.click()
// View controls tab should be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.shortcuts.viewControlsTab).toHaveAttribute(
'aria-selected',
'true'
)
// Should display view controls shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
await expect(bottomPanel.shortcuts.keyBadges.first()).toBeVisible()
// Should have view and panel controls sections
await expect(
comfyPage.page.getByRole('heading', { name: 'View' })
).toBeVisible()
@@ -89,54 +64,48 @@ test.describe('Bottom Panel Shortcuts', () => {
})
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Essentials should be active initially
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await bottomPanel.keyboardShortcutsButton.click()
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
// View controls should now be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).not.toHaveAttribute('aria-selected', 'true')
await bottomPanel.shortcuts.viewControlsTab.click()
// Switch back to essentials
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
await expect(bottomPanel.shortcuts.viewControlsTab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(bottomPanel.shortcuts.essentialsTab).not.toHaveAttribute(
'aria-selected',
'true'
)
// Essentials should be active again
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).not.toHaveAttribute('aria-selected', 'true')
await bottomPanel.shortcuts.essentialsTab.click()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(bottomPanel.shortcuts.viewControlsTab).not.toHaveAttribute(
'aria-selected',
'true'
)
})
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Wait for shortcuts to load
await comfyPage.page.waitForSelector('.key-badge')
await bottomPanel.keyboardShortcutsButton.click()
// Check for common formatted keys
const keyBadges = comfyPage.page.locator('.key-badge')
const keyBadges = bottomPanel.shortcuts.keyBadges
await keyBadges.first().waitFor({ state: 'visible' })
const count = await keyBadges.count()
expect(count).toBeGreaterThanOrEqual(1)
// Should show formatted modifier keys
const badgeText = await keyBadges.allTextContents()
const hasModifiers = badgeText.some((text) =>
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
@@ -144,91 +113,89 @@ test.describe('Bottom Panel Shortcuts', () => {
expect(hasModifiers).toBeTruthy()
})
test('should maintain panel state when switching to terminal', async ({
test('should maintain panel state when switching between panels', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel first
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Open terminal panel (should switch panels)
await comfyPage.page
.locator('button[aria-label*="Toggle Bottom Panel"]')
.click()
// Panel should still be visible but showing terminal content
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Switch back to shortcuts
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Should show shortcuts content again
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
// Try to open terminal panel - may show terminal OR close shortcuts
// depending on whether terminal tabs have loaded (async loading)
await bottomPanel.toggleButton.click()
// Check if terminal tabs loaded (Logs tab visible) or fell back to shortcuts toggle
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
const hasTerminalTabs = await logsTab.isVisible().catch(() => false)
if (hasTerminalTabs) {
// Terminal panel is visible - verify we can switch back to shortcuts
await expect(bottomPanel.root).toBeVisible()
// Switch back to shortcuts
await bottomPanel.keyboardShortcutsButton.click()
// Should show shortcuts content again
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
} else {
// Terminal tabs not loaded - button toggled shortcuts off, reopen for verification
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
}
})
test('should handle keyboard navigation', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Focus the first tab
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.essentialsTab.focus()
// Use arrow keys to navigate between tabs
await comfyPage.page.keyboard.press('ArrowRight')
// View controls tab should now have focus
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toBeFocused()
await expect(bottomPanel.shortcuts.viewControlsTab).toBeFocused()
// Press Enter to activate the tab
await comfyPage.page.keyboard.press('Enter')
// Tab should be selected
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.shortcuts.viewControlsTab).toHaveAttribute(
'aria-selected',
'true'
)
})
test('should close panel by clicking shortcuts button again', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
const { bottomPanel } = comfyPage
// Click shortcuts button again to close
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
// Panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible()
})
test('should display shortcuts in organized columns', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Should have 3-column grid layout
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
// Should have multiple subcategory sections
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
await expect(
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
).toBeVisible()
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
const titleCount = await subcategoryTitles.count()
expect(titleCount).toBeGreaterThanOrEqual(2)
})
@@ -236,43 +203,30 @@ test.describe('Bottom Panel Shortcuts', () => {
test('should open shortcuts panel with Ctrl+Shift+K', async ({
comfyPage
}) => {
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
// Press Ctrl+Shift+K to open shortcuts panel
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Should show essentials tab by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.root).toBeVisible()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
})
test('should open settings dialog when clicking manage shortcuts button', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
const { bottomPanel } = comfyPage
// Manage shortcuts button should be visible
await expect(
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
// Click manage shortcuts button
await comfyPage.page
.getByRole('button', { name: /Manage Shortcuts/i })
.click()
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
await bottomPanel.shortcuts.manageButton.click()
// Settings dialog should open with keybinding tab
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
// Should show keybinding settings (check for keybinding-related content)
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()

View File

@@ -1,16 +1,18 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Browser tab title', () => {
test.describe('Browser tab title', { tag: '@smoke' }, () => {
test.describe('Beta Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can display workflow name', async ({ comfyPage }) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].extensionManager.workflow.activeWorkflow.filename
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
})
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
})
@@ -21,7 +23,8 @@ test.describe('Browser tab title', () => {
comfyPage
}) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].extensionManager.workflow.activeWorkflow.filename
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
})
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
@@ -30,19 +33,21 @@ test.describe('Browser tab title', () => {
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.clickEmptySpace()
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {
return window['app'].extensionManager.workflow.activeWorkflow.delete()
return (
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.delete()
})
})
})
test.describe('Legacy Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display default title', async ({ comfyPage }) => {

View File

@@ -6,69 +6,69 @@ import {
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window['app'].canvas.emitBeforeChange()
window.app!.canvas!.emitBeforeChange()
})
}
async function afterChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window['app'].canvas.emitAfterChange()
window.app!.canvas!.emitAfterChange()
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', () => {
test.describe('Change Tracker', { tag: '@workflow' }, () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setupWorkflowsDirectory({})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Can undo multiple operations', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.getFirstNodeRef())!
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.ctrlB()
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(2)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(2)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(1)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(1)
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(2)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
})
})
test('Can group multiple change actions into a single transaction', async ({
comfyPage
}) => {
const node = (await comfyPage.getFirstNodeRef())!
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
expect(node).toBeTruthy()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed()
@@ -77,27 +77,27 @@ test.describe('Change Tracker', () => {
// Bypass + collapse node
await node.click('title')
await node.click('collapse')
await comfyPage.ctrlB()
await comfyPage.keyboard.bypass()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// Undo, undo, ensure both changes undone
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).toBeCollapsed()
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
// Prevent clicks registering a double-click
await comfyPage.clickEmptySpace()
await comfyPage.canvasOps.clickEmptySpace()
await node.click('title')
// Run again, but within a change transaction
await beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.ctrlB()
await comfyPage.keyboard.bypass()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
@@ -105,7 +105,7 @@ test.describe('Change Tracker', () => {
await afterChange(comfyPage)
// Ensure undo reverts both changes
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
})
@@ -113,10 +113,10 @@ test.describe('Change Tracker', () => {
test('Can nest multiple change transactions without adding undo steps', async ({
comfyPage
}) => {
const node = (await comfyPage.getFirstNodeRef())!
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const bypassAndPin = async () => {
await beforeChange(comfyPage)
await comfyPage.ctrlB()
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
@@ -142,30 +142,30 @@ test.describe('Change Tracker', () => {
await multipleChanges()
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBePinned()
await expect(node).not.toBeCollapsed()
await comfyPage.ctrlY()
await comfyPage.keyboard.redo()
await expect(node).toBeBypassed()
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window['app'].graph.extra.foo = 'bar'
window.app!.graph!.extra.foo = 'bar'
})
// Click empty space to trigger a change detection.
await comfyPage.clickEmptySpace()
expect(await comfyPage.getUndoQueueSize()).toBe(1)
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.pan({ x: 10, y: 10 })
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
})

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import type { Palette } from '../../src/schemas/colorPaletteSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const customColorPalettes: Record<string, Palette> = {
const customColorPalettes = {
obsidian: {
version: 102,
id: 'obsidian',
@@ -151,42 +151,50 @@ const customColorPalettes: Record<string, Palette> = {
}
}
test.describe('Color Palette', () => {
test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
await comfyPage.settings.setSetting(
'Comfy.CustomColorPalettes',
customColorPalettes
)
// Reload to apply the new setting. Setting Comfy.CustomColorPalettes directly
// doesn't update the store immediately.
await comfyPage.setup()
await comfyPage.loadWorkflow('nodes/every_node_color')
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-light-red.png'
)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
test('Can add custom color palette', async ({ comfyPage }) => {
await comfyPage.page.evaluate((p) => {
window['app'].extensionManager.colorPalette.addCustomColorPalette(p)
await comfyPage.page.evaluate(async (p) => {
await (
window.app!.extensionManager as WorkspaceStore
).colorPalette.addCustomColorPalette(p)
}, customColorPalettes.obsidian_dark)
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
// Legacy `custom_` prefix is still supported
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.settings.setSetting(
'Comfy.ColorPalette',
'custom_obsidian_dark'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
@@ -194,104 +202,110 @@ test.describe('Color Palette', () => {
})
})
test.describe('Node Color Adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/every_node_color')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.2-arc-theme.png'
)
})
test('should not serialize color adjustments in workflow', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}
})
test('should lighten node colors when switching to light theme', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-lightened-colors.png')
})
test.describe('Context menu color adjustments', () => {
test.describe(
'Node Color Adjustments',
{ tag: ['@screenshot', '@settings'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.getFirstNodeRef()
await node?.clickContextMenuOption('Colors')
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
})
test('should persist color adjustments when changing custom node colors', async ({
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("red")')
.click()
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-changed.png'
'node-opacity-0.2-arc-theme.png'
)
})
test('should persist color adjustments when removing custom node color', async ({
test('should not serialize color adjustments in workflow', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("No color")')
.click()
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}
})
test('should lighten node colors when switching to light theme', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-removed.png'
'node-lightened-colors.png'
)
})
})
})
test.describe('Context menu color adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.nodeOps.getFirstNodeRef()
await node?.clickContextMenuOption('Colors')
})
test('should persist color adjustments when changing custom node colors', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("red")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-changed.png'
)
})
test('should persist color adjustments when removing custom node color', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("No color")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-removed.png'
)
})
})
}
)

View File

@@ -3,52 +3,52 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test.describe('Keybindings', { tag: '@keyboard' }, () => {
test('Should execute command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {
window['foo'] = true
await comfyPage.command.registerCommand('TestCommand', () => {
window.foo = true
})
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
})
test('Should execute async command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', async () => {
await comfyPage.command.registerCommand('TestCommand', async () => {
await new Promise<void>((resolve) =>
setTimeout(() => {
window['foo'] = true
window.foo = true
resolve()
}, 5)
)
})
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
})
test('Should handle command errors', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {
await comfyPage.command.registerCommand('TestCommand', () => {
throw new Error('Test error')
})
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.getToastErrorCount()).toBe(1)
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
})
test('Should handle async command errors', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', async () => {
await new Promise<void>((resolve, reject) =>
await comfyPage.command.registerCommand('TestCommand', async () => {
await new Promise<void>((_resolve, reject) =>
setTimeout(() => {
reject(new Error('Test error'))
}, 5)
)
})
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.getToastErrorCount()).toBe(1)
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
})
})

View File

@@ -1,24 +1,31 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Copy Paste', () => {
test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
})
test('Can copy and paste node with link', async ({ comfyPage }) => {
await comfyPage.clickTextEncodeNode1()
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.clipboard.copy()
await comfyPage.page.keyboard.press('Control+Shift+V')
await expect(comfyPage.canvas).toHaveScreenshot('copied-node-with-link.png')
})
@@ -28,9 +35,9 @@ test.describe('Copy Paste', () => {
await textBox.click()
const originalString = await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC(null)
await comfyPage.ctrlV(null)
await comfyPage.ctrlV(null)
await comfyPage.clipboard.copy(null)
await comfyPage.clipboard.paste(null)
await comfyPage.clipboard.paste(null)
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
})
@@ -44,7 +51,7 @@ test.describe('Copy Paste', () => {
y: 281
}
})
await comfyPage.ctrlC(null)
await comfyPage.clipboard.copy(null)
// Empty latent node's width
await comfyPage.canvas.click({
position: {
@@ -52,7 +59,7 @@ test.describe('Copy Paste', () => {
y: 643
}
})
await comfyPage.ctrlV(null)
await comfyPage.clipboard.paste(null)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
})
@@ -63,15 +70,19 @@ test.describe('Copy Paste', () => {
test('Paste in text area with node previously copied', async ({
comfyPage
}) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC(null)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.clipboard.copy(null)
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC(null)
await comfyPage.ctrlV(null)
await comfyPage.ctrlV(null)
await comfyPage.clipboard.copy(null)
await comfyPage.clipboard.paste(null)
await comfyPage.clipboard.paste(null)
await expect(comfyPage.canvas).toHaveScreenshot(
'paste-in-text-area-with-node-previously-copied.png'
)
@@ -82,10 +93,10 @@ test.describe('Copy Paste', () => {
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC(null)
await comfyPage.clipboard.copy(null)
// Unfocus textbox.
await comfyPage.page.mouse.click(10, 10)
await comfyPage.ctrlV(null)
await comfyPage.clipboard.paste(null)
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
})
@@ -103,19 +114,19 @@ test.describe('Copy Paste', () => {
test('Can undo paste multiple nodes as single action', async ({
comfyPage
}) => {
const initialCount = await comfyPage.getGraphNodesCount()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
await comfyPage.canvas.click()
await comfyPage.ctrlA()
await comfyPage.keyboard.selectAll()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
const pasteCount = await comfyPage.getGraphNodesCount()
const pasteCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(pasteCount).toBe(initialCount * 2)
await comfyPage.ctrlZ()
const undoCount = await comfyPage.getGraphNodesCount()
await comfyPage.keyboard.undo()
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -22,9 +22,9 @@ async function verifyCustomIconSvg(iconElement: Locator) {
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
}
test.describe('Custom Icons', () => {
test.describe('Custom Icons', { tag: '@settings' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('sidebar tab icons use custom SVGs', async ({ comfyPage }) => {

View File

@@ -1,18 +1,19 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import type { Keybinding } from '../../src/platform/keybindings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load workflow warning', () => {
test.describe('Load workflow warning', { tag: '@ui' }, () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
@@ -22,7 +23,7 @@ test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes in subgraphs', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing/missing_nodes_in_subgraph')
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
@@ -36,22 +37,26 @@ test.describe('Load workflow warning', () => {
})
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.closeDialog()
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' })
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
// Make a change to the graph
await comfyPage.doubleClickCanvas()
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
// Undo and redo the change
await comfyPage.ctrlZ()
await comfyPage.keyboard.undo()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await comfyPage.ctrlY()
await comfyPage.keyboard.redo()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
})
@@ -59,7 +64,7 @@ test.describe('Execution error', () => {
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.queueButton.click()
await comfyPage.nextFrame()
@@ -71,7 +76,10 @@ test.describe('Execution error', () => {
test.describe('Missing models warning', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.page.evaluate((url: string) => {
return fetch(`${url}/api/devtools/cleanup_fake_model`)
}, comfyPage.url)
@@ -80,7 +88,7 @@ test.describe('Missing models warning', () => {
test('Should display a warning when missing models are found', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing/missing_models')
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
@@ -97,7 +105,9 @@ test.describe('Missing models warning', () => {
comfyPage
}) => {
// Load workflow that has a node with models metadata at the node level
await comfyPage.loadWorkflow('missing/missing_models_from_node_properties')
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_from_node_properties'
)
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
@@ -146,7 +156,7 @@ test.describe('Missing models warning', () => {
{ times: 1 }
)
await comfyPage.loadWorkflow('missing/missing_models')
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
@@ -157,7 +167,9 @@ test.describe('Missing models warning', () => {
}) => {
// This tests the scenario where outdated model metadata exists in the workflow
// but the actual selected models (widget values) have changed
await comfyPage.loadWorkflow('missing/model_metadata_widget_mismatch')
await comfyPage.workflow.loadWorkflow(
'missing/model_metadata_widget_mismatch'
)
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
@@ -171,7 +183,7 @@ test.describe('Missing models warning', () => {
}) => {
// The fake_model.safetensors is served by
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.loadWorkflow('missing/missing_models')
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
@@ -190,11 +202,11 @@ test.describe('Missing models warning', () => {
let closeButton: Locator
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting(
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.loadWorkflow('missing/missing_models')
await comfyPage.workflow.loadWorkflow('missing/missing_models')
checkbox = comfyPage.page.getByLabel("Don't show this again")
closeButton = comfyPage.page.getByLabel('Close')
@@ -210,7 +222,7 @@ test.describe('Missing models warning', () => {
await closeButton.click()
await changeSettingPromise
const settingValue = await comfyPage.getSetting(
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(false)
@@ -221,7 +233,7 @@ test.describe('Missing models warning', () => {
}) => {
await closeButton.click()
const settingValue = await comfyPage.getSetting(
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(true)
@@ -252,9 +264,11 @@ test.describe('Settings', () => {
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await test.step('Setting should persist', async () => {
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
expect(await comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
maxSpeed
)
})
})
@@ -311,7 +325,7 @@ test.describe('Support', () => {
test('Should open external zendesk link with OSS tag', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const pagePromise = comfyPage.page.context().waitForEvent('page')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
const newPage = await pagePromise
@@ -331,13 +345,13 @@ test.describe('Error dialog', () => {
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const graph = window['graph']
graph.configure = () => {
const graph = window.graph!
;(graph as { configure: () => void }).configure = () => {
throw new Error('Error on configure!')
}
})
await comfyPage.loadWorkflow('default')
await comfyPage.workflow.loadWorkflow('default')
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
@@ -347,7 +361,7 @@ test.describe('Error dialog', () => {
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window['app']
const app = window.app!
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
@@ -362,9 +376,13 @@ test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage
}) => {
const nodeNum = (await comfyPage.getNodes()).length
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
const textBox = comfyPage.widgetTextBox
await textBox.click()
@@ -373,7 +391,7 @@ test.describe('Signin dialog', () => {
await textBox.press('Control+c')
await comfyPage.page.evaluate(() => {
void window['app'].extensionManager.dialog.showSignInDialog()
void window.app!.extensionManager.dialog.showSignInDialog()
})
const input = comfyPage.page.locator('#comfy-org-sign-in-password')
@@ -381,6 +399,6 @@ test.describe('Signin dialog', () => {
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.getNodes()).toHaveLength(nodeNum)
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
})
})

View File

@@ -3,12 +3,12 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('DOM Widget', () => {
test.describe('DOM Widget', { tag: '@widget' }, () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible()
})
@@ -21,7 +21,7 @@ test.describe('DOM Widget', () => {
await expect(firstMultiline).toBeVisible()
await expect(lastMultiline).toBeVisible()
const nodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
for (const node of nodes) {
await node.click('collapse')
}
@@ -29,12 +29,16 @@ test.describe('DOM Widget', () => {
await expect(lastMultiline).not.toBeVisible()
})
test('Position update when entering focus mode', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
})
test(
'Position update when entering focus mode',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
}
)
// No DOM widget should be created by creation of interim LGraphNode objects.
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
@@ -64,9 +68,9 @@ test.describe('DOM Widget', () => {
.first()
await expect(textareaWidget).toBeVisible()
await comfyPage.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.nextFrame()
let oldPos: [number, number]
@@ -81,15 +85,15 @@ test.describe('DOM Widget', () => {
// --- test ---
await comfyPage.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.nextFrame()
await checkBboxChange()
})

View File

@@ -3,43 +3,54 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Execution', () => {
test('Report error on unconnected slot', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await comfyPage.clickEmptySpace()
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
test(
'Report error on unconnected slot',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page.locator('.p-dialog-close-button').click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)
})
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page
.locator('.p-dialog')
.getByRole('button', { name: 'Close' })
.click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)
}
)
})
test.describe('Execute to selected output nodes', () => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/partial_execution')
const input = await comfyPage.getNodeRefById(3)
const output1 = await comfyPage.getNodeRefById(1)
const output2 = await comfyPage.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
await output1.click('title')
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
test.describe(
'Execute to selected output nodes',
{ tag: ['@smoke', '@workflow'] },
() => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
}).toPass({ timeout: 2_000 })
})
})
await output1.click('title')
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
}).toPass({ timeout: 2_000 })
})
}
)

View File

@@ -1,23 +1,32 @@
import { expect } from '@playwright/test'
import type { Settings } from '../../src/schemas/apiSchema'
import type { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
/**
* Type helper for test settings with arbitrary IDs.
* Extensions can register settings with any ID, but SettingParams.id
* is typed as keyof Settings for autocomplete. This helper allows
* arbitrary IDs in tests while keeping type safety for other fields.
*/
type TestSettingId = keyof Settings
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
window['foo'] = true
window.foo = true
}
}
],
@@ -31,15 +40,15 @@ test.describe('Topbar commands', () => {
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
})
test('Should not allow register command defined in other extension', async ({
comfyPage
}) => {
await comfyPage.registerCommand('foo', () => alert(1))
await comfyPage.command.registerCommand('foo', () => alert(1))
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
menuCommands: [
{
@@ -56,14 +65,14 @@ test.describe('Topbar commands', () => {
test('Should allow registering keybindings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const app = window['app']
const app = window.app!
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'TestCommand',
function: () => {
window['TestCommand'] = true
window.TestCommand = true
}
}
],
@@ -77,68 +86,77 @@ test.describe('Topbar commands', () => {
})
await comfyPage.page.keyboard.press('k')
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
true
)
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
test.describe('Settings', () => {
test('Should allow adding settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
// Extensions can register arbitrary setting IDs
id: 'TestSetting' as TestSettingId,
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!',
onChange: () => {
window['changeCount'] = (window['changeCount'] ?? 0) + 1
window.changeCount = (window.changeCount ?? 0) + 1
}
}
]
})
})
// onChange is called when the setting is first added
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
'Hello, world!'
)
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
'Hello, universe!'
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
})
test('Should allow setting boolean settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'Comfy.TestSetting',
// Extensions can register arbitrary setting IDs
id: 'Comfy.TestSetting' as TestSettingId,
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
onChange: () => {
window['changeCount'] = (window['changeCount'] ?? 0) + 1
window.changeCount = (window.changeCount ?? 0) + 1
}
}
]
})
})
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
false
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
true
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
})
test.describe('Passing through attrs to setting components', () => {
const testCases: Array<{
config: Partial<SettingParams>
config: Pick<SettingParams, 'type' | 'defaultValue'> &
Partial<Omit<SettingParams, 'id' | 'type' | 'defaultValue'>>
selector: string
}> = [
{
@@ -191,13 +209,13 @@ test.describe('Topbar commands', () => {
comfyPage
}) => {
await comfyPage.page.evaluate((config) => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'Comfy.TestSetting',
// Extensions can register arbitrary setting IDs
id: 'Comfy.TestSetting' as TestSettingId,
name: 'Test',
// The `disabled` attr is common to all settings components
attrs: { disabled: true },
...config
}
@@ -224,7 +242,7 @@ test.describe('Topbar commands', () => {
test.describe('About panel', () => {
test('Should allow adding badges', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
aboutPageBadges: [
{
@@ -247,61 +265,71 @@ test.describe('Topbar commands', () => {
test.describe('Dialog', () => {
test('Should allow showing a prompt dialog', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
void window['app'].extensionManager.dialog
.prompt({
void window
.app!.extensionManager.dialog.prompt({
title: 'Test Prompt',
message: 'Test Prompt Message'
})
.then((value: string) => {
window['value'] = value
.then((value: string | null) => {
;(window as unknown as Record<string, unknown>)['value'] = value
})
})
await comfyPage.fillPromptDialog('Hello, world!')
expect(await comfyPage.page.evaluate(() => window['value'])).toBe(
'Hello, world!'
)
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
).toBe('Hello, world!')
})
test('Should allow showing a confirmation dialog', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
void window['app'].extensionManager.dialog
.confirm({
void window
.app!.extensionManager.dialog.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'
})
.then((value: boolean) => {
window['value'] = value
.then((value: boolean | null) => {
;(window as unknown as Record<string, unknown>)['value'] = value
})
})
await comfyPage.confirmDialog.click('confirm')
expect(await comfyPage.page.evaluate(() => window['value'])).toBe(true)
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
).toBe(true)
})
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['value'] = 'foo'
void window['app'].extensionManager.dialog
.confirm({
;(window as unknown as Record<string, unknown>)['value'] = 'foo'
void window
.app!.extensionManager.dialog.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'
})
.then((value: boolean) => {
window['value'] = value
.then((value: boolean | null) => {
;(window as unknown as Record<string, unknown>)['value'] = value
})
})
await comfyPage.confirmDialog.click('reject')
expect(await comfyPage.page.evaluate(() => window['value'])).toBeNull()
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
).toBeNull()
})
})
test.describe('Selection Toolbox', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('Should allow adding commands to selection toolbox', async ({
@@ -309,7 +337,7 @@ test.describe('Topbar commands', () => {
}) => {
// Register an extension with a selection toolbox command
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
@@ -317,7 +345,9 @@ test.describe('Topbar commands', () => {
label: 'Test Command',
icon: 'pi pi-star',
function: () => {
window['selectionCommandExecuted'] = true
;(window as unknown as Record<string, unknown>)[
'selectionCommandExecuted'
] = true
}
}
],
@@ -325,7 +355,7 @@ test.describe('Topbar commands', () => {
})
})
await comfyPage.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
// Click the command button in the selection toolbox
const toolboxButton = comfyPage.page.locator(
@@ -333,9 +363,13 @@ test.describe('Topbar commands', () => {
)
await toolboxButton.click()
// Verify the command was executed
expect(
await comfyPage.page.evaluate(() => window['selectionCommandExecuted'])
await comfyPage.page.evaluate(
() =>
(window as unknown as Record<string, unknown>)[
'selectionCommandExecuted'
]
)
).toBe(true)
})
})

View File

@@ -3,10 +3,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Feature Flags', () => {
test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage
}) => {
@@ -25,7 +25,7 @@ test.describe('Feature Flags', () => {
const originalSend = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
try {
const parsed = JSON.parse(data)
const parsed = JSON.parse(data as string)
if (parsed.type === 'feature_flags') {
window.__capturedMessages!.clientFeatureFlags = parsed
}
@@ -38,11 +38,11 @@ test.describe('Feature Flags', () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
window.app?.api?.serverFeatureFlags &&
Object.keys(window.app.api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window['app'].api.serverFeatureFlags
window.app.api.serverFeatureFlags
clearInterval(checkInterval)
}
}, 100)
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverFeatureFlags
return window.app!.api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
@@ -115,26 +115,22 @@ test.describe('Feature Flags', () => {
}) => {
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverSupportsFeature(
'supports_preview_metadata'
)
return window.app!.api.serverSupportsFeature('supports_preview_metadata')
})
// The method should return a boolean based on the backend's value
expect(typeof supportsPreviewMetadata).toBe('boolean')
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverSupportsFeature(
'non_existent_feature_xyz'
)
return window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window['app']!.api.serverFeatureFlags
window['app']!.api.serverFeatureFlags = {
const original = window.app!.api.serverFeatureFlags
window.app!.api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -143,15 +139,15 @@ test.describe('Feature Flags', () => {
}
const results = {
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
string_value: window['app']!.api.serverSupportsFeature('string_value'),
number_value: window['app']!.api.serverSupportsFeature('number_value'),
null_value: window['app']!.api.serverSupportsFeature('null_value')
bool_true: window.app!.api.serverSupportsFeature('bool_true'),
bool_false: window.app!.api.serverSupportsFeature('bool_false'),
string_value: window.app!.api.serverSupportsFeature('string_value'),
number_value: window.app!.api.serverSupportsFeature('number_value'),
null_value: window.app!.api.serverSupportsFeature('null_value')
}
// Restore original
window['app']!.api.serverFeatureFlags = original
window.app!.api.serverFeatureFlags = original
return results
})
@@ -168,20 +164,20 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature('supports_preview_metadata')
return window.app!.api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature('max_upload_size')
return window.app!.api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature(
return window.app!.api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
@@ -194,7 +190,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeatures()
return window.app!.api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
@@ -207,14 +203,14 @@ test.describe('Feature Flags', () => {
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window['app']!.api.getClientFeatureFlags()
const flags2 = window['app']!.api.getClientFeatureFlags()
const flags1 = window.app!.api.getClientFeatureFlags()
const flags2 = window.app!.api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window['app']!.api.getClientFeatureFlags()
const flags3 = window.app!.api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
@@ -240,14 +236,14 @@ test.describe('Feature Flags', () => {
}) => {
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window['app']!.api.getServerFeatures()
const features1 = window.app!.api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window['app']!.api.getServerFeatures()
const features2 = window.app!.api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
@@ -286,35 +282,26 @@ test.describe('Feature Flags', () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
window.__appReadiness = {
...window.__appReadiness,
featureFlagsReceived: true
}
window.__appReadiness!.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
}
}, 10)
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
window.__appReadiness = {
...window.__appReadiness,
apiInitialized: true
}
if (window.app?.api) {
window.__appReadiness!.apiInitialized = true
clearInterval(checkApi)
}
}, 10)
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
window.__appReadiness = {
...window.__appReadiness,
appInitialized: true
}
if (window.app?.graph) {
window.__appReadiness!.appInitialized = true
clearInterval(checkApp)
}
}, 10)
@@ -333,7 +320,7 @@ test.describe('Feature Flags', () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
{
timeout: 10000
@@ -344,7 +331,7 @@ test.describe('Feature Flags', () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window['app']!.api.serverFeatureFlags
currentFlags: window.app!.api.serverFeatureFlags
}
})

View File

@@ -3,24 +3,24 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph', () => {
test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Should be able to fix link input slot index after swap the input order
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
test('Fix link input slots', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('inputs/input_order_swap')
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
expect(
await comfyPage.page.evaluate(() => {
return window['app'].graph.links.get(1)?.target_slot
return window.app!.graph!.links.get(1)?.target_slot
})
).toBe(1)
})
test('Validate workflow links', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.loadWorkflow('links/bad_link')
await expect(comfyPage.getVisibleToastCount()).resolves.toBe(2)
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
})
})

View File

@@ -1,48 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph Canvas Menu', () => {
test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Set link render mode to spline to make sure it's not affected by other tests'
// side effects.
await comfyPage.setSetting('Comfy.LinkRenderMode', 2)
await comfyPage.settings.setSetting('Comfy.LinkRenderMode', 2)
// Enable canvas menu for all tests
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
})
test('Can toggle link visibility', async ({ comfyPage }) => {
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window['LiteGraph'].HIDDEN_LINK
})
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
test(
'Can toggle link visibility',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const button = comfyPage.page.getByTestId(
TestIds.canvas.toggleLinkVisibilityButton
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window.LiteGraph!.HIDDEN_LINK
})
expect(await comfyPage.settings.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
hiddenLinkRenderMode
)
})
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(
await comfyPage.settings.getSetting('Comfy.LinkRenderMode')
).not.toBe(hiddenLinkRenderMode)
}
)
test('Toggle minimap button is clickable and has correct test id', async ({
comfyPage
}) => {
const minimapButton = comfyPage.page.getByTestId('toggle-minimap-button')
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(minimapButton).toBeVisible()
await expect(minimapButton).toBeEnabled()

View File

@@ -1,42 +1,50 @@
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 type { NodeLibrarySidebarTab } from '../fixtures/components/SidebarTab'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Group Node', () => {
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab
let libraryTab: NodeLibrarySidebarTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
expect(await libraryTab.getFolder(groupNodeCategory).count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialNodeCount + 1
)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
@@ -48,7 +56,7 @@ test.describe('Group Node', () => {
// Verify the node is added to the bookmarks tab
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([groupNodeBookmarkName])
// Verify the bookmark node with the same name is added to the tree
expect(await libraryTab.getNode(groupNodeName).count()).not.toBe(0)
@@ -62,7 +70,7 @@ test.describe('Group Node', () => {
// Verify the node is removed from the bookmarks tab
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toHaveLength(0)
})
@@ -89,20 +97,24 @@ test.describe('Group Node', () => {
// does not have a v-model on the query, so we cannot observe the raw
// query update, and thus cannot set the spinning state between the raw query
// update and the debounced search update.
test.skip('Can be added to canvas using search', async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
})
test.skip(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
}
)
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
@@ -110,9 +122,9 @@ test.describe('Group Node', () => {
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
const node1 = (await comfyPage.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.getNodeRefsByType(type2))[0]
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
await node2.click('title', {
modifiers: ['Shift']
@@ -140,7 +152,7 @@ test.describe('Group Node', () => {
test('Preserves hidden input configuration when containing duplicate node types', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow(
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
@@ -151,16 +163,14 @@ test.describe('Group Node', () => {
const totalInputCount = await comfyPage.page.evaluate((nodeName) => {
const {
extra: { groupNodes }
} = window['app'].graph
const { nodes } = groupNodes[nodeName]
return nodes.reduce((acc: number, node) => {
return acc + node.inputs.length
}, 0)
} = window.app!.graph!
const { nodes } = groupNodes![nodeName]
return nodes.reduce((acc, node) => acc + (node.inputs?.length ?? 0), 0)
}, groupNodeName)
const visibleInputCount = await comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return node.inputs.length
const node = window.app!.graph!.getNodeById(id)
return node!.inputs.length
}, groupNodeId)
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
@@ -174,7 +184,7 @@ test.describe('Group Node', () => {
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.getNodeRefsByType(type)
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
@@ -209,8 +219,8 @@ test.describe('Group Node', () => {
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('groupnodes/legacy_group_node')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
@@ -226,7 +236,7 @@ test.describe('Group Node', () => {
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window['LiteGraph'].registered_node_types[nodeType]
return !!window.LiteGraph!.registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
@@ -242,17 +252,17 @@ test.describe('Group Node', () => {
comfyPage: ComfyPage,
expectedCount: number
) => {
expect(await comfyPage.getNodeRefsByType(GROUP_NODE_TYPE)).toHaveLength(
expectedCount
)
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow(WORKFLOW_NAME)
groupNode = await comfyPage.getFirstNodeRef()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
await groupNode.copy()
@@ -261,7 +271,7 @@ test.describe('Group Node', () => {
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {
await comfyPage.ctrlV()
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 2)
})
@@ -269,12 +279,12 @@ test.describe('Group Node', () => {
comfyPage
}) => {
// Set setting
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.ctrlV()
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -282,15 +292,15 @@ test.describe('Group Node', () => {
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.ctrlV()
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node across different workflows', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('default')
await comfyPage.ctrlV()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -298,14 +308,15 @@ test.describe('Group Node', () => {
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.ctrlV()
await comfyPage.clipboard.paste()
const currentGraphState = await comfyPage.page.evaluate(() =>
window['app'].graph.serialize()
window.app!.graph!.serialize()
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) => window['app'].loadGraphData(workflow),
(workflow) =>
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
currentGraphState
)
await comfyPage.nextFrame()
@@ -316,15 +327,18 @@ test.describe('Group Node', () => {
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
await comfyPage.page.keyboard.press('Alt+g')
expect(await comfyPage.getVisibleToastCount()).toBe(1)
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.clickTextEncodeNode1()
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Alt+g')
expect(await comfyPage.getVisibleToastCount()).toBe(1)
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
})
})
})

View File

@@ -7,15 +7,17 @@ import {
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Item Interaction', () => {
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can select/delete all items', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/mixed_graph_items')
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await expect(comfyPage.canvas).toHaveScreenshot('selected-all.png')
await comfyPage.canvas.press('Delete')
@@ -23,7 +25,7 @@ test.describe('Item Interaction', () => {
})
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/mixed_graph_items')
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
@@ -51,22 +53,34 @@ test.describe('Node Interaction', () => {
test(`Can add multiple nodes to selection using ${modifier}+Click`, async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
for (const node of clipNodes) {
await node.click('title', { modifiers: [modifier] })
}
const selectedNodeCount = await comfyPage.getSelectedGraphNodesCount()
const selectedNodeCount =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedNodeCount).toBe(clipNodes.length)
})
})
test('@2x Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
test(
'@2x Can highlight selected',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode2
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
}
)
const dragSelectNodes = async (
comfyPage: ComfyPage,
@@ -76,7 +90,7 @@ test.describe('Node Interaction', () => {
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.page.keyboard.down('Meta')
await comfyPage.dragAndDrop(
await comfyPage.canvasOps.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
@@ -90,9 +104,10 @@ test.describe('Node Interaction', () => {
}
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
@@ -100,7 +115,8 @@ test.describe('Node Interaction', () => {
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
@@ -112,7 +128,7 @@ test.describe('Node Interaction', () => {
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.executeCommand(
await comfyPage.command.executeCommand(
`Comfy.Canvas.MoveSelectedNodes.${direction}`
)
await comfyPage.canvas.press(`Control+Arrow${direction}`)
@@ -150,15 +166,21 @@ test.describe('Node Interaction', () => {
})
})
test('Can drag node', async ({ comfyPage }) => {
await comfyPage.dragNode2()
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test.describe('Edge Interaction', () => {
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'no action'
)
})
// Test both directions of edge connection.
@@ -166,11 +188,11 @@ test.describe('Node Interaction', () => {
test(`Can disconnect/connect edge ${reverse ? 'reverse' : 'normal'}`, async ({
comfyPage
}) => {
await comfyPage.disconnectEdge()
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge({ reverse })
await comfyPage.canvasOps.connectEdge({ reverse })
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
await comfyPage.canvasOps.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
@@ -179,14 +201,14 @@ test.describe('Node Interaction', () => {
})
test('Can move link', async ({ comfyPage }) => {
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputSlot,
comfyPage.clipTextEncodeNode1InputSlot
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode2InputSlot,
DefaultGraphPositions.clipTextEncodeNode1InputSlot
)
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
})
@@ -195,15 +217,15 @@ test.describe('Node Interaction', () => {
test.skip('Can copy link by shift-drag existing link', async ({
comfyPage
}) => {
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputLinkPath,
comfyPage.clipTextEncodeNode1InputSlot
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode2InputLinkPath,
DefaultGraphPositions.clipTextEncodeNode1InputSlot
)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
@@ -213,22 +235,28 @@ test.describe('Node Interaction', () => {
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyMouse.move(comfyPage.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(comfyPage.clipTextEncodeNode2InputSlot)
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
await expect(comfyPage.canvas).toHaveScreenshot('snapped-highlighted.png')
})
})
test('Can adjust widget value', async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png')
})
test(
'Can adjust widget value',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nodeOps.adjustEmptyLatentWidth()
await expect(comfyPage.canvas).toHaveScreenshot(
'adjusted-widget-value.png'
)
}
)
test('Link snap to slot', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/snap_to_slot')
test('Link snap to slot', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('links/snap_to_slot')
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
const outputSlotPos = {
@@ -239,62 +267,78 @@ test.describe('Node Interaction', () => {
x: 748,
y: 77
}
await comfyPage.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await comfyPage.canvasOps.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
})
test('Can batch move links by drag with shift', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/batch_move_links')
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
test(
'Can batch move links by drag with shift',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
const outputSlot1Pos = {
x: 304,
y: 127
const outputSlot1Pos = {
x: 304,
y: 127
}
const outputSlot2Pos = {
x: 307,
y: 310
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot(
'batch_move_links_moved.png'
)
}
const outputSlot2Pos = {
x: 307,
y: 310
)
test(
'Can batch disconnect links with ctrl+alt+click',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const loadCheckpointClipSlotPos = {
x: 332,
y: 508
}
await comfyPage.canvas.click({
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'batch-disconnect-links-disconnected.png'
)
}
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot(
'batch_move_links_moved.png'
)
})
test('Can batch disconnect links with ctrl+alt+click', async ({
comfyPage
}) => {
const loadCheckpointClipSlotPos = {
x: 332,
y: 508
test(
'Can toggle dom widget node open/closed',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
}
await comfyPage.canvas.click({
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'batch-disconnect-links-disconnected.png'
)
})
test('Can toggle dom widget node open/closed', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
})
)
test('Can close prompt dialog with canvas click (number widget)', async ({
comfyPage
@@ -325,7 +369,7 @@ test.describe('Node Interaction', () => {
x: 167,
y: 143
}
await comfyPage.loadWorkflow('nodes/single_save_image_node')
await comfyPage.workflow.loadWorkflow('nodes/single_save_image_node')
await comfyPage.canvas.click({
position: textWidgetPos
})
@@ -341,24 +385,28 @@ test.describe('Node Interaction', () => {
await expect(legacyPrompt).toBeHidden()
})
test('Can double click node title to edit', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
y: 10
},
delay: 5
})
await comfyPage.page.keyboard.type('Hello World')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png')
})
test(
'Can double click node title to edit',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
y: 10
},
delay: 5
})
await comfyPage.page.keyboard.type('Hello World')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png')
}
)
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
@@ -369,54 +417,75 @@ test.describe('Node Interaction', () => {
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
})
test('Can group selected nodes', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.GroupSelectedNodes.Padding', 10)
await comfyPage.select2Nodes()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.press('KeyG')
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('group-selected-nodes.png')
})
test(
'Can group selected nodes',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
10
)
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.press('KeyG')
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-selected-nodes.png'
)
}
)
test('Can fit group to contents', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/oversized_group')
await comfyPage.ctrlA()
await comfyPage.nextFrame()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('group-fit-to-contents.png')
})
test(
'Can fit group to contents',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await comfyPage.keyboard.selectAll()
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-fit-to-contents.png'
)
}
)
test('Can pin/unpin nodes', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await comfyPage.executeCommand('Comfy.Canvas.ToggleSelectedNodes.Pin')
test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.command.executeCommand(
'Comfy.Canvas.ToggleSelectedNodes.Pin'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-pinned.png')
await comfyPage.executeCommand('Comfy.Canvas.ToggleSelectedNodes.Pin')
await comfyPage.command.executeCommand(
'Comfy.Canvas.ToggleSelectedNodes.Pin'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
})
test('Can bypass/unbypass nodes with keyboard shortcut', async ({
comfyPage
}) => {
await comfyPage.select2Nodes()
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
})
test(
'Can bypass/unbypass nodes with keyboard shortcut',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
}
)
})
test.describe('Group Interaction', () => {
test.describe('Group Interaction', { tag: '@screenshot' }, () => {
test('Can double click group title to edit', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/single_group')
await comfyPage.workflow.loadWorkflow('groups/single_group')
await comfyPage.canvas.dblclick({
position: {
x: 50,
@@ -430,18 +499,18 @@ test.describe('Group Interaction', () => {
})
})
test.describe('Canvas Interaction', () => {
test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out', async ({ comfyPage }) => {
await comfyPage.zoom(-100)
await comfyPage.canvasOps.zoom(-100)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
await comfyPage.zoom(200)
await comfyPage.canvasOps.zoom(200)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
})
test('Can zoom very far out', async ({ comfyPage }) => {
await comfyPage.zoom(100, 12)
await comfyPage.canvasOps.zoom(100, 12)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-very-far-out.png')
await comfyPage.zoom(-100, 12)
await comfyPage.canvasOps.zoom(-100, 12)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-back-in.png')
})
@@ -450,11 +519,11 @@ test.describe('Canvas Interaction', () => {
}) => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
await comfyPage.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
await comfyPage.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-default-ctrl-shift.png'
)
@@ -465,35 +534,35 @@ test.describe('Canvas Interaction', () => {
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.05)
await comfyPage.zoom(-100, 4)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.05)
await comfyPage.canvasOps.zoom(-100, 4)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-low-zoom-speed.png'
)
await comfyPage.zoom(100, 8)
await comfyPage.canvasOps.zoom(100, 8)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-out-low-zoom-speed.png'
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('Can zoom in/out after increasing canvas zoom speed', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.5)
await comfyPage.zoom(-100, 4)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.5)
await comfyPage.canvasOps.zoom(-100, 4)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-high-zoom-speed.png'
)
await comfyPage.zoom(100, 8)
await comfyPage.canvasOps.zoom(100, 8)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-out-high-zoom-speed.png'
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('Can pan', async ({ comfyPage }) => {
await comfyPage.pan({ x: 200, y: 200 })
await comfyPage.canvasOps.pan({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
@@ -564,9 +633,9 @@ test.describe('Canvas Interaction', () => {
})
test('Can pan when dragging a link', async ({ comfyPage, comfyMouse }) => {
const posSlot1 = comfyPage.clipTextEncodeNode1InputSlot
const posSlot1 = DefaultGraphPositions.clipTextEncodeNode1InputSlot
await comfyMouse.move(posSlot1)
const posEmpty = comfyPage.emptySpace
const posEmpty = DefaultGraphPositions.emptySpace
await comfyMouse.drag(posEmpty)
await expect(comfyPage.canvas).toHaveScreenshot('dragging-link1.png')
@@ -585,23 +654,23 @@ test.describe('Canvas Interaction', () => {
test('Can pan very far and back', async ({ comfyPage }) => {
// intentionally slice the edge of where the clip text encode dom widgets are
await comfyPage.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
await comfyPage.canvasOps.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-one.png')
await comfyPage.pan({ x: -200, y: 0 }, { x: 1000, y: 10 })
await comfyPage.canvasOps.pan({ x: -200, y: 0 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-two.png')
await comfyPage.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 })
await comfyPage.canvasOps.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-far-away.png')
await comfyPage.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 })
await comfyPage.canvasOps.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-from-far.png')
await comfyPage.pan({ x: 200, y: 0 }, { x: 1000, y: 10 })
await comfyPage.canvasOps.pan({ x: 200, y: 0 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-two.png')
await comfyPage.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await comfyPage.canvasOps.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png')
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.closeMenu()
await comfyPage.panWithTouch({ x: 200, y: 200 })
await comfyPage.canvasOps.panWithTouch({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-touch.png')
})
})
@@ -614,41 +683,41 @@ test.describe('Widget Interaction', () => {
await expect(textBox).toHaveValue('')
await textBox.fill('Hello World')
await expect(textBox).toHaveValue('Hello World')
await comfyPage.ctrlZ(null)
await comfyPage.keyboard.undo(null)
await expect(textBox).toHaveValue('')
})
test('Undo attention edit', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EditAttention.Delta', 0.05)
await comfyPage.settings.setSetting('Comfy.EditAttention.Delta', 0.05)
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('1girl')
await expect(textBox).toHaveValue('1girl')
await textBox.selectText()
await comfyPage.ctrlArrowUp(null)
await comfyPage.keyboard.moveUp(null)
await expect(textBox).toHaveValue('(1girl:1.05)')
await comfyPage.ctrlZ(null)
await comfyPage.keyboard.undo(null)
await expect(textBox).toHaveValue('1girl')
})
})
test.describe('Load workflow', () => {
test.describe('Load workflow', { tag: '@screenshot' }, () => {
test('Can load workflow with string node id', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/string_node_id')
await comfyPage.workflow.loadWorkflow('nodes/string_node_id')
await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png')
})
test('Can load workflow with ("STRING",) input node', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('inputs/string_input')
await comfyPage.workflow.loadWorkflow('inputs/string_input')
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png')
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png')
@@ -657,10 +726,10 @@ test.describe('Load workflow', () => {
test('Restore workflow on reload (modify workflow)', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
const node = (await comfyPage.getFirstNodeRef())!
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('collapse')
await comfyPage.clickEmptySpace()
await comfyPage.canvasOps.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
@@ -678,7 +747,7 @@ test.describe('Load workflow', () => {
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
@@ -696,7 +765,7 @@ test.describe('Load workflow', () => {
test('Restores topbar workflow tabs after reload', async ({
comfyPage
}) => {
await comfyPage.setSetting(
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
@@ -709,7 +778,7 @@ test.describe('Load workflow', () => {
})
test('Restores sidebar workflows after reload', async ({ comfyPage }) => {
await comfyPage.setSetting(
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
@@ -732,34 +801,40 @@ test.describe('Load workflow', () => {
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableWorkflowViewRestore', false)
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',
false
)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler_fit.png')
})
})
test.describe('Load duplicate workflow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('A workflow can be loaded multiple times in a row', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.menu.workflowsTab.open()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.loadWorkflow('nodes/single_ksampler')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
})
})
test.describe('Viewport settings', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setupWorkflowsDirectory({})
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Keeps viewport settings when changing tabs', async ({
@@ -769,7 +844,7 @@ test.describe('Viewport settings', () => {
const changeTab = async (tab: Locator) => {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(comfyPage.emptySpace)
await comfyMouse.move(DefaultGraphPositions.emptySpace)
// If tooltip is visible, wait for it to hide
await expect(
@@ -778,11 +853,13 @@ test.describe('Viewport settings', () => {
}
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await toggleButton.click()
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await comfyPage.nextFrame()
@@ -799,7 +876,7 @@ test.describe('Viewport settings', () => {
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
await changeTab(tabB)
await comfyMouse.move(comfyPage.emptySpace)
await comfyMouse.move(DefaultGraphPositions.emptySpace)
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
@@ -824,16 +901,22 @@ test.describe('Viewport settings', () => {
})
})
test.describe('Canvas Navigation', () => {
test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test.describe('Legacy Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
)
})
test('Left-click drag in empty area should pan canvas', async ({
comfyPage
}) => {
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.canvasOps.dragAndDrop(
{ x: 50, y: 50 },
{ x: 150, y: 150 }
)
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-left-drag-pan.png'
)
@@ -866,8 +949,11 @@ test.describe('Canvas Navigation', () => {
})
test('Left-click on node should not pan canvas', async ({ comfyPage }) => {
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-click-node-select.png'
@@ -877,18 +963,22 @@ test.describe('Canvas Navigation', () => {
test.describe('Standard Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'standard')
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
})
test('Left-click drag in empty area should select nodes', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.dragAndDrop(
await comfyPage.canvasOps.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
@@ -899,7 +989,7 @@ test.describe('Canvas Navigation', () => {
}
)
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCount).toBe(clipNodes.length)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-left-drag-select.png'
@@ -939,8 +1029,11 @@ test.describe('Canvas Navigation', () => {
test('Left-click on node should select node (not start selection box)', async ({
comfyPage
}) => {
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-click-node-select.png'
@@ -953,7 +1046,10 @@ test.describe('Canvas Navigation', () => {
await comfyPage.nextFrame()
await comfyPage.page.keyboard.down('Space')
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.canvasOps.dragAndDrop(
{ x: 50, y: 50 },
{ x: 150, y: 150 }
)
await comfyPage.page.keyboard.up('Space')
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-space-drag-pan.png'
@@ -963,11 +1059,12 @@ test.describe('Canvas Navigation', () => {
test('Space key overrides default left-click behavior', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const offset = 64
await comfyPage.dragAndDrop(
await comfyPage.canvasOps.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
@@ -979,16 +1076,16 @@ test.describe('Canvas Navigation', () => {
)
const selectedCountAfterDrag =
await comfyPage.getSelectedGraphNodesCount()
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCountAfterDrag).toBeGreaterThan(0)
await comfyPage.clickEmptySpace()
await comfyPage.canvasOps.clickEmptySpace()
const selectedCountAfterClear =
await comfyPage.getSelectedGraphNodesCount()
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCountAfterClear).toBe(0)
await comfyPage.page.keyboard.down('Space')
await comfyPage.dragAndDrop(
await comfyPage.canvasOps.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
@@ -1001,7 +1098,7 @@ test.describe('Canvas Navigation', () => {
await comfyPage.page.keyboard.up('Space')
const selectedCountAfterSpaceDrag =
await comfyPage.getSelectedGraphNodesCount()
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCountAfterSpaceDrag).toBe(0)
})
})
@@ -1009,7 +1106,10 @@ test.describe('Canvas Navigation', () => {
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning')
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
)
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
@@ -1047,11 +1147,17 @@ test.describe('Canvas Navigation', () => {
test('Multiple modifier keys work correctly in legacy mode', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.canvasOps.dragAndDrop(
{ x: 50, y: 50 },
{ x: 150, y: 150 }
)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.page.keyboard.up('Alt')
@@ -1071,7 +1177,10 @@ test.describe('Canvas Navigation', () => {
})
}
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
)
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')

Some files were not shown because too many files have changed in this diff Show More