Compare commits

...

21 Commits

Author SHA1 Message Date
bymyself
c330529da8 fix: address CodeRabbit review feedback
- Remove unused keysBySource from store public API
- Fix socketless required input validation (widget-only inputs)

Amp-Thread-ID: https://ampcode.com/threads/T-019c0d45-1155-70d9-830a-ed6e50e23ee8
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 21:24:16 -08:00
bymyself
50d339e2ba feat: add proactive validation for missing required connections
- Create useRequiredConnectionValidator composable that validates on graphChanged events
- Detect missing required connections by checking slot.link and widget values
- Use debounced validation (200ms) for performance on large graphs
- Register event listener with proper cleanup on unmount

Completes COM-12907: Missing workflow connection now highlights BEFORE clicking Run

Amp-Thread-ID: https://ampcode.com/threads/T-019c0c91-73e0-7188-9770-c315279fadd8
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:15:47 -08:00
bymyself
ed10909f38 feat: add centralized graphErrorStateStore for multi-source error handling
- Create graphErrorStateStore with command-based API (REPLACE_SOURCE, CLEAR_SOURCE, CLEAR_ALL)
- Support multiple error sources: 'frontend' (proactive validation) and 'backend' (execution errors)
- Add useGraphErrorState projection hook that applies errors to graph nodes and propagates up subgraph hierarchy
- Migrate executionStore to use new store instead of direct node.has_errors/slot.hasErrors mutation

Part of COM-12907: Missing workflow connection doesn't highlight

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c0c91-73e0-7188-9770-c315279fadd8
2026-01-29 18:13:12 -08:00
Jin Yi
65ff23c5af [bugfix] Fix manager missing node tab with shared composable (#8409) 2026-01-29 06:23:47 +00:00
Alexander Brown
6ce60a11a4 test: use createTestingPinia instead of createPinia (#8376)
Replace \createPinia\ with \createTestingPinia({ stubActions: false })\
from \@pinia/testing\ across 45 test files for proper test isolation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8376-test-use-createTestingPinia-instead-of-createPinia-2f66d73d36508137a9f0daffcddc86f7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 22:21:38 -08:00
Christian Byrne
3b5d124029 fix: use getAuthHeader in createCustomer to support API key auth (#8408)
## Summary

Fixes authentication failure when using API key authentication on
staging server after frontend update to 1.33.10.

<img width="1160" height="709" alt="image"
src="https://github.com/user-attachments/assets/fe56866d-1819-419e-9f53-35a123d764c3"
/>


## Changes

- **What**: Changed `createCustomer()` to use `getAuthHeader()` instead
of `getFirebaseAuthHeader()`, allowing API key users to authenticate
successfully

## Review Focus

- Verify `getAuthHeader()` correctly falls back to API key when no
Firebase token exists
- Backend `/customers` endpoint supports `X-API-KEY` header (per cloud
PR #1766)

Fixes COM-12398

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8408-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d3650819994e3e6d3ed9f3dfa)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 22:12:28 -08:00
Alexander Brown
bd4920febc Chore: Actions updates and cleanup (#8377)
## Summary

...

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8377-WIP-Chore-Actions-updates-and-cleanup-2f66d73d3650818483a8dffa32a6f245)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 21:22:39 -08:00
Jin Yi
bd916096ac fix: add null check in getCanvasCenter to prevent crash on asset insert (#8399)
## Summary
Adds null check in `getCanvasCenter()` to prevent crash when inserting
asset as node before canvas is fully initialized.

## Changes
- **What**: Added optional chaining for `app.canvas?.ds?.visible_area`
with fallback to `[0, 0]`

## Review Focus
- Simple defensive fix - returns origin position if canvas not ready

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8399-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-insert-2f76d73d365081e88c08ef40ea9e7b78)
by [Unito](https://www.unito.io)
2026-01-29 13:19:51 +09:00
guill
9be853f6b5 feat: support dev-only nodes (#8359)
## Summary

Support `dev_only` property to node definitions that hides nodes from
search and menus unless dev mode is enabled. Dev-only nodes display a
"DEV" badge when visible.

This functionality is primarily intended to support unit-testing nodes
on Comfy Cloud, but also has other uses.

## Changes

- **What**: Nodes flagged as dev_only in the node schema will only
appear in search and menus if Dev Mode is on.

## Screenshots (if applicable)

With Dev Mode off:
<img width="2189" height="1003" alt="image"
src="https://github.com/user-attachments/assets/a08e1fd7-dca9-4ce1-9964-5f4f3b7b95ac"
/>

With Dev Mode on:
<img width="2201" height="1066" alt="image"
src="https://github.com/user-attachments/assets/7fe6cd1f-f774-4f48-b604-a528e286b584"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8359-feat-support-dev-only-nodes-2f66d73d36508102839ee7cd66a26129)
by [Unito](https://www.unito.io)
2026-01-28 19:41:45 -08:00
Christian Byrne
2103dcc788 fix: increase Vue node resize handle size for better usability (#8391)
## Summary

Increases the resize handle size on Vue nodes to improve usability,
especially when nodes are selected.

## Changes

- **What**: Increased resize handle from 12px to 20px and offset it
slightly outside the node boundary to avoid overlap with selection
outline

## Review Focus

The resize handle was too small and became harder to grab when the node
was selected (the 2px outline rendered outside the box, visually
obscuring the corner). This fix increases the hit area and positions it
to extend beyond the node edge.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8391-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d36508136b2aac51bc0d53551)
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>
2026-01-28 19:15:40 -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
166 changed files with 2805 additions and 1123 deletions

1
.gitattributes vendored
View File

@@ -11,6 +11,7 @@
*.ts text eol=lf
*.vue text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true

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,12 @@ 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 }}
- 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 +62,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 +75,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

@@ -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/
@@ -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 }}
@@ -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,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup playwright environment
- name: Setup ComfyUI Frontend
@@ -41,7 +41,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/
@@ -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,13 +170,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
# 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
@@ -301,7 +301,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'
@@ -55,7 +55,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: |
@@ -66,16 +66,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 +95,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 +116,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 +143,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'

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -18,7 +18,8 @@ Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/domains/workflow/ui/stores/workflowStore'
@@ -27,8 +28,8 @@ describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
// Create a fresh pinia and activate it for each test
setActivePinia(createPinia())
// Create a fresh testing pinia and activate it for each test
setActivePinia(createTestingPinia({ stubActions: false }))
// Initialize the store
store = useWorkflowStore()

View File

@@ -11,6 +11,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
## Testing Vue Composables with Reactivity
@@ -253,3 +254,79 @@ it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```
## Mocking Composables with Reactive State
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
### Pattern
```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'
vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})
describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
await store.initialize()
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})
it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'
await store.initialize()
expect(store.error).toBe('Something went wrong')
})
})
```
### Anti-patterns
```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})
// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```
```
```

