Compare commits

..

31 Commits

Author SHA1 Message Date
Comfy Org PR Bot
62175277fc [backport cloud/1.34] fix: remove custom LoRA from subscription benefits display (#7398)
Backport of #7396 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 15:58:27 -08:00
Comfy Org PR Bot
e907545e39 [backport cloud/1.34] fix: remove custom LoRA feature from standard tier (#7392)
Backport of #7391 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7392-backport-cloud-1-34-fix-remove-custom-LoRA-feature-from-standard-tier-2c66d73d3650814fb37de338ce016931)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 14:51:50 -08:00
Comfy Org PR Bot
44a1c7a194 [backport cloud/1.34] increase some API nodes pricing (#7386)
Backport of #7156 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7386-backport-cloud-1-34-increase-some-API-nodes-pricing-2c66d73d365081b68fe5d12062d7f0f5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2025-12-11 14:18:26 -05:00
Comfy Org PR Bot
a1086d5df8 [backport cloud/1.34] fix: remove incorrect tooltip on remaining credit balance (#7384)
Backport of #7383 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 10:56:05 -08:00
Christian Byrne
350044dd91 [backport cloud/1.34] remove useless/misleading toast in topup dialog (#7380)
## Summary
Backports the fix from main that removes the misleading "purchase
successful" toast that appears immediately after clicking the buy button
in the credit top-up dialog.

## Details
- Purchase actually happens on Stripe page, not immediately after
clicking
- Toast was confusing users into thinking purchase completed when it
hadn't
- Only error toast remains for actual failures
- Fixed merge conflict in `useCoreCommands.ts` by keeping cloud/1.34's
`SubgraphNode` import

## Related
- Original PR: #7375 
- Cherry-picked from: 29af56c154

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7380-backport-cloud-1-34-remove-useless-misleading-toast-in-topup-dialog-2c66d73d365081a1b46bdce6e3860a86)
by [Unito](https://www.unito.io)

Co-authored-by: GitHub Action <action@github.com>
2025-12-11 08:22:09 -07:00
Comfy Org PR Bot
d987d08ac9 [backport cloud/1.34] fix: consistent subscription dialog width (#7379)
Backport of #7378 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7379-backport-cloud-1-34-fix-consistent-subscription-dialog-width-2c66d73d36508178a944d2dcf8b2e275)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-11 08:09:11 -07:00
Christian Byrne
719ebe1189 [backport cloud/1.34] Improve import model copy and examples (#7372)
## Summary
Backport of #7339 to cloud/1.34

Updates user-facing copy in the import model feature for clarity and
better examples.

## Changes
- **Example Link**: Changed from direct download URL to model page URL
(easier to find and copy)
- **Success Message**: Removed emoji for more professional tone
- **Support Documentation**: Updated Civitai link to include `/models`
path

Original PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7339

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7372-backport-cloud-1-34-docs-Improve-import-model-copy-and-examples-2c66d73d365081b5ae9bd59d25b0ce65)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 06:27:17 -07:00
Comfy Org PR Bot
5c24bb2258 [backport cloud/1.34] fix: hardcoded color tokens (not theme-aware) (#7368)
Backport of #7366 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7368-backport-cloud-1-34-fix-hardcoded-color-tokens-not-theme-aware-2c66d73d365081b8a75ec5e12e9c6061)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-11 04:29:48 -07:00
Comfy Org PR Bot
5e8cba5559 [backport cloud/1.34] feat: add popover with link to Wan Fun Control template on pricing table (#7364)
Backport of #7363 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7364-backport-cloud-1-34-feat-add-popover-with-link-to-Wan-Fun-Control-template-on-pricing--2c66d73d365081a1a86afc3d8b1a0d0a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-11 02:42:05 -07:00
Comfy Org PR Bot
bade95b2c5 [backport cloud/1.34] feat: replace Stripe pricing table with custom implementation (#7361)
Backport of #7359 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 02:12:57 -07:00
Comfy Org PR Bot
801ab024e5 [backport cloud/1.34] feat: show subscription tier below name on cloud (#7360)
Backport of #7356 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7360-backport-cloud-1-34-feat-show-subscription-tier-below-name-on-cloud-2c66d73d3650817093b2ed141f477629)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-11 02:01:28 -07:00
Christian Byrne
2b4d3484b8 [backport cloud/1.34] fix: make subscription panel reactive to actual tier (#7357)
## Summary
Backport of #7354 to cloud/1.34

- Update CloudSubscriptionStatusResponse to use generated types from
comfyRegistryTypes which includes subscription_tier
- Add subscriptionTier computed to useSubscription composable
- Make SubscriptionPanel tierName, tierPrice, and tierBenefits reactive
to actual subscription tier from API
- Normalize i18n tier structure with consistent value/label format
- Add FOUNDERS_EDITION tier support

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7357-fix-make-subscription-panel-reactive-to-actual-tier-backport-to-cloud-1-34-2c66d73d365081a99695fa1fb7901120)
by [Unito](https://www.unito.io)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-11 01:18:33 -07:00
bymyself
4e68a266a0 Revert "fix: make subscription panel reactive to actual tier (#7354)"
This reverts commit d3d02d0d4b.
2025-12-10 23:54:22 -08:00
Christian Byrne
d3d02d0d4b fix: make subscription panel reactive to actual tier (#7354)
Was previously hard-coded, now is actually reactive to value returned
from server

- Update CloudSubscriptionStatusResponse to use generated types from
comfyRegistryTypes which includes subscription_tier
- Add subscriptionTier computed to useSubscription composable
- Make SubscriptionPanel tierName, tierPrice, and tierBenefits reactive
to actual subscription tier from API
- Normalize i18n tier structure with consistent value/label format
- Add FOUNDERS_EDITION tier support

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7354-fix-make-subscription-panel-reactive-to-actual-tier-2c66d73d365081059a7be875c13fdd0c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-10 23:50:40 -08:00
Comfy Org PR Bot
4b008eeaf7 [backport cloud/1.34] fix: credits loading skeleton in user popover (#7350)
Backport of #7347 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7350-backport-cloud-1-34-fix-credits-loading-skeleton-in-user-popover-2c66d73d36508130b251f38bc8a8f66d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:20:59 -07:00
Comfy Org PR Bot
3f203002b9 [backport cloud/1.34] fix: subscribe button overflow on cloud (#7346)
Backport of #7343 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7346-backport-cloud-1-34-fix-subscribe-button-overflow-on-cloud-2c66d73d36508195920ce922265c0a0d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:20:50 -07:00
Comfy Org PR Bot
cc60dfd910 [backport cloud/1.34] remove fraction digits on topup credit number (#7349)
Backport of #7345 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7349-backport-cloud-1-34-remove-fraction-digits-on-topup-credit-number-2c66d73d365081f1a635e7a4b55cfaf0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:08:07 -07:00
Comfy Org PR Bot
913875d745 [backport cloud/1.34] [chore] Update Comfy Registry API types from comfy-api@e1e32b5 (#7348)
Backport of #7344 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7348-backport-cloud-1-34-chore-Update-Comfy-Registry-API-types-from-comfy-api-e1e32b5-2c66d73d3650816d8565df2c3df17215)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-10 22:59:51 -07:00
Comfy Org PR Bot
0751a13df7 [backport cloud/1.34] Improve subscription dialog width for laptop screens (#7329)
Backport of #7324 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7329-backport-cloud-1-34-Improve-subscription-dialog-width-for-laptop-screens-2c66d73d36508160884be66e159c2ad2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-10 17:45:55 -07:00
Comfy Org PR Bot
562db3b0d9 [backport cloud/1.34] fix: allow dots in template URL parameter for version numbers (#7328)
Backport of #7325 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7328-backport-cloud-1-34-fix-allow-dots-in-template-URL-parameter-for-version-numbers-2c56d73d36508192b2b6f90a0562029d)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-10 17:13:06 -07:00
Comfy Org PR Bot
432c1e8e33 [backport cloud/1.34] Fix compatibility with older browsers (#7314)
Backport of #7205 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7314-backport-cloud-1-34-Fix-compatibility-with-older-browsers-2c56d73d3650816cbe75dcdca276edb2)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 23:31:15 -07:00
Comfy Org PR Bot
a660c55da9 [backport cloud/1.34] feat: display and upload Civitai preview images in model upload flow (#7301)
Backport of #7274 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7301-backport-cloud-1-34-feat-display-and-upload-Civitai-preview-images-in-model-upload-flo-2c56d73d3650814caedbc0b64480cb9c)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 23:30:58 -07:00
Comfy Org PR Bot
fdda9cc752 [backport cloud/1.34] feat: update subscription panel with tier-based design and improved UX (#7312)
Backport of #7307 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7312-backport-cloud-1-34-feat-update-subscription-panel-with-tier-based-design-and-improved-2c56d73d365081e28400d30170266e85)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 22:30:12 -07:00
Comfy Org PR Bot
ab74061bc6 [backport cloud/1.34] style: redesign TopUpCredits dialog (#7313)
Backport of #7305 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7313-backport-cloud-1-34-style-redesign-TopUpCredits-dialog-2c56d73d36508172b633f5602a8b967d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 22:30:05 -07:00
Comfy Org PR Bot
c82f4272a7 [backport cloud/1.34] style: redesign user popover with improved layout and integration with design system (#7311)
Backport of #7303 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7311-backport-cloud-1-34-style-redesign-user-popover-with-improved-layout-and-integration-w-2c56d73d365081019e0beea29f06e362)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 21:30:20 -07:00
Comfy Org PR Bot
32c525a4a6 [backport cloud/1.34] add shared comfy credit conversion helpers (#7293)
Backport of #7061 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7293-backport-cloud-1-34-add-shared-comfy-credit-conversion-helpers-2c46d73d365081a9b1bcfb82462c3d7f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 05:26:00 -07:00
Comfy Org PR Bot
27dcb152ff [backport cloud/1.34] feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7292)
Backport of #7288 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7292-backport-cloud-1-34-feat-add-Stripe-pricing-table-integration-for-subscription-dialog--2c46d73d36508111869ddf32de921b29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 05:03:59 -07:00
Comfy Org PR Bot
d2f5f0dce1 [backport cloud/1.34] feat: Enable system notifications on cloud (#7287)
Backport of #7277 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7287-backport-cloud-1-34-feat-Enable-system-notifications-on-cloud-2c46d73d365081aaaf90d4ff96d0ca52)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 03:41:29 -07:00
Comfy Org PR Bot
47d8f022ec [backport cloud/1.34] change credits icons and tooltips (conditional on feature flag) (#7291)
Backport of #7276 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7291-backport-cloud-1-34-change-credits-icons-and-tooltips-conditional-on-feature-flag-2c46d73d36508163b2d6cad792078e4c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 03:41:09 -07:00
Comfy Org PR Bot
271c69f261 [backport cloud/1.34] Add label to open subgraph button (#7290)
Backport of #7244 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7290-backport-cloud-1-34-Add-label-to-open-subgraph-button-2c46d73d3650813f914ae58916047178)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 03:22:11 -07:00
Comfy Org PR Bot
218a7f24a6 [backport cloud/1.34] Fix cloud queue cancel to target specific jobs (#7283)
Backport of #7176 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7283-backport-cloud-1-34-Fix-cloud-queue-cancel-to-target-specific-jobs-2c46d73d365081e99fbddbd615e858b3)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-09 01:20:54 -07:00
317 changed files with 3514 additions and 7044 deletions

View File

@@ -1,26 +0,0 @@
# Description: Runs shellcheck on tracked shell scripts when they change
name: "CI: Shell Validation"
on:
push:
branches:
- main
paths:
- '**/*.sh'
pull_request:
paths:
- '**/*.sh'
jobs:
shell-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install shellcheck
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
- name: Run shellcheck
run: bash ./scripts/cicd/check-shell.sh

View File

@@ -16,10 +16,6 @@ on:
type: boolean
default: false
concurrency:
group: backport-${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
backport:
if: >

View File

@@ -124,16 +124,12 @@ jobs:
- name: Stage changed snapshot files
id: changed-snapshots
run: |
set -euo pipefail
echo "=========================================="
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
echo "=========================================="
# Get list of changed snapshot files (including untracked/new files)
changed_files=$( (
git diff --name-only browser_tests/ 2>/dev/null || true
git ls-files --others --exclude-standard browser_tests/ 2>/dev/null || true
) | sort -u | grep -E '\-snapshots/' || true )
# Get list of changed snapshot files
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
if [ -z "$changed_files" ]; then
echo "No snapshot changes in this shard"
@@ -155,11 +151,6 @@ jobs:
# Strip 'browser_tests/' prefix to avoid double nesting
echo "Copying changed files to staging directory..."
while IFS= read -r file; do
# Skip paths that no longer exist (e.g. deletions)
if [ ! -f "$file" ]; then
echo " → (skipped; not a file) $file"
continue
fi
# Remove 'browser_tests/' prefix
file_without_prefix="${file#browser_tests/}"
# Create parent directories
@@ -270,19 +261,11 @@ jobs:
echo "CHANGES SUMMARY"
echo "=========================================="
echo ""
echo "Changed files in browser_tests (including untracked):"
CHANGES=$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)
if [ -z "$CHANGES" ]; then
echo "No changes"
echo ""
echo "Total changes:"
echo "0"
else
echo "$CHANGES" | head -50
echo ""
echo "Total changes:"
echo "$CHANGES" | wc -l
fi
echo "Changed files in browser_tests:"
git diff --name-only browser_tests/ | head -20 || echo "No changes"
echo ""
echo "Total changes:"
git diff --name-only browser_tests/ | wc -l || echo "0"
- name: Commit updated expectations
id: commit
@@ -290,7 +273,7 @@ jobs:
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)" ]; then
if git diff --quiet browser_tests/; then
echo "No changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0

View File

@@ -2,98 +2,25 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"components.d.ts",
"lint-staged.config.js",
"vitest.setup.ts",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",
"coverage/*",
"dist/*",
"packages/registry-types/src/comfyRegistryTypes.ts",
"playwright-report/*",
"src/extensions/core/*",
"src/scripts/*",
"src/types/generatedManagerTypes.ts",
"src/types/vue-shim.d.ts",
"test-results/*",
"vitest.setup.ts"
],
"plugins": [
"eslint",
"import",
"oxc",
"typescript",
"unicorn",
"vitest",
"vue"
"src/types/vue-shim.d.ts"
],
"rules": {
"no-async-promise-executor": "off",
"no-console": [
"error",
{
"allow": [
"warn",
"error"
]
}
],
"no-control-regex": "off",
"no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "primevue/calendar",
"message": "Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from 'primevue/datepicker'"
},
{
"name": "primevue/dropdown",
"message": "Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from 'primevue/select'"
},
{
"name": "primevue/inputswitch",
"message": "InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from 'primevue/toggleswitch'"
},
{
"name": "primevue/overlaypanel",
"message": "OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from 'primevue/popover'"
},
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": [
"st",
"t",
"te",
"d"
],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}
],
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"import/default": "error",
"import/export": "error",
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": [
"error",
"prefer-top-level"
],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
@@ -112,18 +39,6 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
"overrides": [
{
"files": [
"**/*.{stories,test,spec}.ts",
"**/*.stories.vue"
],
"rules": {
"no-console": "allow"
}
}
]
"vue/no-import-compiler-macros": "error"
}
}

View File

@@ -12,9 +12,6 @@
"declaration-property-value-no-unknown": [
true,
{
"typesSyntax": {
"radial-gradient()": "| <any-value>"
},
"ignoreProperties": {
"speak": ["none"],
"app-region": ["drag", "no-drag"],
@@ -59,7 +56,10 @@
"function-no-unknown": [
true,
{
"ignoreFunctions": ["theme", "v-bind"]
"ignoreFunctions": [
"theme",
"v-bind"
]
}
]
},

View File

@@ -73,6 +73,24 @@ The project uses **Nx** for build orchestration and task management
- composables `useXyz.ts`
- Pinia stores `*Store.ts`
## Testing Guidelines
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
- Test files:
- Unit/Component: `**/*.test.ts`
- E2E: `browser_tests/**/*.spec.ts`
- Litegraph Specific: `src/lib/litegraph/test/`
- Coverage: text/json/html reporters enabled
- aim to cover critical logic and new features
- Playwright:
- optional tags like `@mobile`, `@2x` are respected by config
- Tests to avoid
- Change detector tests
- e.g. a test that just asserts that the defaults are certain values
- Tests that are dependent on non-behavioral features like utility classes or styles
- Redundant tests
## Commit & Pull Request Guidelines
@@ -143,7 +161,7 @@ The project uses **Nx** for build orchestration and task management
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
15. Do not add or retain redundant comments, clean as you go
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
17. Refactoring should be used to make complex code simpler
18. Try to minimize the surface area (exported values) of each module and composable
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
20. Keep functions short and functional
@@ -152,42 +170,6 @@ The project uses **Nx** for build orchestration and task management
23. Favor pure functions (especially testable ones)
24. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Testing Guidelines
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
- Test files:
- Unit/Component: `**/*.test.ts`
- E2E: `browser_tests/**/*.spec.ts`
- Litegraph Specific: `src/lib/litegraph/test/`
### General
1. Do not write change detector tests
e.g. a test that just asserts that the defaults are certain values
2. Do not write tests that are dependent on non-behavioral features like utility classes or styles
3. Be parsimonious in testing, do not write redundant tests
See <https://tidyfirst.substack.com/p/composable-tests>
4. [Dont Mock What You Dont Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
### Vitest / Unit Tests
1. Do not write tests that just test the mocks
Ensure that the tests fail when the code itself would behave in a way that was not expected or desired
2. For mocking, leverage [Vitest's utilities](https://vitest.dev/guide/mocking.html) where possible
3. Keep your module mocks contained
Do not use global mutable state within the test file
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
5. Aim for behavioral coverage of critical and new features
### Playwright / Browser / E2E Tests
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
## External Resources
- Vue: <https://vuejs.org/api/>
@@ -200,7 +182,6 @@ The project uses **Nx** for build orchestration and task management
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Project Philosophy

View File

@@ -25,9 +25,6 @@
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu

View File

@@ -87,8 +87,6 @@
}
},
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},

View File

@@ -40,8 +40,7 @@
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step from 'primevue/step'
import type { StepPassThroughOptions } from 'primevue/step'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{

View File

@@ -155,14 +155,12 @@ export async function loadLocale(locale: string): Promise<void> {
}
// Only include English in the initial bundle
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof enMessages
const messages: Record<string, LocaleMessages> = {
en: enMessages
}
type LocaleMessages = typeof messages.en
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2

View File

@@ -1,6 +1,5 @@
import { useTimeout } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { type Ref, computed, ref, watch } from 'vue'
/**
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.

View File

@@ -29,8 +29,7 @@ import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'

View File

@@ -5,7 +5,7 @@
<img
class="sad-girl"
src="/assets/images/sad_girl.png"
:alt="$t('notSupported.illustrationAlt')"
alt="Sad girl illustration"
/>
<div class="no-drag sad-text flex items-center">

View File

@@ -1,150 +0,0 @@
{
"id": "e0cb1d7e-5437-4911-b574-c9603dfbeaee",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "8bfe4227-f272-49e1-a892-0a972a86867c",
"pos": [
-317,
-336
],
"size": [
210,
58
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
[
"-1",
"batch_size"
]
]
},
"widgets_values": [
1
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "8bfe4227-f272-49e1-a892-0a972a86867c",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
-562,
-358,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
-52,
-358,
120,
40
]
},
"inputs": [
{
"id": "b4a8bc2a-8e9f-41aa-938d-c567a11d2c00",
"name": "batch_size",
"type": "INT",
"linkIds": [
1
],
"pos": [
-462,
-338
]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "EmptyLatentImage",
"pos": [
-382,
-376
],
"size": [
270,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.35.1"
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -126,20 +126,6 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -256,9 +242,6 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {
@@ -585,15 +568,9 @@ export class ComfyPage {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
} = {}
) {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
} = options
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
@@ -630,14 +607,6 @@ export class ComfyPage {
// Dropping a URL (e.g., dropping image across browser tabs in Firefox)
if (url) evaluateParams.url = url
// Set up response waiter for file uploads before triggering the drop
const uploadResponsePromise = waitForUpload
? this.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10000 }
)
: null
// Execute the drag and drop in the browser
await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer()
@@ -704,17 +673,12 @@ export class ComfyPage {
}
}, evaluateParams)
// Wait for file upload to complete
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
options: { dropPosition?: Position } = {}
) {
return this.dragAndDropExternalResource({ fileName, ...options })
}

View File

@@ -137,13 +137,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
await this.page.keyboard.type(newName)
await this.page.keyboard.press('Enter')
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() => !window['app']?.extensionManager?.workflow?.isBusy,
undefined,
{ timeout: 3000 }
)
}
async insertWorkflow(locator: Locator) {

View File

@@ -92,26 +92,9 @@ export class Topbar {
)
// Wait for the dialog to close.
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}
}
async openTopbarMenu() {
// If menu is already open, close it first to reset state
const isAlreadyOpen = await this.menuLocator.isVisible()
if (isAlreadyOpen) {
// Click outside the menu to close it properly
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
}
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator
@@ -179,36 +162,15 @@ export class Topbar {
await topLevelMenu.hover()
// Hover over top-level menu with retry logic for flaky submenu appearance
const submenu = this.getVisibleSubmenu()
try {
await submenu.waitFor({ state: 'visible', timeout: 1000 })
} catch {
// Click outside to reset, then reopen menu
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
// Re-hover on top-level menu to trigger submenu
await topLevelMenu.hover()
await submenu.waitFor({ state: 'visible', timeout: 1000 })
}
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = submenu
.locator(`.p-tieredmenu-item:has-text("${commandName}")`)
const menuItem = currentMenu
.locator(
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
// For the last item, click it
if (i === path.length - 1) {
await menuItem.click()
return
}
// Otherwise, hover to open nested submenu
await menuItem.hover()
currentMenu = menuItem
}

View File

@@ -1,5 +1,4 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import path from 'path'
import type {
@@ -9,20 +8,9 @@ import type {
export class ComfyTemplates {
readonly content: Locator
readonly allTemplateCards: Locator
constructor(readonly page: Page) {
this.content = page.getByTestId('template-workflows-content')
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
}
async waitForMinimumCardCount(count: number) {
return await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
timeout: 1_000
})
}
async loadTemplate(id: string) {

View File

@@ -77,7 +77,8 @@ test.describe('Background Image Upload', () => {
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
const inputValue = await urlInput.inputValue()
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')

View File

@@ -36,10 +36,9 @@ test.describe('Execute to selected output nodes', () => {
await output1.click('title')
await comfyPage.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 })
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('')
})
})

View File

@@ -306,16 +306,14 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: numberWidgetPos
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await comfyPage.delay(300)
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-opened.png')
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-closed.png')
})
test('Can close prompt dialog with canvas click (text widget)', async ({
@@ -329,16 +327,18 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: textWidgetPos
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await comfyPage.delay(300)
await expect(comfyPage.canvas).toHaveScreenshot(
'prompt-dialog-opened-text.png'
)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
await expect(comfyPage.canvas).toHaveScreenshot(
'prompt-dialog-closed-text.png'
)
})
test('Can double click node title to edit', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -260,12 +260,6 @@ test.describe('Release context menu', () => {
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
await expect(contextMenu.locator('.litemenu-title')).toContainText(
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -50,7 +50,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows the release
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release version
@@ -79,7 +79,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows no releases
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show "No recent releases" message
@@ -125,7 +125,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Should show no releases due to error
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
@@ -175,7 +175,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is hidden
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
// Should not show any popups or toasts
@@ -260,7 +260,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release
@@ -308,7 +308,7 @@ test.describe('Release Notifications', () => {
await helpCenterButton.click()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Close help center
@@ -359,7 +359,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Section should be hidden regardless of empty releases
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
})
})

View File

@@ -212,12 +212,8 @@ test.describe('Remote COMBO Widget', () => {
// Click on the canvas to trigger widget refresh
await comfyPage.page.mouse.click(400, 300)
await expect(async () => {
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
}).toPass({
timeout: 2_000
})
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
})
test('does not refresh when TTL is not set', async ({ comfyPage }) => {
@@ -325,12 +321,8 @@ test.describe('Remote COMBO Widget', () => {
await clickRefreshButton(comfyPage, nodeName)
// Verify the selected value of the widget is the first option in the refreshed list
await expect(async () => {
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
expect(refreshedValue).toEqual('new first option')
}).toPass({
timeout: 2_000
})
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
expect(refreshedValue).toEqual('new first option')
})
})

View File

@@ -290,20 +290,16 @@ test.describe('Node library sidebar', () => {
await comfyPage.page.keyboard.insertText('bar')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(async () => {
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
).toEqual({
'bar/': {
icon: 'pi-folder',
color: '#007bff'
}
})
}).toPass({
timeout: 2_000
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
).toEqual({
'bar/': {
icon: 'pi-folder',
color: '#007bff'
}
})
})

View File

@@ -340,11 +340,6 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.getGraphNodesCount()
// Get the bounding box of the canvas element
@@ -363,10 +358,6 @@ test.describe('Workflows sidebar', () => {
'#graph-canvas',
{ targetPosition }
)
// Wait for nodes to be inserted after drag-drop with retryable assertion
await expect
.poll(() => comfyPage.getGraphNodesCount(), { timeout: 3000 })
.toBe(nodeCount * 2)
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
})
})

View File

@@ -329,15 +329,6 @@ test.describe('Subgraph Operations', () => {
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can create widget from link with compressed target_slot', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/subgraph-compressed-target-slot')
const step = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes[0].widgets[0].options.step
})
expect(step).toBe(10)
})
})
test.describe('Subgraph Creation and Deletion', () => {

View File

@@ -188,19 +188,22 @@ test.describe('Templates', () => {
.locator('header')
.filter({ hasText: 'Templates' })
await comfyPage.templates.waitForMinimumCardCount(1)
const cardCount = await comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.count()
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(nav).toBeVisible() // Nav should be visible at desktop size
const mobileSize = { width: 640, height: 800 }
await comfyPage.page.setViewportSize(mobileSize)
await comfyPage.templates.waitForMinimumCardCount(1)
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)
await comfyPage.templates.waitForMinimumCardCount(1)
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -828,55 +828,55 @@ test.describe('Vue Node Link Interaction', () => {
})
test.describe('Release actions (Shift-drop)', () => {
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
test.fixme(
'Context menu opens and endpoint is pinned on Shift-drop',
async ({ comfyPage, comfyMouse }) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 90, y: outputCenter.y - 70 }
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
}
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
})
)
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
comfyPage,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,144 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../../../helpers/fitToView'
test.describe('Vue Node Bring to Front', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
/**
* Helper to get the z-index of a node by its title
*/
async function getNodeZIndex(
comfyPage: ComfyPage,
title: string
): Promise<number> {
const node = comfyPage.vueNodes.getNodeByTitle(title)
const style = await node.getAttribute('style')
if (!style) {
throw new Error(
`Node "${title}" has no style attribute (observed: ${style})`
)
}
const match = style.match(/z-index:\s*(\d+)/)
if (!match) {
throw new Error(
`Node "${title}" has no z-index in style (observed: "${style}")`
)
}
return parseInt(match[1], 10)
}
/**
* Helper to get the bounding box center of a node
*/
async function getNodeCenter(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const node = comfyPage.vueNodes.getNodeByTitle(title)
const box = await node.boundingBox()
if (!box) throw new Error(`Node "${title}" not found`)
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
test('should bring overlapped node to front when clicking on it', async ({
comfyPage
}) => {
// Get initial positions
const clipCenter = await getNodeCenter(comfyPage, 'CLIP Text Encode')
const ksamplerHeader = await comfyPage.page
.getByText('KSampler')
.boundingBox()
if (!ksamplerHeader) throw new Error('KSampler header not found')
// Drag KSampler on top of CLIP Text Encode
await comfyPage.dragAndDrop(
{ x: ksamplerHeader.x + 50, y: ksamplerHeader.y + 10 },
clipCenter
)
await comfyPage.nextFrame()
// Screenshot showing KSampler on top of CLIP
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-overlapped-before.png'
)
// KSampler should be on top (higher z-index) after being dragged
const ksamplerZIndexBefore = await getNodeZIndex(comfyPage, 'KSampler')
const clipZIndexBefore = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
expect(ksamplerZIndexBefore).toBeGreaterThan(clipZIndexBefore)
// Click on CLIP Text Encode (underneath) - need to click on a visible part
// Since KSampler is on top, we click on the edge of CLIP that should still be visible
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
const clipBox = await clipNode.boundingBox()
if (!clipBox) throw new Error('CLIP node not found')
// Click on a visible edge of CLIP
await comfyPage.page.mouse.click(clipBox.x + 30, clipBox.y + 10)
await comfyPage.nextFrame()
// CLIP should now be on top - compare post-action z-indices
const clipZIndexAfter = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
const ksamplerZIndexAfter = await getNodeZIndex(comfyPage, 'KSampler')
expect(clipZIndexAfter).toBeGreaterThan(ksamplerZIndexAfter)
// Screenshot showing CLIP now on top
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-overlapped-after.png'
)
})
test('should bring overlapped node to front when clicking on its widget', async ({
comfyPage
}) => {
// Get CLIP Text Encode position (it has a text widget)
const clipCenter = await getNodeCenter(comfyPage, 'CLIP Text Encode')
// Get VAE Decode position and drag it on top of CLIP
const vaeHeader = await comfyPage.page.getByText('VAE Decode').boundingBox()
if (!vaeHeader) throw new Error('VAE Decode header not found')
await comfyPage.dragAndDrop(
{ x: vaeHeader.x + 50, y: vaeHeader.y + 10 },
{ x: clipCenter.x - 50, y: clipCenter.y }
)
await comfyPage.nextFrame()
// VAE should be on top after drag
const vaeZIndexBefore = await getNodeZIndex(comfyPage, 'VAE Decode')
const clipZIndexBefore = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
expect(vaeZIndexBefore).toBeGreaterThan(clipZIndexBefore)
// Screenshot showing VAE on top
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-widget-overlapped-before.png'
)
// Click on the text widget of CLIP Text Encode
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
const clipBox = await clipNode.boundingBox()
if (!clipBox) throw new Error('CLIP node not found')
await comfyPage.page.mouse.click(clipBox.x + 170, clipBox.y + 80)
await comfyPage.nextFrame()
// CLIP should now be on top - compare post-action z-indices
const clipZIndexAfter = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
const vaeZIndexAfter = await getNodeZIndex(comfyPage, 'VAE Decode')
expect(clipZIndexAfter).toBeGreaterThan(vaeZIndexAfter)
// Screenshot showing CLIP now on top after widget click
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-widget-overlapped-after.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -252,8 +252,7 @@ test.describe('Animated image widget', () => {
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y },
waitForUpload: true
dropPosition: { x, y }
})
// Expect the filename combo value to be updated

View File

@@ -15,7 +15,6 @@ import {
parser as tseslintParser
} from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
import path from 'node:path'
const extraFileExtensions = ['.vue']
@@ -62,20 +61,16 @@ export default defineConfig([
{
ignores: [
'.i18nrc.cjs',
'.nx/*',
'components.d.ts',
'lint-staged.config.js',
'vitest.setup.ts',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'components.d.ts',
'coverage/*',
'dist/*',
'packages/registry-types/src/comfyRegistryTypes.ts',
'playwright-report/*',
'src/extensions/core/*',
'src/scripts/*',
'src/types/generatedManagerTypes.ts',
'src/types/vue-shim.d.ts',
'test-results/*',
'vitest.setup.ts'
'src/types/vue-shim.d.ts'
]
},
{
@@ -107,17 +102,24 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Bad types in the plugin
pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
storybook.configs['flat/recommended'],
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.recommended,
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.typescript,
{
plugins: {
'unused-imports': unusedImports,
// @ts-expect-error Type incompatibility in i18n plugin
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility in i18n plugin
'@intlify/vue-i18n': pluginI18n
},
rules: {
@@ -132,24 +134,59 @@ export default defineConfig([
allowInterfaces: 'always'
}
],
'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
'vue/no-v-html': 'off',
// Prohibit dark-theme: and dark: prefixes
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/match-component-import-name': 'error',
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
'vue/no-unused-emit-declarations': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/one-component-per-file': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'primevue/calendar',
message:
'Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from "primevue/datepicker"'
},
{
name: 'primevue/dropdown',
message:
'Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from "primevue/select"'
},
{
name: 'primevue/inputswitch',
message:
'InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from "primevue/toggleswitch"'
},
{
name: 'primevue/overlaypanel',
message:
'OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from "primevue/popover"'
},
{
name: 'primevue/sidebar',
message:
'Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from "primevue/drawer"'
}
]
}
],
// i18n rules
'@intlify/vue-i18n/no-raw-text': [
'error',
@@ -237,6 +274,12 @@ export default defineConfig([
]
}
},
{
files: ['**/*.{test,spec,stories}.ts', '**/*.stories.vue'],
rules: {
'no-console': 'off'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
@@ -249,18 +292,6 @@ export default defineConfig([
'no-console': 'off'
}
},
// Turn off ESLint rules that are already handled by oxlint
...oxlint.buildFromOxlintConfigFile(
path.resolve(import.meta.dirname, '.oxlintrc.json')
),
{
rules: {
'import-x/default': 'off',
'import-x/export': 'off',
'import-x/namespace': 'off',
'import-x/no-duplicates': 'off',
'import-x/consistent-type-specifier-style': 'off'
}
}
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json')
])

17
lint-staged.config.js Normal file
View File

@@ -0,0 +1,17 @@
export default {
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => f.replace(process.cwd() + '/', ''))
return [
`pnpm exec eslint --cache --fix ${relativePaths.join(' ')}`,
`pnpm exec prettier --cache --write ${relativePaths.join(' ')}`
]
}

View File

@@ -1,21 +0,0 @@
import path from 'node:path'
export default {
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames: string[]) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.35.4",
"version": "1.34.7",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -34,7 +34,6 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
@@ -47,7 +46,6 @@
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
},

View File

@@ -89,8 +89,6 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
@@ -98,6 +96,7 @@
--color-bypass: #6a246a;
--color-error: #962a2a;
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-panel-job-progress-primary: var(--color-azure-300);
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
@@ -184,13 +183,9 @@
--interface-menu-component-surface-hovered: var(--color-smoke-200);
--interface-menu-component-surface-selected: var(--color-smoke-400);
--interface-menu-keybind-surface-default: var(--color-smoke-500);
--interface-menu-surface: var(--color-white);
--interface-menu-stroke: var(--color-smoke-600);
--interface-panel-surface: var(--color-white);
--interface-stroke: var(--color-smoke-300);
--nav-background: var(--color-white);
--node-border: var(--color-smoke-300);
@@ -260,8 +255,6 @@
--component-node-widget-background-selected: var(--secondary-background-selected);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
@@ -308,8 +301,6 @@
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-menu-surface: var(--color-charcoal-800);
--interface-menu-stroke: var(--color-ash-800);
--interface-panel-surface: var(--color-charcoal-800);
--interface-stroke: var(--color-charcoal-400);
@@ -385,8 +376,6 @@
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);
--modal-card-background: var(--secondary-background);
--modal-card-background-hovered: var(--secondary-background-hover);
@@ -427,8 +416,6 @@
--color-interface-menu-keybind-surface-default: var(
--interface-menu-keybind-surface-default
);
--color-interface-menu-surface: var(--interface-menu-surface);
--color-interface-menu-stroke: var(--interface-menu-stroke);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(
@@ -437,11 +424,7 @@
--color-interface-button-hover-surface: var(
--interface-button-hover-surface
);
--color-comfy-input: var(--comfy-input-bg);
--color-comfy-input-foreground: var(--input-text);
--color-comfy-menu-bg: var(--comfy-menu-bg);
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
@@ -497,8 +480,6 @@
--color-component-node-widget-background-selected: var(--component-node-widget-background-selected);
--color-component-node-widget-background-disabled: var(--component-node-widget-background-disabled);
--color-component-node-widget-background-highlighted: var(--component-node-widget-background-highlighted);
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);
@@ -1328,15 +1309,6 @@ audio.comfy-audio.empty-audio-widget {
font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
.transform-pane--interacting .lg-node * {
transition: none !important;
}
.transform-pane--interacting .lg-node {
will-change: transform;
}
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

1648
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.39.1
'@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind': ^1.1.3
@@ -17,7 +17,7 @@ catalog:
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.1.3
'@prettier/plugin-oxc': ^0.0.4
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -48,15 +48,15 @@ catalog:
axios: ^1.8.2
cross-env: ^10.1.0
dotenv: ^16.4.5
eslint: ^9.39.1
eslint: ^9.34.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^9.1.16
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
eslint-plugin-storybook: ^9.1.6
eslint-plugin-unused-imports: ^4.2.0
eslint-plugin-vue: ^10.4.0
firebase: ^11.6.0
globals: ^15.9.0
happy-dom: ^15.11.0
@@ -64,29 +64,29 @@ catalog:
jiti: 2.4.2
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.5.2
lint-staged: ^15.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1
oxlint: ^1.32.0
oxlint-tsgolint: ^0.8.4
oxlint: ^1.25.0
oxlint-tsgolint: ^0.4.0
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0
prettier: ^3.7.4
prettier: ^3.6.2
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
storybook: ^9.1.16
stylelint: ^16.26.1
storybook: ^9.1.6
stylelint: ^16.24.0
tailwindcss: ^4.1.12
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.49.0
typescript: ^5.9.2
typescript-eslint: ^8.44.0
unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0
@@ -100,7 +100,7 @@ catalog:
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-router: ^4.4.3
vue-tsc: ^3.1.8
vue-tsc: ^3.0.7
vuefire: ^3.2.1
yjs: ^13.6.27
zod: ^3.23.8

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
if ! command -v shellcheck >/dev/null 2>&1; then
echo "Error: shellcheck is required but not installed" >&2
exit 127
fi
mapfile -t shell_files < <(git ls-files -- '*.sh')
if [[ ${#shell_files[@]} -eq 0 ]]; then
echo 'No shell scripts found.'
exit 0
fi
shellcheck --format=gcc "${shell_files[@]}"

View File

@@ -74,7 +74,7 @@ deploy_report() {
# Project name with dots converted to dashes for Cloudflare
sanitized_browser="${browser//./-}"
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
project="comfyui-playwright-${sanitized_browser}"
echo "Deploying $browser to project $project on branch $branch..." >&2
@@ -208,7 +208,7 @@ else
# Wait for all deployments to complete
for pid in $pids; do
wait "$pid"
wait $pid
done
# Collect URLs and counts in order
@@ -254,9 +254,9 @@ else
total_tests=0
# Parse counts and calculate totals
IFS='|' read -r -a counts_array <<< "$all_counts"
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] && continue
IFS='|'
set -- $all_counts
for counts_json; do
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
# Parse JSON counts using simple grep/sed if jq is not available
if command -v jq > /dev/null 2>&1; then
@@ -324,12 +324,13 @@ $status_icon **$status_text**
# Add browser results with individual counts
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] && { i=$((i + 1)); continue; }
browser="${browser_array[$i]:-}"
url="${url_array[$i]:-}"
IFS='|'
set -- $all_counts
for counts_json; do
# Get browser name
browser=$(echo "$BROWSERS" | cut -d' ' -f$((i + 1)))
# Get URL at position i
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
if [ "$url" != "failed" ] && [ -n "$url" ]; then
# Parse individual browser counts
@@ -373,4 +374,4 @@ $status_icon **$status_text**
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi
fi

View File

@@ -134,38 +134,22 @@ function resolveRelease(
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
// Fetch all branches
// Calculate target minor version (next minor)
const targetMinor = currentMinor + 1
const targetBranch = `core/1.${targetMinor}`
// Check if target branch exists in frontend repo
exec('git fetch origin', frontendRepoPath)
// Try next minor first, fall back to current minor if not available
let targetMinor = currentMinor + 1
let targetBranch = `core/1.${targetMinor}`
const nextMinorExists = exec(
const branchExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!nextMinorExists) {
// Fall back to current minor for patch releases
targetMinor = currentMinor
targetBranch = `core/1.${targetMinor}`
const currentMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!currentMinorExists) {
console.error(
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
)
return null
}
if (!branchExists) {
console.error(
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for patch release`
`Target branch ${targetBranch} does not exist in frontend repo`
)
return null
}
// Get latest patch tag for target minor

View File

@@ -20,6 +20,17 @@
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="mr-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
@@ -76,6 +87,8 @@ import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -83,6 +96,8 @@ import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const executionStore = useExecutionStore()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
@@ -93,9 +108,13 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -112,6 +131,11 @@ onMounted(() => {
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
</script>
<style scoped>

View File

@@ -28,20 +28,8 @@
)
"
/>
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<ComfyRunButton />
</div>
</Panel>
</div>
@@ -55,24 +43,17 @@ import {
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -140,14 +121,7 @@ const setInitialPosition = () => {
}
}
}
//The ComfyRunButton is a dynamic import. Which means it will not be loaded onMount in this component.
//So we must use suspense resolve to ensure that is has loaded and updated the DOM before calling setInitialPosition()
async function comfyRunButtonResolved() {
await nextTick()
setInitialPosition()
}
onMounted(setInitialPosition)
watch(visible, async (newVisible) => {
if (newVisible) {
await nextTick(setInitialPosition)
@@ -276,16 +250,6 @@ watch(isDragging, (dragging) => {
isMouseOverDropZone.value = false
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',

View File

@@ -58,7 +58,7 @@ const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()

View File

@@ -10,12 +10,12 @@
class="bg-transparent"
>
<div class="flex w-full justify-between">
<div class="tabs-container font-inter">
<div class="tabs-container">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="m-1 mx-2 border-none font-inter"
class="m-1 mx-2 border-none"
:class="{
'tab-list-single-item':
bottomPanelStore.bottomPanelTabs.length === 1

View File

@@ -55,6 +55,7 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()
const { subcategories } = defineProps<{
commands: ComfyCommandImpl[]
subcategories: Record<string, ComfyCommandImpl[]>
}>()

View File

@@ -21,7 +21,7 @@
class="icon-[lucide--triangle-alert] text-warning-background"
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
@@ -83,7 +83,7 @@ const props = withDefaults(defineProps<Props>(), {
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()

View File

@@ -41,7 +41,7 @@ const {
inputAttrs?: Record<string, string>
}>()
const emit = defineEmits(['edit', 'cancel'])
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const isCanceling = ref(false)

View File

@@ -11,6 +11,7 @@ import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
defineProps<{
defaultValue?: string
label?: string
}>()

View File

@@ -1,93 +1,84 @@
<template>
<div
:class="
cn(
'relative flex w-full items-center gap-2 bg-comfy-input cursor-text text-comfy-input-foreground',
customClass,
wrapperStyle
)
"
>
<InputText
ref="inputRef"
v-model="modelValue"
:placeholder
:autofocus
unstyled
class="absolute inset-0 size-full pl-11 border-none outline-none bg-transparent text-sm"
:aria-label="placeholder"
/>
<IconButton
v-if="filterIcon"
class="p-inputicon filter-button absolute right-0 inset-y-0 h-full m-0 p-0"
:icon="filterIcon"
severity="contrast"
@click="$emit('showFilter', $event)"
/>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
class="p-inputicon clear-button"
icon="pi pi-times"
text
severity="contrast"
@click="modelValue = ''"
/>
</div>
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
<div>
<IconField>
<Button
v-if="filterIcon"
class="p-inputicon filter-button"
:icon="filterIcon"
text
severity="contrast"
@click="$emit('showFilter', $event)"
/>
<InputText
ref="inputRef"
class="search-box-input w-full"
:model-value="modelValue"
:placeholder="placeholder"
:autofocus="autofocus"
@input="handleInput"
/>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
class="p-inputicon clear-button"
icon="pi pi-times"
text
severity="contrast"
@click="clearSearch"
/>
</IconField>
<div
v-if="filters?.length"
class="search-filters flex flex-wrap gap-2 pt-2"
>
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</div>
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { cn } from '@comfyorg/tailwind-utils'
import { watchDebounced } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import IconButton from '../button/IconButton.vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
modelValue,
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false,
showBorder = false,
size = 'md',
class: customClass
autofocus = false
} = defineProps<{
modelValue: string
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const modelValue = defineModel<string>({ required: true })
const inputRef = ref()
defineExpose({
@@ -96,27 +87,20 @@ defineExpose({
}
})
watchDebounced(
modelValue,
(value: string) => {
emit('search', value, filters)
},
{ debounce: debounceTime }
)
const emitSearch = debounce((value: string) => {
emit('search', value, filters)
}, debounceTime)
const wrapperStyle = computed(() => {
if (showBorder) {
return cn('rounded p-2 border border-solid border-border-default')
}
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
emitSearch(target.value)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn('rounded-lg', sizeClasses)
})
const clearSearch = () => {
emit('update:modelValue', '')
emitSearch('')
}
</script>
<style scoped>

View File

@@ -2,7 +2,7 @@
<Tree
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
class="tree-explorer px-2 py-0 2xl:px-4"
:class="props.class"
:value="renderedRoot.children"
selection-mode="single"

View File

@@ -388,8 +388,8 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'

View File

@@ -5,7 +5,7 @@
icon="pi pi-exclamation-circle"
:title="title"
:message="error.exceptionMessage"
text-class="break-words max-w-[60vw]"
:text-class="'break-words max-w-[60vw]'"
/>
<template v-if="error.extensionFile">
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
@@ -128,7 +128,7 @@ onMounted(async () => {
reportContent.value = generateErrorReport({
systemStats: systemStatsStore.systemStats!,
serverLogs: logs,
workflow: app.rootGraph.serialize(),
workflow: app.graph.serialize(),
exceptionType: error.exceptionType,
exceptionMessage: error.exceptionMessage,
traceback: error.traceback,

View File

@@ -60,13 +60,12 @@
</div>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="mx-auto h-8 w-8" />
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
type="submit"
:label="t('auth.login.loginButton')"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
/>
</Form>
</template>
@@ -75,7 +74,6 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
@@ -102,11 +100,11 @@ const emit = defineEmits<{
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}, 1_500)
}
const handleForgotPassword = async (
email: string,

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