View File

@@ -1,13 +1,11 @@
<template>
<WorkspaceAuthGate>
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex h-[unset] items-center justify-center"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</WorkspaceAuthGate>
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex h-[unset] items-center justify-center"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</template>
<script setup lang="ts">
@@ -16,7 +14,6 @@ import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import WorkspaceAuthGate from '@/components/auth/WorkspaceAuthGate.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -2,7 +2,8 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -14,6 +15,7 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -36,7 +38,17 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -55,18 +67,21 @@ function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
},
...stubs
},
directives: {
tooltip: () => {}
@@ -91,6 +106,7 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -151,7 +167,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
await nextTick()
@@ -169,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -185,7 +201,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -199,7 +215,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -210,6 +226,84 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,101 +1,130 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
class="ml-1 flex flex-col gap-1 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
</div>
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -104,6 +133,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -147,6 +177,15 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -164,6 +203,19 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,6 +10,7 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -18,7 +19,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div ref="panelRef" class="flex items-center select-none gap-2">
<div class="relative flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -43,6 +44,14 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -51,14 +60,17 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
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 type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -69,6 +81,15 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -76,15 +97,22 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<HTMLElement | null>(null)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelRef, {
const { x, y, style, isDragging } = useDraggable(panelElement, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -101,11 +129,12 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (panelRef.value) {
const panel = panelElement.value
if (panel) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -181,11 +210,12 @@ watch(
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const panel = panelElement.value
if (panel) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -252,6 +282,19 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -24,7 +24,7 @@
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
@@ -120,7 +120,10 @@ async function initializeWorkspaceMode(): Promise<void> {
}
}
// Start initialization immediately during component setup
// (not in onMounted, so initialization starts before DOM is ready)
void initialize()
// Initialize on mount. This gate should be placed on the authenticated layout
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
// The router guard ensures only authenticated users reach this layout.
onMounted(() => {
void initialize()
})
</script>

View File

@@ -14,7 +14,12 @@
</template>
<template #header>
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
autofocus
/>
</template>
<template #header-right-area>

View File

@@ -149,7 +149,7 @@ import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { useNewUserService } from '@/services/useNewUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -457,11 +457,9 @@ onMounted(async () => {
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
newUserService().initializeIfNewUser(settingStore)
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(

View File

@@ -13,6 +13,8 @@ import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
import * as litegraphUtil from '@/utils/litegraphUtil'
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
@@ -289,9 +291,8 @@ describe('SelectionToolbox', () => {
)
})
it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
it('should show mask editor only for single image nodes', () => {
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')
// Single image node
isImageNodeSpy.mockReturnValue(true)
@@ -307,9 +308,8 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
it('should show Color picker button only for single Load3D nodes', () => {
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
@@ -325,13 +325,9 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
it('should show ExecuteButton only when output nodes are selected', () => {
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')
// With output node selected
isOutputNodeSpy.mockReturnValue(true)

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -47,7 +48,7 @@ describe('ExecuteButton', () => {
}
})
beforeEach(async () => {
beforeEach(() => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
@@ -71,10 +72,7 @@ describe('ExecuteButton', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
vi.mocked(useSelectionState).mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}

View File

@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,36 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -8,12 +8,14 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -146,9 +148,12 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -14,6 +14,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -52,7 +54,7 @@ const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
provide('hideLayoutField', true)
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const { t } = useI18n()

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -15,6 +17,7 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -38,6 +41,7 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -59,7 +63,13 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
return sourceNode ? sourceNode.title || sourceNode.type : null
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -21,16 +21,17 @@
</div>
</div>
<div class="option-badges">
<Tag
v-if="nodeDef.experimental"
:value="$t('g.experimental')"
severity="primary"
/>
<Tag
v-if="nodeDef.deprecated"
:value="$t('g.deprecated')"
severity="danger"
/>
<Tag
v-if="nodeDef.experimental"
:value="$t('g.experimental')"
severity="primary"
/>
<Tag v-if="nodeDef.dev_only" :value="$t('g.devOnly')" severity="info" />
<Tag
v-if="showNodeFrequency && nodeFrequency > 0"
:value="formatNumberWithSuffix(nodeFrequency, { roundToInt: true })"

View File

@@ -1,7 +1,11 @@
<template>
<div
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
props.class
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
@@ -35,6 +39,8 @@
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
title: string
class?: string

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
@@ -10,6 +10,7 @@ defineProps<{
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
@@ -18,9 +19,20 @@ whenever(feedbackRef, () => {
})
</script>
<template>
<Popover>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
<Popover v-else>
<template #button>
<Button variant="inverted" class="rounded-full size-12">
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>

View File

@@ -0,0 +1,76 @@
import { watch } from 'vue'
import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import {
findSubgraphByUuid,
forEachNode,
forEachSubgraphNode
} from '@/utils/graphTraversalUtil'
export function useGraphErrorState(): void {
const store = useGraphErrorStateStore()
watch(
() => store.version,
() => {
const rootGraph = app.rootGraph
if (!rootGraph) return
forEachNode(rootGraph, (node) => {
node.has_errors = false
if (node.inputs) {
for (const slot of node.inputs) {
slot.hasErrors = false
}
}
})
for (const [nodeId, keys] of store.keysByNode) {
if (keys.size === 0) continue
const parsed = parseNodeLocatorId(nodeId)
if (!parsed) continue
const targetGraph = parsed.subgraphUuid
? findSubgraphByUuid(rootGraph, parsed.subgraphUuid)
: rootGraph
if (!targetGraph) continue
const node = targetGraph.getNodeById(parsed.localNodeId)
if (!node) continue
node.has_errors = true
for (const key of keys) {
const error = store.errorsByKey.get(key)
if (error && error.target.kind === 'slot' && node.inputs) {
const slotName = error.target.slotName
const slot = node.inputs.find((s) => s.name === slotName)
if (slot) {
slot.hasErrors = true
}
}
}
propagateErrorToParents(node)
}
},
{ immediate: true }
)
}
function propagateErrorToParents(node: LGraphNode): void {
const subgraph = node.graph as Subgraph | undefined
if (!subgraph || subgraph.isRootGraph) return
const subgraphId = subgraph.id
if (!subgraphId) return
forEachSubgraphNode(app.rootGraph, subgraphId, (subgraphNode) => {
subgraphNode.has_errors = true
propagateErrorToParents(subgraphNode)
})
}

View File

@@ -0,0 +1,122 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
}
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn(() => ({
nodeDefsByName: {}
}))
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
forEachNode: vi.fn()
}))
describe('useRequiredConnectionValidator', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('store integration', () => {
it('adds errors for missing required connections', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:missing:1:model',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'model' },
code: 'MISSING_REQUIRED_INPUT'
}
]
})
expect(store.hasSlotError('1', 'model')).toBe(true)
expect(store.hasErrorsForNode('1')).toBe(true)
})
it('clears errors when connections are made', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:missing:1:model',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'model' }
}
]
})
expect(store.hasSlotError('1', 'model')).toBe(true)
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: []
})
expect(store.hasSlotError('1', 'model')).toBe(false)
})
it('preserves backend errors when frontend errors change', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'backend',
errors: [
{
key: 'backend:node:2',
source: 'backend',
target: { kind: 'node', nodeId: '2' }
}
]
})
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:missing:1:model',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'model' }
}
]
})
expect(store.hasErrorsForNode('1')).toBe(true)
expect(store.hasErrorsForNode('2')).toBe(true)
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: []
})
expect(store.hasErrorsForNode('1')).toBe(false)
expect(store.hasErrorsForNode('2')).toBe(true)
})
})
})

View File

@@ -0,0 +1,74 @@
import { useDebounceFn } from '@vueuse/core'
import { onUnmounted } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
import type { GraphError } from '@/stores/graphErrorStateStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { forEachNode } from '@/utils/graphTraversalUtil'
export function useRequiredConnectionValidator(): void {
const errorStore = useGraphErrorStateStore()
const nodeDefStore = useNodeDefStore()
function validate(): void {
const rootGraph = app.rootGraph
if (!rootGraph) return
const errors: GraphError[] = []
forEachNode(rootGraph, (node: LGraphNode) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']
if (!nodeDef?.input?.required) return
const subgraphId =
node.graph && !node.graph.isRootGraph ? node.graph.id : null
const locatorId = subgraphId
? createNodeLocatorId(subgraphId, node.id)
: String(node.id)
for (const inputName of Object.keys(nodeDef.input.required)) {
const slot = node.inputs?.find((s) => s.name === inputName)
const hasConnection = slot?.link !== null && slot?.link !== undefined
const hasWidgetValue = hasWidgetValueForInput(node, inputName)
if (!hasConnection && !hasWidgetValue) {
errors.push({
key: `frontend:missing:${locatorId}:${inputName}`,
source: 'frontend',
target: { kind: 'slot', nodeId: locatorId, slotName: inputName },
code: 'MISSING_REQUIRED_INPUT'
})
}
}
})
errorStore.execute({ type: 'REPLACE_SOURCE', source: 'frontend', errors })
}
function hasWidgetValueForInput(
node: LGraphNode,
inputName: string
): boolean {
if (!node.widgets) return false
const widget = node.widgets.find((w) => w.name === inputName)
if (!widget) return false
return (
widget.value !== undefined && widget.value !== null && widget.value !== ''
)
}
const debouncedValidate = useDebounceFn(validate, 200)
api.addEventListener('graphChanged', debouncedValidate)
onUnmounted(() => {
api.removeEventListener('graphChanged', debouncedValidate)
})
validate()
}

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,13 +185,11 @@ export function useJobList() {
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
const defaultSettingStore = {
get: vi.fn((key: string) => {
@@ -50,9 +51,6 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -16,15 +16,16 @@ useExtensionService().registerExtension({
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
// Refresh config when subscription status changes
// Initial auth-aware refresh happens in WorkspaceAuthGate before app renders
// Refresh config when auth or subscription status changes
// Primary auth refresh is handled by WorkspaceAuthGate on mount
// This watcher handles subscription changes and acts as a backup for auth
watchDebounced(
[isLoggedIn, isActiveSubscription],
() => {
if (!isLoggedIn.value) return
void refreshRemoteConfig()
},
{ debounce: 256 }
{ debounce: 256, immediate: true }
)
// Poll for config updates every 10 minutes (with auth)

View File

@@ -55,6 +55,7 @@ class Load3d {
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
this.clock = new THREE.Clock()
@@ -145,6 +146,7 @@ class Load3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
@@ -154,6 +156,16 @@ class Load3d {
}, 100)
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
@@ -512,7 +524,6 @@ class Load3d {
this.viewHelperManager.recreateViewHelper()
this.handleResize()
this.forceRender()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
@@ -574,7 +585,6 @@ class Load3d {
}
this.handleResize()
this.forceRender()
this.loadingPromise = null
}
@@ -608,7 +618,6 @@ class Load3d {
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
this.forceRender()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
@@ -621,7 +630,6 @@ class Load3d {
refreshViewport(): void {
this.handleResize()
this.forceRender()
}
handleResize(): void {
@@ -809,6 +817,11 @@ class Load3d {
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null

View File

@@ -46,12 +46,9 @@ describe('LGraph', () => {
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
test('is exactly the same type', ({ expect }) => {
// LGraph from barrel export and LiteGraph.LGraph should be the same
expect(LiteGraph.LGraph).toBe(LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {

View File

@@ -233,6 +233,14 @@ export class LGraphNode
static description?: string
static filter?: string
static skip_list?: boolean
static nodeData?: {
dev_only?: boolean
deprecated?: boolean
experimental?: boolean
output_node?: boolean
api_node?: boolean
name?: string
}
static resizeHandleSize = 15
static resizeEdgeSize = 5

View File

@@ -1,12 +1,15 @@
import { clamp } from 'es-toolkit/compat'
import { beforeEach, describe, expect, vi } from 'vitest'
import { describe, expect } from 'vitest'
import {
LiteGraphGlobal,
LGraphCanvas,
LiteGraph
LiteGraph,
LGraph
} from '@/lib/litegraph/src/litegraph'
import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'
import { test } from './__fixtures__/testExtensions'
describe('Litegraph module', () => {
@@ -27,22 +30,9 @@ describe('Litegraph module', () => {
})
describe('Import order dependency', () => {
beforeEach(() => {
vi.resetModules()
})
test('Imports without error when entry point is imported first', async ({
expect
}) => {
async function importNormally() {
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
const directImport = await import('@/lib/litegraph/src/LGraph')
// Sanity check that imports were cleared.
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
}
await expect(importNormally()).resolves.toBeUndefined()
test('Imports reference the same types', ({ expect }) => {
// Both imports should reference the same LGraph class
expect(LiteGraph.LGraph).toBe(DirectLGraph)
expect(LiteGraph.LGraph).toBe(LGraph)
})
})

View File

@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
function createMockWidgetConfig(
overrides: Partial<IColorWidget> = {}
): IColorWidget {
return {
type: 'color',
name: 'test_color',
value: '#ff0000',
options: {},
y: 0,
...overrides
}
}
function createMockCanvas(): LGraphCanvasType {
return {
setDirty: vi.fn()
} as Partial<LGraphCanvasType> as LGraphCanvasType
}
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
return { clientX, clientY } as CanvasPointerEvent
}
describe('ColorWidget', () => {
let node: LGraphNodeType
let widget: ColorWidgetType
let mockCanvas: LGraphCanvasType
let mockEvent: CanvasPointerEvent
let ColorWidget: typeof ColorWidgetType
let LGraphNode: typeof LGraphNodeType
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode
const colorWidgetModule =
await import('@/lib/litegraph/src/widgets/ColorWidget')
ColorWidget = colorWidgetModule.ColorWidget
node = new LGraphNode('TestNode')
mockCanvas = createMockCanvas()
mockEvent = createMockEvent()
})
afterEach(() => {
vi.useRealTimers()
document
.querySelectorAll('input[type="color"]')
.forEach((el) => el.remove())
})
describe('onClick', () => {
it('should create a color input and append it to document body', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input).toBeTruthy()
expect(input.parentElement).toBe(document.body)
})
it('should set input value from widget value', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#00ff00' }),
node
)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#00ff00')
})
it('should default to #000000 when widget value is empty', () => {
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#000000')
})
it('should position input at click coordinates', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const event = createMockEvent(150, 250)
widget.onClick({ e: event, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.style.left).toBe('150px')
expect(input.style.top).toBe('250px')
})
it('should click the input on next animation frame', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
expect(clickSpy).not.toHaveBeenCalled()
vi.runAllTimers()
expect(clickSpy).toHaveBeenCalled()
})
it('should reuse the same input element on subsequent clicks', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const firstInput = document.querySelector('input[type="color"]')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const secondInput = document.querySelector('input[type="color"]')
expect(firstInput).toBe(secondInput)
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
})
it('should update input value when clicking with different widget values', () => {
const widget1 = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const widget2 = new ColorWidget(
createMockWidgetConfig({ value: '#0000ff' }),
node
)
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#ff0000')
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(input.value).toBe('#0000ff')
})
})
describe('onChange', () => {
it('should call setValue when color input changes', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
e: mockEvent,
node,
canvas: mockCanvas
})
})
it('should call canvas.setDirty after value change', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('should remove change listener after firing once', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
// Should only be called once despite two change events
expect(setValueSpy).toHaveBeenCalledTimes(1)
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
})
it('should register new change listener on subsequent onClick', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
// First click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
// Second click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledTimes(2)
expect(setValueSpy).toHaveBeenNthCalledWith(
1,
'#00ff00',
expect.any(Object)
)
expect(setValueSpy).toHaveBeenNthCalledWith(
2,
'#0000ff',
expect.any(Object)
)
})
})
describe('type', () => {
it('should have type "color"', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
expect(widget.type).toBe('color')
})
})
})

View File

@@ -1,12 +1,26 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { BaseWidget } from './BaseWidget'
// Have one color input to prevent leaking instances
// Browsers don't seem to fire any events when the color picker is cancelled
let colorInput: HTMLInputElement | null = null
function getColorInput(): HTMLInputElement {
if (!colorInput) {
colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.position = 'absolute'
colorInput.style.opacity = '0'
colorInput.style.pointerEvents = 'none'
colorInput.style.zIndex = '-999'
document.body.appendChild(colorInput)
}
return colorInput
}
/**
* Widget for displaying a color picker
* This is a widget that only has a Vue widgets implementation
* Widget for displaying a color picker using native HTML color input
*/
export class ColorWidget
extends BaseWidget<IColorWidget>
@@ -15,35 +29,59 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
const { width } = options
const { y, height } = this
const { height, y } = this
const { margin } = BaseWidget
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
const swatchWidth = 40
const swatchHeight = height - 6
const swatchRadius = swatchHeight / 2
const rightPadding = 10
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
// Swatch fixed on the right
const swatchX = width - margin - rightPadding - swatchWidth
const swatchY = y + 3
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
// Draw color swatch as rounded pill
ctx.beginPath()
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
ctx.fillStyle = this.value || '#000000'
ctx.fill()
// Draw label on the left
ctx.fillStyle = this.secondary_text_color
ctx.textAlign = 'left'
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
// Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
onClick({ e, node, canvas }: WidgetEventOptions): void {
const input = getColorInput()
input.value = this.value || '#000000'
input.style.left = `${e.clientX}px`
input.style.top = `${e.clientY}px`
input.addEventListener(
'change',
() => {
this.setValue(input.value, { e, node, canvas })
canvas.setDirty(true)
},
{ once: true }
)
// Wait for next frame else Chrome doesn't render the color picker at the mouse
// Firefox always opens it in top left of window on Windows
requestAnimationFrame(() => input.click())
}
}

View File

@@ -121,6 +121,7 @@
"customize": "Customize",
"experimental": "BETA",
"deprecated": "DEPR",
"devOnly": "DEV",
"loadWorkflow": "Load Workflow",
"goToNode": "Go to Node",
"setAsBackground": "Set as Background",
@@ -756,6 +757,7 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
@@ -2544,7 +2546,7 @@
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"unsupportedUrlSource": "This URL is not supported. Use a direct model link from {sources}. See the how-to videos below for help.",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",

View File

@@ -10,7 +10,7 @@ const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
description:
secondaryText:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
badges: [
{ label: 'checkpoints', type: 'type' },
@@ -131,20 +131,21 @@ export const EdgeCases: Story = {
// Default case for comparison
createAssetData({
name: 'Complete Data',
description: 'Asset with all data present for comparison'
secondaryText: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
description: 'Testing graceful handling when badges are not provided',
secondaryText:
'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
description: 'Testing missing stars data gracefully',
secondaryText: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
@@ -154,7 +155,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
description: 'Testing missing downloads data gracefully',
secondaryText: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
@@ -164,7 +165,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-date',
name: 'No Date',
description: 'Testing missing date data gracefully',
secondaryText: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
@@ -174,21 +175,21 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-stats',
name: 'No Stats',
description: 'Testing when all stats are missing',
secondaryText: 'Testing when all stats are missing',
stats: {}
}),
// Long description
// Long secondaryText
createAssetData({
id: 'long-desc',
name: 'Long Description',
description:
secondaryText:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
description: 'Basic model',
secondaryText: 'Basic model',
tags: ['models'],
badges: [],
stats: {}

View File

@@ -82,14 +82,14 @@
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
v-tooltip.top="{ value: asset.secondaryText, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.description }}
{{ asset.secondaryText }}
</p>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-3 text-xs text-muted-foreground">

View File

@@ -34,7 +34,7 @@ describe('ModelInfoPanel', () => {
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
description: 'A test model description',
secondaryText: 'A test model description',
badges: [],
stats: {},
...overrides

View File

@@ -84,14 +84,14 @@ describe('useAssetBrowser', () => {
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.description).toBe('Test model')
expect(result.secondaryText).toBe('test-asset.safetensors')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('creates fallback description from tags when metadata missing', () => {
it('creates secondaryText from filename when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
@@ -100,7 +100,7 @@ describe('useAssetBrowser', () => {
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.description).toBe('loras model')
expect(result.secondaryText).toBe('test-asset.safetensors')
})
it('removes category prefix from badge labels', () => {

View File

@@ -9,8 +9,8 @@ import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vu
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -70,7 +70,7 @@ type AssetBadge = {
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
description: string
secondaryText: string
badges: AssetBadge[]
stats: {
formattedDate?: string
@@ -116,15 +116,11 @@ export function useAssetBrowser(
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
const secondaryText = getAssetFilename(asset)
// Create badges from tags and metadata
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
// Type badge from non-root tag
if (typeTag) {
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
@@ -152,7 +148,7 @@ export function useAssetBrowser(
return {
...asset,
description,
secondaryText,
badges,
stats
}

View File

@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
const mockIsCloud = vi.hoisted(() => ({ value: false }))
@@ -126,7 +128,6 @@ describe('useMediaAssetActions', () => {
})
it('should use asset.name as filename', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -146,7 +147,6 @@ describe('useMediaAssetActions', () => {
})
it('should use asset_hash as filename when available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -160,7 +160,6 @@ describe('useMediaAssetActions', () => {
})
it('should fall back to asset.name when asset_hash is not available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -174,7 +173,6 @@ describe('useMediaAssetActions', () => {
})
it('should fall back to asset.name when asset_hash is null', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -196,7 +194,6 @@ describe('useMediaAssetActions', () => {
})
it('should use asset_hash for each asset', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const assets = [

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueI18nModule from 'vue-i18n'
@@ -79,7 +80,7 @@ describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
authStore = useFirebaseAuthStore()
vi.clearAllMocks()
})

View File

@@ -1,27 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
const STORAGE_KEY = 'Comfy.FeatureUsage'
describe('useFeatureUsageTracker', () => {
beforeEach(() => {
localStorage.clear()
vi.resetModules()
vi.clearAllMocks()
})
afterEach(() => {
localStorage.clear()
})
it('initializes with zero count for new feature', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('test-feature')
it('initializes with zero count for new feature', () => {
const { useCount } = useFeatureUsageTracker('test-feature-1')
expect(useCount.value).toBe(0)
})
it('increments count on trackUsage', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature')
it('increments count on trackUsage', () => {
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature-2')
expect(useCount.value).toBe(0)
@@ -32,14 +32,12 @@ describe('useFeatureUsageTracker', () => {
expect(useCount.value).toBe(2)
})
it('sets firstUsed only on first use', async () => {
it('sets firstUsed only on first use', () => {
vi.useFakeTimers()
const firstTs = 1000000
vi.setSystemTime(firstTs)
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-3')
trackUsage()
expect(usage.value?.firstUsed).toBe(firstTs)
@@ -52,12 +50,10 @@ describe('useFeatureUsageTracker', () => {
}
})
it('updates lastUsed on each use', async () => {
it('updates lastUsed on each use', () => {
vi.useFakeTimers()
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-4')
trackUsage()
const firstLastUsed = usage.value?.lastUsed ?? 0
@@ -71,10 +67,9 @@ describe('useFeatureUsageTracker', () => {
}
})
it('reset clears feature data', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
it('reset clears feature data', () => {
const { useCount, trackUsage, reset } =
useFeatureUsageTracker('test-feature')
useFeatureUsageTracker('test-feature-5')
trackUsage()
trackUsage()
@@ -84,8 +79,7 @@ describe('useFeatureUsageTracker', () => {
expect(useCount.value).toBe(0)
})
it('tracks multiple features independently', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
it('tracks multiple features independently', () => {
const featureA = useFeatureUsageTracker('feature-a')
const featureB = useFeatureUsageTracker('feature-b')
@@ -100,8 +94,6 @@ describe('useFeatureUsageTracker', () => {
it('persists to localStorage', async () => {
vi.useFakeTimers()
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { trackUsage } = useFeatureUsageTracker('persisted-feature')
trackUsage()
@@ -114,7 +106,7 @@ describe('useFeatureUsageTracker', () => {
}
})
it('loads existing data from localStorage', async () => {
it('loads existing data from localStorage', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
@@ -122,8 +114,6 @@ describe('useFeatureUsageTracker', () => {
})
)
vi.resetModules()
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('existing-feature')
expect(useCount.value).toBe(5)

View File

@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as TopupTrackerModule from '@/platform/telemetry/topupTracker'
import {
startTopupTracking,
checkForCompletedTopup,
clearTopupTracking
} from '@/platform/telemetry/topupTracker'
import type { AuditLog } from '@/services/customerEventsService'
// Mock localStorage
@@ -25,19 +29,15 @@ vi.mock('@/platform/telemetry', () => ({
}))
describe('topupTracker', () => {
let topupTracker: typeof TopupTrackerModule
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks()
// Dynamically import to ensure fresh module state
topupTracker = await import('@/platform/telemetry/topupTracker')
})
describe('startTopupTracking', () => {
it('should save current timestamp to localStorage', () => {
const beforeTimestamp = Date.now()
topupTracker.startTopupTracking()
startTopupTracking()
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'pending_topup_timestamp',
@@ -57,7 +57,7 @@ describe('topupTracker', () => {
it('should return false if no pending topup exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)
const result = topupTracker.checkForCompletedTopup([])
const result = checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -66,7 +66,7 @@ describe('topupTracker', () => {
it('should return false if events array is empty', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = topupTracker.checkForCompletedTopup([])
const result = checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -75,7 +75,7 @@ describe('topupTracker', () => {
it('should return false if events array is null', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = topupTracker.checkForCompletedTopup(null)
const result = checkForCompletedTopup(null)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -94,7 +94,7 @@ describe('topupTracker', () => {
}
]
const result = topupTracker.checkForCompletedTopup(events)
const result = checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
@@ -122,7 +122,7 @@ describe('topupTracker', () => {
}
]
const result = topupTracker.checkForCompletedTopup(events)
const result = checkForCompletedTopup(events)
expect(result).toBe(true)
expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
@@ -144,7 +144,7 @@ describe('topupTracker', () => {
}
]
const result = topupTracker.checkForCompletedTopup(events)
const result = checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -164,7 +164,7 @@ describe('topupTracker', () => {
}
]
const result = topupTracker.checkForCompletedTopup(events)
const result = checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -189,7 +189,7 @@ describe('topupTracker', () => {
}
]
const result = topupTracker.checkForCompletedTopup(events)
const result = checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -198,7 +198,7 @@ describe('topupTracker', () => {
describe('clearTopupTracking', () => {
it('should remove pending topup from localStorage', () => {
topupTracker.clearTopupTracking()
clearTopupTracking()
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'

View File

@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTelemetry } from '@/platform/telemetry'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
@@ -9,17 +11,14 @@ describe('useTelemetry', () => {
vi.clearAllMocks()
})
it('should return null when not in cloud distribution', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
it('should return null when not in cloud distribution', () => {
const provider = useTelemetry()
// Should return null for OSS builds
expect(provider).toBeNull()
}, 10000)
it('should return null consistently for OSS builds', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
})
it('should return null consistently for OSS builds', () => {
const provider1 = useTelemetry()
const provider2 = useTelemetry()

View File

@@ -1,19 +1,96 @@
import { createPinia, setActivePinia } from 'pinia'
import { compare, valid } from 'semver'
import type { Mock } from 'vitest'
import { until } from '@vueuse/core'
import { setActivePinia } from 'pinia'
import { compare } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useReleaseService } from '@/platform/updates/common/releaseService'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { createTestingPinia } from '@pinia/testing'
import type { SystemStats } from '@/types'
// Mock the dependencies
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('semver', () => ({
compare: vi.fn(),
valid: vi.fn(() => '1.0.0')
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => true)
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
vi.mock('@/platform/updates/common/releaseService', () => {
const getReleases = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useReleaseService: () => ({
getReleases,
isLoading,
error
})
}
})
vi.mock('@/platform/settings/settingStore', () => {
const get = vi.fn((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const set = vi.fn()
return {
useSettingStore: () => ({ get, set })
}
})
const mockSystemStatsState = vi.hoisted(() => ({
systemStats: {
system: {
comfyui_version: '1.0.0',
argv: []
}
} satisfies {
system: Partial<SystemStats['system']>
},
isInitialized: true,
reset() {
this.systemStats = {
system: {
comfyui_version: '1.0.0',
argv: []
} satisfies Partial<SystemStats['system']>
}
this.isInitialized = true
}
}))
vi.mock('@/stores/systemStatsStore', () => {
const refetchSystemStats = vi.fn()
const getFormFactor = vi.fn(() => 'git-windows')
return {
useSystemStatsStore: () => ({
get systemStats() {
return mockSystemStatsState.systemStats
},
set systemStats(val) {
mockSystemStatsState.systemStats = val
},
get isInitialized() {
return mockSystemStatsState.isInitialized
},
set isInitialized(val) {
mockSystemStatsState.isInitialized = val
},
refetchSystemStats,
getFormFactor
})
}
})
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} })),
@@ -21,27 +98,6 @@ vi.mock('@vueuse/core', () => ({
}))
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
let mockReleaseService: {
getReleases: Mock
isLoading: ReturnType<typeof ref<boolean>>
error: ReturnType<typeof ref<string | null>>
}
let mockSettingStore: { get: Mock; set: Mock }
let mockSystemStatsStore: {
systemStats: {
system: {
comfyui_version: string
argv?: string[]
[key: string]: unknown
}
devices?: unknown[]
} | null
isInitialized: boolean
refetchSystemStats: Mock
getFormFactor: Mock
}
const mockRelease = {
id: 1,
project: 'comfyui' as const,
@@ -51,71 +107,16 @@ describe('useReleaseStore', () => {
attention: 'high' as const
}
beforeEach(async () => {
setActivePinia(createPinia())
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
// Reset all mocks
vi.clearAllMocks()
// Setup mock services with proper refs
mockReleaseService = {
getReleases: vi.fn(),
isLoading: ref(false),
error: ref(null)
}
mockSettingStore = {
get: vi.fn(),
set: vi.fn()
}
mockSystemStatsStore = {
systemStats: {
system: {
comfyui_version: '1.0.0'
}
},
isInitialized: true,
refetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
// Setup mock implementations
const { useReleaseService } =
await import('@/platform/updates/common/releaseService')
const { useSettingStore } = await import('@/platform/settings/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
const { isElectron } = await import('@/utils/envUtil')
vi.mocked(useReleaseService).mockReturnValue(
mockReleaseService as Partial<
ReturnType<typeof useReleaseService>
> as ReturnType<typeof useReleaseService>
)
vi.mocked(useSettingStore).mockReturnValue(
mockSettingStore as Partial<
ReturnType<typeof useSettingStore>
> as ReturnType<typeof useSettingStore>
)
vi.mocked(useSystemStatsStore).mockReturnValue(
mockSystemStatsStore as Partial<
ReturnType<typeof useSystemStatsStore>
> as ReturnType<typeof useSystemStatsStore>
)
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(valid).mockReturnValue('1.0.0')
// Default showVersionUpdates to true
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
store = useReleaseStore()
vi.resetAllMocks()
mockSystemStatsState.reset()
})
describe('initial state', () => {
it('should initialize with default state', () => {
const store = useReleaseStore()
expect(store.releases).toEqual([])
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
@@ -124,6 +125,7 @@ describe('useReleaseStore', () => {
describe('computed properties', () => {
it('should return most recent release', () => {
const store = useReleaseStore()
const olderRelease = {
...mockRelease,
id: 2,
@@ -136,6 +138,7 @@ describe('useReleaseStore', () => {
})
it('should return 3 most recent releases', () => {
const store = useReleaseStore()
const releases = [
mockRelease,
{ ...mockRelease, id: 2, version: '1.1.0' },
@@ -148,6 +151,7 @@ describe('useReleaseStore', () => {
})
it('should show update button (shouldShowUpdateButton)', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
@@ -155,6 +159,7 @@ describe('useReleaseStore', () => {
})
it('should not show update button when no new version', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
@@ -163,19 +168,17 @@ describe('useReleaseStore', () => {
})
describe('showVersionUpdates setting', () => {
beforeEach(async () => {
store.releases = [mockRelease]
})
describe('when notifications are enabled', () => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
beforeEach(() => {
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
it('should show toast for medium/high attention releases', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
@@ -183,6 +186,7 @@ describe('useReleaseStore', () => {
})
it('should not show toast for low attention releases', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
const lowAttentionRelease = {
@@ -196,13 +200,18 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -210,11 +219,13 @@ describe('useReleaseStore', () => {
})
it('should fetch releases during initialization', async () => {
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
@@ -224,27 +235,35 @@ describe('useReleaseStore', () => {
})
describe('when notifications are disabled', () => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
beforeEach(() => {
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
})
it('should not show toast even with new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', () => {
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -252,15 +271,19 @@ describe('useReleaseStore', () => {
})
it('should skip fetching releases during initialization', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.initialize()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(releaseService.getReleases).not.toHaveBeenCalled()
})
it('should not fetch releases when calling fetchReleases directly', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.fetchReleases()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
})
@@ -268,11 +291,13 @@ describe('useReleaseStore', () => {
describe('release initialization', () => {
it('should fetch releases successfully', async () => {
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
@@ -282,12 +307,15 @@ describe('useReleaseStore', () => {
})
it('should include form_factor in API call', async () => {
mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
vi.mocked(systemStatsStore.getFormFactor).mockReturnValue('desktop-mac')
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
@@ -296,16 +324,22 @@ describe('useReleaseStore', () => {
})
it('should skip fetching when --disable-api-nodes is present', async () => {
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
await store.initialize()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should skip fetching when --disable-api-nodes is one of multiple args', async () => {
mockSystemStatsStore.systemStats!.system.argv = [
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--disable-api-nodes',
@@ -314,37 +348,46 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should fetch normally when --disable-api-nodes is not present', async () => {
mockSystemStatsStore.systemStats!.system.argv = [
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--verbose'
]
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(store.releases).toEqual([mockRelease])
})
it('should fetch normally when argv is undefined', async () => {
mockSystemStatsStore.systemStats!.system.argv = undefined
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
// TODO: Consider deleting this test since the types have to be violated for it to be relevant
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(store.releases).toEqual([mockRelease])
})
it('should handle API errors gracefully', async () => {
mockReleaseService.getReleases.mockResolvedValue(null)
mockReleaseService.error.value = 'API Error'
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue(null)
releaseService.error.value = 'API Error'
await store.initialize()
@@ -353,7 +396,9 @@ describe('useReleaseStore', () => {
})
it('should handle non-Error objects', async () => {
mockReleaseService.getReleases.mockRejectedValue('String error')
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockRejectedValue('String error')
await store.initialize()
@@ -361,12 +406,14 @@ describe('useReleaseStore', () => {
})
it('should set loading state correctly', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
let resolvePromise: (value: ReleaseNote[] | null) => void
const promise = new Promise<ReleaseNote[] | null>((resolve) => {
resolvePromise = resolve
})
mockReleaseService.getReleases.mockReturnValue(promise)
vi.mocked(releaseService.getReleases).mockReturnValue(promise)
const initPromise = store.initialize()
expect(store.isLoading).toBe(true)
@@ -378,19 +425,23 @@ describe('useReleaseStore', () => {
})
it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats = null
systemStatsStore.isInitialized = false
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(vi.mocked(until)).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
})
it('should not set loading state when notifications disabled', async () => {
mockSettingStore.get.mockImplementation((key: string) => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
@@ -403,16 +454,22 @@ describe('useReleaseStore', () => {
describe('--disable-api-nodes argument handling', () => {
it('should skip fetchReleases when --disable-api-nodes is present', async () => {
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
await store.fetchReleases()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should skip fetchReleases when --disable-api-nodes is among other args', async () => {
mockSystemStatsStore.systemStats!.system.argv = [
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--disable-api-nodes',
@@ -421,96 +478,109 @@ describe('useReleaseStore', () => {
await store.fetchReleases()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should proceed with fetchReleases when --disable-api-nodes is not present', async () => {
mockSystemStatsStore.systemStats!.system.argv = [
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--verbose'
]
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
})
it('should proceed with fetchReleases when argv is undefined', async () => {
mockSystemStatsStore.systemStats!.system.argv = undefined
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
})
it('should proceed with fetchReleases when system stats are not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats = null
systemStatsStore.isInitialized = false
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
})
})
describe('action handlers', () => {
beforeEach(async () => {
store.releases = [mockRelease]
})
it('should handle skip release', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleSkipRelease('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'skipped'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle show changelog', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleShowChangelog('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'changelog seen'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle whats new seen', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleWhatsNewSeen('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
"what's new seen"
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
@@ -519,7 +589,9 @@ describe('useReleaseStore', () => {
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', () => {
mockSettingStore.get.mockImplementation((key: string) => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
@@ -534,8 +606,10 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(compare).mockReturnValue(1)
mockSettingStore.get.mockImplementation((key: string) => {
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
@@ -546,8 +620,11 @@ describe('useReleaseStore', () => {
})
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockImplementation((key: string) => {
const store = useReleaseStore()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
@@ -562,8 +639,11 @@ describe('useReleaseStore', () => {
describe('edge cases', () => {
it('should handle missing system stats gracefully', async () => {
mockSystemStatsStore.systemStats = null
mockSettingStore.get.mockImplementation((key: string) => {
const store = useReleaseStore()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
systemStatsStore.systemStats = null
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
@@ -571,11 +651,13 @@ describe('useReleaseStore', () => {
await store.initialize()
// Should not fetch system stats when notifications disabled
expect(mockSystemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
})
it('should handle concurrent fetchReleases calls', async () => {
mockReleaseService.getReleases.mockImplementation(
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve([mockRelease]), 100)
@@ -589,41 +671,37 @@ describe('useReleaseStore', () => {
await Promise.all([promise1, promise2])
// Should only call API once due to loading check
expect(mockReleaseService.getReleases).toHaveBeenCalledTimes(1)
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
})
})
describe('isElectron environment checks', () => {
beforeEach(async () => {
// Set up a new version available
store.releases = [mockRelease]
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
describe('when running in Electron (desktop)', () => {
beforeEach(async () => {
const { isElectron } = await import('@/utils/envUtil')
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(true)
})
it('should show toast when conditions are met', () => {
vi.mocked(compare).mockReturnValue(1)
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -632,12 +710,12 @@ describe('useReleaseStore', () => {
})
describe('when NOT running in Electron (web)', () => {
beforeEach(async () => {
const { isElectron } = await import('@/utils/envUtil')
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should NOT show toast even when all other conditions are met', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Set up all conditions that would normally show toast
@@ -647,12 +725,15 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Test with high attention releases
@@ -672,6 +753,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even with high attention release', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
@@ -680,7 +762,10 @@ describe('useReleaseStore', () => {
})
it('should NOT show popup even for latest version', () => {
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)

View File

@@ -1,4 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { until } from '@vueuse/core'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -39,7 +41,7 @@ describe('useVersionCompatibilityStore', () => {
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
// Clear the mock dismissal storage
mockDismissalStorage.value = {}
@@ -357,17 +359,15 @@ describe('useVersionCompatibilityStore', () => {
describe('initialization', () => {
it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
await store.initialize()
expect(until).toHaveBeenCalled()
expect(vi.mocked(until)).toHaveBeenCalled()
})
it('should not fetch system stats if already available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
@@ -378,7 +378,7 @@ describe('useVersionCompatibilityStore', () => {
await store.initialize()
expect(until).not.toHaveBeenCalled()
expect(vi.mocked(until)).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -66,7 +67,7 @@ describe('useWorkflowStore', () => {
}
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
store = useWorkflowStore()
bookmarkStore = useWorkflowBookmarkStore()
vi.clearAllMocks()

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
@@ -108,7 +109,7 @@ describe('useWorkflowPersistence', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useTeamWorkspaceStore } from './teamWorkspaceStore'
@@ -111,7 +112,7 @@ const mockMemberWorkspace = {
describe('useTeamWorkspaceStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
sessionStorage.clear()

View File

@@ -3,6 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createGraphThumbnail } from '@/renderer/core/thumbnail/graphThumbnailRenderer'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
vi.mock('@/renderer/core/thumbnail/graphThumbnailRenderer', () => ({
createGraphThumbnail: vi.fn()
@@ -19,12 +22,6 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useWorkflowThumbnail } =
await import('@/renderer/core/thumbnail/useWorkflowThumbnail')
const { createGraphThumbnail } =
await import('@/renderer/core/thumbnail/graphThumbnailRenderer')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>

View File

@@ -205,7 +205,7 @@ defineExpose({ runButtonClick })
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
/>
</template>
</div>
@@ -237,7 +237,7 @@ defineExpose({ runButtonClick })
:node-data
:class="
cn(
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg',
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
nodeData.hasErrors &&
'ring-2 ring-inset ring-node-stroke-error'
)

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import {
CollapsibleRoot,
CollapsibleTrigger,
CollapsibleContent
} from 'reka-ui'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
</script>
<template>
<CollapsibleRoot class="flex flex-col">
<CollapsibleTrigger as-child>
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
<i class="icon-[lucide--menu] size-8" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="flex gap-2 flex-col">
<div class="w-full border-b-2 border-border-subtle" />
<Popover>
<template #button>
<Button variant="secondary" size="lg" class="w-full">
<i class="icon-[comfy--workflow]" />
{{ t('Workflows') }}
</Button>
</template>
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
</Popover>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="useWorkflowTemplateSelectorDialog().show('menu')"
>
<i class="icon-[comfy--template]" />
{{ t('sideToolbar.templates') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
"
>
<i class="icon-[lucide--log-out]" />
{{ t('linearMode.graphMode') }}
</Button>
<div class="w-full border-b-2 border-border-subtle" />
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -156,7 +156,11 @@ watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (
newAssets.length === oldAssets.length ||
(oldAssets.length === 0 && newAssets.length !== 1)
)
return
if (selectedIndex.value[0] <= 0) {
selectedIndex.value = [0, 0]
return

View File

@@ -200,9 +200,8 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}))
}))
const { useMinimap } =
await import('@/renderer/extensions/minimap/composables/useMinimap')
const { api } = await import('@/scripts/api')
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { api } from '@/scripts/api'
describe('useMinimap', () => {
let moduleMockCanvasElement: HTMLCanvasElement

View File

@@ -103,9 +103,7 @@ describe('useMinimapRenderer', () => {
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } =
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
it('should only render when redraw is needed', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -15,7 +16,7 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
describe('useMinimapSettings', () => {
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})

View File

@@ -4,6 +4,11 @@ import { ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
@@ -66,10 +71,7 @@ describe('useMinimapViewport', () => {
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
it('should calculate graph bounds from nodes', () => {
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
@@ -92,10 +94,7 @@ describe('useMinimapViewport', () => {
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', async () => {
const { calculateNodeBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
it('should handle empty graph', () => {
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
@@ -131,11 +130,7 @@ describe('useMinimapViewport', () => {
})
})
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
it('should calculate viewport transform', () => {
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
@@ -236,10 +231,7 @@ describe('useMinimapViewport', () => {
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
it('should calculate scale correctly', () => {
const testBounds = {
minX: 0,
minY: 0,

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