Compare commits

...

32 Commits

Author SHA1 Message Date
comfy-pr-bot
b53835998f [release] Increment version to 1.45.16 2026-06-08 20:59:33 +00:00
Comfy Org PR Bot
16db78a61a [backport core/1.45] fix: clear missing model on promoted widget change (#12696)
Backport of #12677 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 19:14:55 +09:00
Comfy Org PR Bot
82c87dfbdf [backport core/1.45] fix: defer node auto-pan until drag starts (#12700)
Backport of #12654 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 19:14:23 +09:00
Comfy Org PR Bot
400d0ef9a6 [backport core/1.45] fix: keep connected advanced inputs visible (#12690)
Backport of #12652 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 15:53:15 +09:00
Comfy Org PR Bot
2c85e09574 [backport core/1.45] fix(load3d): load Preview3DAdvanced / splat / pointcloud previews from temp/ (#12675)
Backport of #12671 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-05 20:35:00 -04:00
Comfy Org PR Bot
a650cefe30 [backport core/1.45] fix(load3d): load Preview3DAdvanced output from temp/, allow temp loadFolder (#12668)
Backport of #12661 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-05 11:14:50 -04:00
Comfy Org PR Bot
9c67099a9f [backport core/1.45] feat(load3d): register Preview3DAdvanced extension (#12657)
Backport of #12527 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-04 19:56:21 -04:00
Comfy Org PR Bot
2d9b1fed64 [backport core/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12639)
Backport of #11716 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-04 11:30:51 +09:00
Comfy Org PR Bot
deb4045f18 1.45.15 (#12608)
Patch version increment to 1.45.15

**Base branch:** `core/1.45`

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-06-02 14:41:53 -07:00
Terry Jia
0b3927d8d5 [backport core/1.45] feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12596)
## Summary
Backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/12545 to
core/1.45

Tested and Verified on local build
<img width="1886" height="1538" alt="image"
src="https://github.com/user-attachments/assets/6f5086e8-05c8-47c8-95cd-8c9bb9ae8a5a"
/>
2026-06-02 13:44:06 -07:00
Comfy Org PR Bot
955472dab5 [backport core/1.45] fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12586)
Backport of #12500 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-02 14:53:03 +09:00
Comfy Org PR Bot
4ad242181b [backport core/1.45] Track undo state on subgraph conversion (#12584)
Backport of #12575 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-01 18:59:02 -07:00
Comfy Org PR Bot
16dfc33df3 [backport core/1.45] Remove drag node test from interaction.spec.ts (#12588)
Backport of #12579 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-01 18:57:12 -07:00
Comfy Org PR Bot
1a8bf498ef [backport core/1.45] fix: preserve validation errors on execution start (#12547)
Backport of #12493 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:30 +09:00
Comfy Org PR Bot
7b8ad1c11b [backport core/1.45] fix: open model library for desktop model downloads (#12551)
Backport of #12478 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:19 +09:00
Comfy Org PR Bot
364bcb3831 [backport core/1.45] Fix node tooltip metadata i18n parsing (#12555)
Backport of #12469 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:30:43 +09:00
Comfy Org PR Bot
a6699f6922 [backport core/1.45] Fix interrupted audio playback from assets panel (#12524)
Backport of #12425 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 12:39:14 -07:00
Comfy Org PR Bot
962e70d7a5 [backport core/1.45] Fix ghost links on IO remove slot (#12522)
Backport of #12473 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 10:38:39 -07:00
Comfy Org PR Bot
6193b76157 [backport core/1.45] Fix restoring values to dynamic combos (#12489)
Backport of #12211 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 12:00:30 -07:00
Comfy Org PR Bot
c5c916f80e [backport core/1.45] Fix mask editor sometimes showing wrong image (#12483)
Backport of #12413 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:52:46 -07:00
Comfy Org PR Bot
badc97b982 [backport core/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12453)
Backport of #12447 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 22:12:03 -07:00
Comfy Org PR Bot
67affd2075 [backport core/1.45] Fix missing value control on 'Primitive Int' (#12461)
Backport of #12431 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:23:57 -07:00
Comfy Org PR Bot
1cd98e8ab2 1.45.14 (#12432)
Patch version increment to 1.45.14

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-23 02:36:28 +00:00
jaeone94
654a7d6904 Add error catalog display resolver (#12402)
## Summary

This PR introduces the frontend error catalog display resolver as the
foundation for the DES-220 / FE-816 error messaging work.

The main goal is to create a single FE boundary where raw Core/Cloud
error payloads can be converted into human-friendly display fields,
while preserving the original API contract fields (`message` and
`details`) unchanged. UI components can now prefer resolved display copy
when it exists and fall back to the raw API copy otherwise.

As a small concrete sample, this PR implements the first cataloged
validation error:

- `required_input_missing` is resolved as the `missing_connection`
catalog item.
- Panel title: `Missing connection`
- Panel message: `Required input slots have no connection feeding them.`
- Detail/item copy can include the node and input name, e.g. `KSampler
is missing a required input: model` and `KSampler - model`.
- Single-error toast/overlay-oriented fields are added to the data model
for follow-up UI work, but this PR does not redesign the overlay.

## What This PR Targets

This PR is intentionally scoped as the skeleton PR for the error catalog
UX system.

It adds:

- A new resolver module under `src/platform/errorCatalog`.
- Shared resolved display fields:
  - `catalogId`
  - `displayTitle`
  - `displayMessage`
  - `displayDetails`
  - `displayItemLabel`
  - `toastTitle`
  - `toastMessage`
- A resolver entry point for run-time workflow errors:
  - node validation errors
  - execution/runtime errors
  - prompt errors
- A resolver entry point for pre-run missing-resource groups:
  - missing node packs
  - swap nodes
  - missing models
  - missing media
- Error group wiring so `useErrorGroups` resolves display copy in one
place instead of making UI components own message decisions.
- The first real validation rule for `required_input_missing` / Missing
connection.
- The existing prompt error copy moved into the
`errorCatalog.promptErrors` namespace in `src/locales/en/main.json`.
- Tests covering the resolver, grouping behavior, panel rendering,
prompt error copy, missing group copy, and fallback behavior.

## What This PR Deliberately Does Not Target

This PR avoids the larger UX and product behavior changes so the
foundation can land separately.

It does not:

- Redesign the error overlay.
- Redesign the right-side error panel.
- Change the shape of Core/Cloud API error payloads.
- Replace raw `message` / `details`; those remain intact for
API-contract alignment and technical debugging.
- Re-group execution errors by final message type yet.
- Add special runtime error messaging for credits, timeouts, content
policy, OOM, or rate limits.
- Render the new `displayItemLabel` everywhere it will eventually be
useful.

## User-Facing Behavior

Most behavior is preserved.

The main visible change is for missing required input validation errors.
Those now display as Missing connection copy instead of exposing the raw
validation message directly.

Prompt errors should keep the same user-facing wording as before, but
the source of that wording now lives under the error catalog namespace.

Missing node/model/media/swap-node groups still preserve the existing
titles, counts, and friendly messages, but their display copy now flows
through the same resolver boundary.

Execution/runtime errors receive catalog fields for future toast/overlay
usage, but the current runtime overlay path intentionally keeps the raw
technical error copy until the overlay redesign PR decides how to
consume the new fields.

## Screenshots 
Before
<img width="505" height="266" alt="스크린샷 2026-05-22 오후 2 15 27"
src="https://github.com/user-attachments/assets/09e8eb31-dca4-42d8-8237-9474cb71a14c"
/>
<img width="463" height="317" alt="스크린샷 2026-05-22 오후 2 16 09"
src="https://github.com/user-attachments/assets/c0a0159e-5bd9-4b3f-9c21-c0040373fbca"
/>

After
<img width="482" height="297" alt="스크린샷 2026-05-22 오후 2 14 25"
src="https://github.com/user-attachments/assets/4ca10bf0-29d2-4b65-940e-0d78db3fd278"
/>
<img width="460" height="194" alt="스크린샷 2026-05-22 오후 2 16 55"
src="https://github.com/user-attachments/assets/20848054-5012-4dd3-b903-ef8c920f70c8"
/>


## Follow-Up PR Plan

This PR is the first stacked PR in the error catalog work. Follow-up PRs
are expected to build on this foundation in roughly this order:

1. Expand general execution error messaging.
- Add broader validation error handling beyond `required_input_missing`,
including list/range/value validation cases.
   - Add general runtime execution messaging.
- Continue migrating prompt error display decisions into the catalog
resolver.

2. Add special runtime error messaging.
   - Credits / insufficient credits.
   - Timeout.
   - Content not allowed / blocked content.
   - Server crash.
   - Out of memory.
   - Rate limiting.
   - Other high-volume Cloud-only runtime failures from DES-220.

3. Re-group execution errors by message/catalog type.
- Move away from grouping primarily by node class when the cataloged
error type is the more useful user-facing grouping key.
   - Keep raw technical details available inside cards/logs.

4. Update the error overlay behavior.
   - Use `toastTitle` and `toastMessage` for single-error cases.
   - Use aggregate copy such as "N errors found" for multi-error cases.
   - Add node navigation affordances where appropriate.

5. Update the right-side error panel design.
   - Render resolved item labels such as `Node name - Input name`.
   - Align expanded card details and logs with the new design.
   - Preserve copy/debug affordances for technical details.

6. Fold in related missing media/model/node messaging improvements.
- FE-583 should become a child/follow-up issue in this stack for
improving missing image/media messaging.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- Targeted resolver/grouping tests during review iterations
- `pnpm knip`

`pnpm knip` passes with only the pre-existing tag hint:

`Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer →
@knipIgnoreUnusedButUsedByCustomNodes`
2026-05-23 01:31:29 +00:00
imick-io
eeeacc9b03 feat(website): constrain sections to max-w-9xl on wide screens (#12428)
Add max-w-9xl mx-auto to section/container wrappers across the website
so layout stays centered and capped at 96rem on screens wider than
1536px.

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:36:29 +00:00
Terry Jia
f744a4f1f8 chore: gitignore .nx daemon workspace data (#12427)
## Summary
add .nx to gitignore as it causes some workspace-cache now
2026-05-22 19:45:44 +00:00
Terry Jia
0588ca45b3 test: remove stale primevue mocks from load3d tests (#12350)
## Summary
The load3d components no longer import primevue/select or
primevue/checkbox, so the vi.mock blocks targeting them had no effect.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12350-test-remove-stale-primevue-mocks-from-load3d-tests-3666d73d365081f692dbe91e16d594c3)
by [Unito](https://www.unito.io)
2026-05-22 19:32:18 +00:00
Daxiong (Lin)
60ce0ee0c3 Add png and ico favicon for comfy.org (#12426) 2026-05-22 11:55:41 -07:00
jaeone94
91d2df45a1 Fix V2 draft lifecycle persistence (#12269)
## Summary

This PR fixes the remaining FE-367 workflow persistence gap by moving
the workflow draft lifecycle callers from the legacy V1 draft store to
`workflowDraftStoreV2`, following the core design from #10367 while
omitting unrelated changes.

It keeps the change focused on saved workflow tab restore and V2 draft
lifecycle behavior:

- save active workflow drafts through V2 before loading a new graph
- load, save, save-as, close, rename, and delete workflows against V2
draft storage
- prefer a fresh V2 draft when loading a saved workflow, and discard
stale drafts when the remote workflow is newer
- restore saved open tabs from persisted tab state instead of letting
stale active-path state win
- preserve V2 draft payload timestamps when moving or refreshing draft
recency
- remove the now-unused V1 draft store/cache implementation instead of
suppressing knip; the raw V1 on-disk migration path remains for existing
users

Co-authored-by: xmarre <xmarre@users.noreply.github.com>

## Test coverage

Added unit coverage for V2 draft load, stale draft discard, rename/close
lifecycle cleanup, tab restore ordering, metadata-load waiting/fallback,
draft recency updates, quota eviction retry, and persistence-disabled
reset behavior.

Updated the workflow persistence composable tests to use a real
`vue-i18n` plugin host instead of mocking `vue-i18n`.

Added an E2E regression test that saves two workflows, edits an inactive
saved tab draft, makes the active-path pointer stale, reloads, and
verifies the saved tab order, active tab, and inactive draft
restoration.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- pre-push `pnpm knip` (passes with the existing flac tag hint)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12269-Fix-V2-draft-lifecycle-persistence-3606d73d365081b4a84feb1696ed88bb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: xmarre <xmarre@users.noreply.github.com>
2026-05-22 15:24:31 +00:00
Robin Huang
7b4fef5eca fix: show empty state when node library search has no matches (#12254)
The left-sidebar node library search fell back to rendering all visible
node defs whenever the filter returned zero hits, so gibberish queries
looked like the filter wasn't applied. Gates the fallback on the query
string and renders a "No nodes match" empty state across all tabs
(Essentials/All/Blueprints) when the active query has no results.

Before:


https://github.com/user-attachments/assets/ab11ef5e-c757-41f1-9e07-3427942b9929

After: 



https://github.com/user-attachments/assets/a724aaab-95a2-4832-a694-3d8e543fdabf


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12254-fix-show-empty-state-when-node-library-search-has-no-matches-3606d73d365081d19eaaff1095355072)
by [Unito](https://www.unito.io)
2026-05-22 10:15:26 +00:00
Alexander Brown
c703db5f6c chore: remove Nx and migrate monorepo workflows to pnpm (#12355)
## Summary
- remove Nx root/config artifacts and workspace 
x metadata
- replace Nx-based root scripts with direct pnpm workspace + native
Vite/Vitest/Playwright commands
- remove Nx dependencies/catalog entries and regenerate lockfile
- clean residual Nx references from CI workflows, tooling config, and
docs

## Validation
- pnpm install --frozen-lockfile
- pnpm lint
- pnpm typecheck
- pnpm build
- pnpm build:cloud
- pnpm build:desktop
- pnpm build:types
- pnpm test:browser -- --list
- pnpm test:unit *(4 pre-existing failing tests)*

ΓöåIssue is synchronized with this [Notion
page](https://www.notion.so/PR-12355-chore-remove-Nx-and-migrate-monorepo-workflows-to-pnpm-3666d73d3650817888b9e85e24a10b22)
by [Unito](https://www.unito.io)

---

**For keeping Nx**
- Better monorepo orchestration (task graph, affected runs, caching).
- Can reduce CI/runtime cost at larger scale.
- Unified interface across tools and packages.

**Against keeping Nx**
- Extra abstraction layer and config complexity.
- Higher maintenance/debugging cost (plugins, cache behavior, drift).
- Native `pnpm` + tool CLIs are simpler and clearer today.

**Bottom line**
- If current pain is low: prefer simplicity and remove Nx.
- If measured scale pain is high: orchestration may be worth it (Nx or
another tool later).

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 05:37:10 +00:00
skishore23
3011d3a60c feat: OAuth consent UI for authorization (BE-638) (#12159)
## Summary

Frontend half of MCP OAuth (BE-638) — `oauth_request_id` plumbing, Cloud
session cookie integration, and the consent screen that cloud's
`/oauth/authorize` hands off to the browser. Scoped strictly to
OAuth/consent code; local cloud-dev support is held back as local-only
changes.

## Changes

- **What**:
- `oauth_request_id` capture + `sessionStorage` preservation across
login / signup / SSO (`onboardingCloudRoutes.ts`,
`preservedQueryNamespaces.ts`, router guards). Cleared on success or
explicit logout. Never forwarded to a third-party redirect.
- `POST /api/auth/session` integration so the Cloud session cookie is
set before the consent fetch (`useSessionCookie.ts`).
- Consent route `/cloud/oauth/consent` — fetches the JSON challenge,
renders client display name + scopes + redirect URI + Native/Web
app-type badge, submits the user's decision.
- Workspace picker: inline radio list (mirrors the cloud workspace
switcher) using `WorkspaceProfilePic` avatars. Capped at `max-h-72` with
`scrollbar-custom` so 10+ workspaces stay discoverable.
- Cloud calls go via relative URLs (same-origin through Vite proxy /
prod reverse proxy) — fixes the cross-origin cookie loss that would
bounce the consent challenge back to login.
- 400 / 403 / 404 cloud errors map to user-facing copy (expired /
scope_broadening / feature_unavailable).
- `vite.config.mts` only adds the `/oauth` proxy (5 lines) — required
for same-origin OAuth calls in dev.
- **Breaking**: None.
- **Dependencies**: None added.

## Review Focus

- **Cross-origin cookie footgun** (`oauthApi.ts:54-67`): chose
same-origin relative URLs deliberately, comment captures why.
- **Deny + workspace_id fallback** (`OAuthConsentView.vue:312-313`): if
user denies without picking a workspace, we send `workspaces[0].id`.
Cloud team should confirm deny is workspace-independent.
- **Login-flow preservation**: confirm no third-party redirect ever sees
`oauth_request_id`.
- **`useSessionCookie` ordering**: session cookie must be set before any
OAuth resume navigation fires.
- **`labelFor()` uses computed i18n keys** (`oauth.workspace.${value}`):
static key analyzer flags `personal`/`team`/`owner`/`member` as unused,
but they're read at runtime.

## Commits

| | Commit | Scope |
|---|---|---|
| 1 | `<foundation>` | OAuth plumbing — `oauth_request_id`, session
cookie, consent route, API client (10 files) |
| 2 | `a973abec0` | UI polish — inline workspace picker, error mapping,
app-type badge, redirect URI (5 files) |

## Test plan

- [x] `pnpm test:unit src/platform/cloud/oauth/
src/platform/auth/session/` — 17/17 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual staging E2E — blocked on cloud-side BE-633–BE-637

## Out of scope (kept as local-only changes)

Local cloud-dev fixes (Firebase auth emulator wiring, local API base,
Mixpanel disable, registry proxy, `__DEV_SERVER_COMFYUI_URL__` build
define) are useful for running the OAuth flow against a local cloud
backend, but aren't required for staging/prod. They're held back from
this PR and can ship separately if needed.

## Supersedes

Closes #12158.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12159-feat-OAuth-consent-UI-for-MCP-authorization-BE-638-35e6d73d3650811e956ff550995f40e6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 04:11:55 +00:00
228 changed files with 11145 additions and 3597 deletions

View File

@@ -45,12 +45,8 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
run: pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -59,7 +59,7 @@ jobs:
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6

5
.gitignore vendored
View File

@@ -19,6 +19,7 @@ yarn.lock
node_modules
.pnpm-store
.nx
dist
dist-ssr
*.local
@@ -89,10 +90,6 @@ storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*

View File

@@ -1 +0,0 @@
.claude/worktrees

View File

@@ -2,7 +2,6 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",

View File

@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
## Monorepo Architecture
The project uses **Nx** for build orchestration and task management
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
## Package Manager
@@ -237,7 +237,6 @@ See @docs/testing/\*.md for detailed patterns.
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records

View File

@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[nx serve hangs]
B -->|Dev server stuck| C[pnpm dev hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
@@ -23,7 +23,7 @@ flowchart TD
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
@@ -41,11 +41,11 @@ flowchart TD
### Development Server Issues
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
#### Q: `pnpm dev` gets stuck and won't start
**Symptoms:**
- Command hangs on "nx serve"
- Command hangs during Vite startup
- Dev server doesn't respond
- Terminal appears frozen
@@ -65,7 +65,7 @@ flowchart TD
3. **Last resort - Full node_modules reset:**
```bash
pnpm dlx rimraf node_modules && pnpm i
pnpm clean:all && pnpm i
```
**Why this happens:**
@@ -73,7 +73,7 @@ flowchart TD
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- NX cache corruption
- stale local build cache
---

View File

@@ -3,8 +3,11 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
"lint": "eslint src --cache",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
@@ -33,88 +36,5 @@
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {
"tags": [
"scope:desktop",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite preview --config vite.config.mts"
}
},
"storybook": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook dev -p 6007"
}
},
"build-storybook": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook build -o dist/storybook"
},
"outputs": [
"{projectRoot}/dist/storybook"
]
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "eslint src --cache"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vue-tsc --noEmit -p tsconfig.json"
}
}
}
}
}

View File

@@ -45,88 +45,5 @@
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<GlassCard
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -74,7 +74,7 @@ useHeroAnimation({
</div>
<!-- Video -->
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/about/co-founders.webm"
poster="https://media.comfy.org/website/about/co-founders-poster.webp"

View File

@@ -33,7 +33,7 @@ const values: {
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<SectionLabel>
{{ t('about.values.label', locale) }}

View File

@@ -16,7 +16,7 @@ const investors = [
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"

View File

@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
<template #right-card>
<img

View File

@@ -41,7 +41,7 @@ function toggle(index: number) {
</script>
<template>
<section class="px-4 py-24 md:px-20 md:py-40">
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -46,7 +46,9 @@ const cards = excludeProduct
</script>
<template>
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
>
<!-- Header -->
<div class="flex flex-col items-center px-4 text-center">
<SectionLabel v-if="labelKey">

View File

@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section class="px-6 py-16 lg:px-16 lg:py-24">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<div
ref="trackRef"
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
>
<div
v-for="(fb, i) in feedbacks"

View File

@@ -72,7 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<template>
<section
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in customerStories"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-16 lg:px-20 lg:py-40">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/silverside/video.webm"
poster="https://media.comfy.org/website/customers/silverside/poster.webp"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"

View File

@@ -223,7 +223,10 @@ while (idx < items.length) {
</script>
<template>
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
<section
data-testid="gallery-grid"
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
>
<!-- Desktop grid -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"

View File

@@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
>
<SectionLabel>
{{ t('gallery.label', locale) }}
</SectionLabel>

View File

@@ -15,7 +15,7 @@ const row2 = [
<template>
<section
class="bg-primary-comfy-ink flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
class="bg-primary-comfy-ink max-w-9xl mx-auto flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
>
<!-- Node rows -->
<div

View File

@@ -12,7 +12,9 @@ const routes = getRoutes(locale)
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<GlassCard
class="flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -36,7 +36,9 @@ const steps = [
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<div class="flex flex-col gap-12 lg:flex-row lg:gap-8">
<!-- Left heading -->
<div

View File

@@ -15,7 +15,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"

View File

@@ -55,7 +55,10 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />

View File

@@ -121,7 +121,7 @@ const activePlanIndex = ref(0)
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-14">
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
@@ -135,7 +135,7 @@ const activePlanIndex = ref(0)
</div>
<!-- Mobile plan tabs -->
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"

View File

@@ -60,7 +60,7 @@ const features: IncludedFeature[] = [
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div

View File

@@ -25,7 +25,7 @@ const cards = [
</script>
<template>
<section class="px-4 pt-24 lg:px-20 lg:pt-40">
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="bg-transparency-white-t4 rounded-5xl mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
class="bg-transparency-white-t4 rounded-5xl max-w-9xl mx-auto mt-4 mb-24 p-2 px-4 lg:mt-8 lg:mb-40 lg:px-20"
>
<div
class="bg-primary-comfy-yellow flex flex-col gap-24 rounded-4xl p-8 lg:flex-row lg:items-end lg:justify-between"

View File

@@ -442,7 +442,7 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<GlassCard
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-16"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-4 py-24 lg:px-20 lg:py-40">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40">
<div
class="bg-transparency-white-t4 rounded-5xl flex flex-col-reverse items-stretch gap-10 p-2 lg:flex-row lg:gap-8"
>

View File

@@ -77,7 +77,9 @@ function getCardClass(layoutClass: string): string {
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"

View File

@@ -11,7 +11,7 @@ defineProps<{
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template v-if="subtitle" #subtitle>

View File

@@ -22,7 +22,7 @@ defineProps<{
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template #subtitle>

View File

@@ -29,7 +29,7 @@ const {
<template>
<section
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
class="max-w-9xl mx-auto flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
>
<!-- Left heading -->
<div

View File

@@ -72,6 +72,9 @@ const websiteJsonLd = {
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -0,0 +1,115 @@
{
"id": "test-missing-model-promoted-widget",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -213,7 +213,8 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
incrementButton: widget.getByTestId(TestIds.widgets.increment)
incrementButton: widget.getByTestId(TestIds.widgets.increment),
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
}
}

View File

@@ -27,6 +27,10 @@ export class ContextMenu {
await this.waitForHidden()
}
menuItem(name: string): Locator {
return this.anyMenu.getByRole('menuitem', { name, exact: true })
}
/**
* Click a litegraph menu entry. Selects the most recently opened matching
* entry so nested submenu items can be reached without being shadowed by

View File

@@ -11,6 +11,11 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
type RunOptions = {
nodeErrors?: Record<string, NodeError>
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
}
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
@@ -70,8 +75,9 @@ export class ExecutionHelper {
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
async run(options: RunOptions = {}): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
const { nodeErrors = {}, onPromptRequest } = options
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
@@ -81,12 +87,13 @@ export class ExecutionHelper {
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await onPromptRequest?.(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
node_errors: nodeErrors
})
})
fulfilled()

View File

@@ -62,12 +62,39 @@ export class WorkflowHelper {
async waitForDraftPersisted() {
await this.comfyPage.page.waitForFunction(() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
Object.keys(localStorage).some((key) =>
key.startsWith('Comfy.Workflow.Draft.v2:')
)
)
}
/** Waits for V2 draft index recency, not payload content freshness. */
async waitForDraftIndexUpdatedSince(updatedSince: number) {
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (
typeof index.updatedAt === 'number' &&
index.updatedAt >= indexUpdatedSince
) {
return true
}
} catch {
// Ignore malformed storage while waiting for persistence.
}
}
return false
}, updatedSince)
}
/**
* Reloads the current page and waits for the app to initialize.
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and

View File

@@ -135,7 +135,8 @@ export const TestIds = {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
},
menu: {
moreMenuContent: 'more-menu-content'
@@ -152,6 +153,7 @@ export const TestIds = {
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
valueControl: 'value-control',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',

View File

@@ -4,6 +4,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ChangeTrackerDebugState = {
changeCount: number
@@ -310,4 +311,28 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
]
})
})
test(
'Tracks convert to subgraph as undo step',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
await input.fill('40')
await node.title.click()
await comfyPage.page
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
.click()
await expect(input).toBeHidden()
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('40')
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('512')
}
)
})

View File

@@ -1,7 +1,60 @@
import { expect } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { NodeError } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const VALIDATION_ERROR_NODE_ID = '1'
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
type PromptRequestNode = {
class_type?: string
}
type PromptRequestBody = {
prompt?: Record<string, PromptRequestNode>
}
function buildPreviewAnyValidationError(): NodeError {
return {
class_type: 'PreviewAny',
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
errors: [
{
type: 'required_input_missing',
message: VALIDATION_ERROR_MESSAGE,
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
function expectPartialExecutionRootNodes(requestBody: unknown): void {
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
}
}
async function getValidationErrorMessage(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(
(nodeId) =>
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
?.message ?? null,
VALIDATION_ERROR_NODE_ID
)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -74,3 +127,48 @@ test.describe(
})
}
)
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
test('preserves validation errors when another active root starts execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const nodeErrors = {
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
}
let promptRequestBody: unknown
const jobId = await exec.run({
nodeErrors,
onPromptRequest: (requestBody) => {
promptRequestBody = requestBody
}
})
expectPartialExecutionRootNodes(promptRequestBody)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await comfyPage.nextFrame()
exec.executionStart(jobId)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
await expect(errorOverlay).toBeVisible()
})
})

View File

@@ -166,15 +166,6 @@ test.describe('Node Interaction', () => {
})
})
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
test.describe('Node Duplication', () => {
test.beforeEach(async ({ comfyPage }) => {
// Pin this suite to the legacy canvas path so Alt+drag exercises

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,6 +1,10 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
)
})
wstest(
'Will not use stale litegraph previews',
async ({ comfyPage, getWebSocket }) => {
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview Image')
async function getNodeOutput() {
return await comfyPage.page.evaluate(
() => graph!.getNodeById('1')!.images?.[0]?.filename
)
}
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
await expect.poll(getNodeOutput).toBe('test1.png')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const resolvableFile = { filename: 'example.png', type: 'input' }
executionHelper.executed('', '1', { images: [resolvableFile] })
await expect.poll(getNodeOutput).toBe('example.png')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await node.imagePreview.hover()
await node.imagePreview
.getByRole('button', { name: 'Edit or mask image' })
.click()
// On previous versions, attempting to open the mask editor here would
// incorrectly reference the non-existant test1.png
// This causes the mask editor to throw in setup and not display
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
}
)

View File

@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
await expect(missingModelGroup).toBeHidden()
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
/**
* Expanded folder view must drop output records that resolve to the same
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
* v-for in VirtualGrid collides and one asset visibly duplicates its
* neighbours while scrolling.
*/
const STACK_JOB_ID = 'job-output-dedupe'
const COVER_NODE_ID = '9'
const COVER_FILENAME = 'cover_00001_.png'
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
// 4 unique composite keys expected after dedupe.
const STACK_JOB_OUTPUTS = [
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
...DISTINCT_FILENAMES.map((filename) => ({
filename,
subfolder: '',
type: 'output' as const
})),
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
]
const STACK_JOB = createMockJob({
id: STACK_JOB_ID,
create_time: 5000,
execution_start_time: 5000,
execution_end_time: 5050,
preview_output: {
filename: COVER_FILENAME,
subfolder: '',
type: 'output',
nodeId: COVER_NODE_ID,
mediaType: 'images'
},
outputs_count: STACK_JOB_OUTPUTS.length
})
const STACK_JOB_DETAIL: JobDetail = {
...STACK_JOB,
outputs: {
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
}
}
const EXPECTED_TOTAL_TILES = 4
test.describe(
'Expanded folder view dedupes duplicate composite output keys',
{ tag: '@cloud' },
() => {
// @cloud comfyPage already navigates with Firebase auth seeded; a second
// setup() call would clear localStorage and bounce to /cloud/login.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([STACK_JOB])
await comfyPage.assets.mockInputFiles([])
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('renders one tile per unique composite key', async ({
comfyPage
}, testInfo) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
const labels = await tab.assetCards.evaluateAll((nodes) =>
nodes
.map((el) => el.getAttribute('aria-label'))
.filter((v): v is string => v !== null)
)
expect(new Set(labels).size).toBe(labels.length)
await testInfo.attach('expanded-folder-view.png', {
body: await comfyPage.page.screenshot({ fullPage: false }),
contentType: 'image/png'
})
})
}
)

View File

@@ -1,8 +1,7 @@
import { expect } from '@playwright/test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -651,6 +650,12 @@ test(
await expect.poll(isConnected).toBe(true)
})
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
@@ -659,9 +664,6 @@ test(
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
@@ -699,5 +701,18 @@ test(
'opacity',
'0'
)
await test.step('Can disconnect link by right click', async () => {
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
const { x, y } = await stepsIOSlot.getPosition()
await comfyPage.page.mouse.click(x, y, { button: 'right' })
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await expect(slotParent).toHaveCSS('opacity', '0')
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const postScreenshot = await comfyPage.page.screenshot({ clip })
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
})
}
)

View File

@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(nodeRef).toBeBypassed()
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
@@ -174,12 +174,33 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(nodeRef).not.toBeBypassed()
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
await openContextMenu(comfyPage, nodeTitle)
await expect(bypassItem).toHaveCount(1)
await expect(removeBypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect(nodeRef).toBeBypassed()
await openContextMenu(comfyPage, nodeTitle)
await expect(removeBypassItem).toHaveCount(1)
await expect(bypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect(nodeRef).not.toBeBypassed()
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
@@ -451,7 +472,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(nodeRef).toBeBypassed()
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
@@ -460,7 +481,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(nodeRef).not.toBeBypassed()
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)

View File

@@ -54,6 +54,35 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
})
}
const advancedButtonOverflowPx = 24
const holdPointCanvasInsetPx = 8
const getAdvancedInputsButton = (node: Locator) =>
node.getByTestId('advanced-inputs-button')
const moveAdvancedButtonRightEdgePastCanvas = async (
comfyPage: ComfyPage,
button: Locator,
overflow: number
) => {
const box = await button.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!box) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const scale = await comfyPage.canvasOps.getScale()
const deltaX = canvasBox.x + canvasBox.width + overflow - box.x - box.width
await comfyPage.page.evaluate(
({ deltaX, scale }) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] += deltaX / scale
canvas.setDirty(true, true)
},
{ deltaX, scale }
)
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -123,7 +152,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const showButton = getAdvancedInputsButton(node)
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
@@ -143,6 +172,83 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(beforePos, afterPos)
})
test(
'should not pan while holding the Advanced button without dragging',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = getAdvancedInputsButton(node)
await expect(showButton).toBeVisible()
await moveAdvancedButtonRightEdgePastCanvas(
comfyPage,
showButton,
advancedButtonOverflowPx
)
const buttonBox = await showButton.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!buttonBox) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const canvasRight = canvasBox.x + canvasBox.width
const buttonRight = buttonBox.x + buttonBox.width
expect(
buttonRight,
'Advanced button should extend past the canvas right edge'
).toBeGreaterThan(canvasRight)
const holdPoint = {
x: canvasRight - holdPointCanvasInsetPx,
y: buttonBox.y + buttonBox.height / 2
}
expect(
holdPoint.x,
'Hold point should stay inside the visible part of the Advanced button'
).toBeGreaterThanOrEqual(buttonBox.x)
expect(
holdPoint.x,
'Hold point should stay inside the visible canvas'
).toBeLessThanOrEqual(canvasRight)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeGreaterThanOrEqual(buttonBox.y)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeLessThanOrEqual(buttonBox.y + buttonBox.height)
const beforeOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(holdPoint.x, holdPoint.y)
await comfyPage.page.mouse.down()
try {
await comfyPage.idleFrames(8)
} finally {
await comfyPage.page.mouse.up()
}
const afterOffset = await comfyPage.canvasOps.getOffset()
expect(afterOffset[0]).toBeCloseTo(beforeOffset[0], 3)
expect(afterOffset[1]).toBeCloseTo(beforeOffset[1], 3)
}
)
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {

View File

@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
const FLOAT_SOURCE_POSITION_LEFT_OF_NODE = { x: 100, y: 200 }
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -32,6 +33,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
return getNode(comfyPage).locator('.lg-node-widget')
}
async function getWidgetIndex(comfyPage: ComfyPage, widgetName: string) {
const index = await comfyPage.page.evaluate((name) => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return node?.widgets?.findIndex((widget) => widget.name === name) ?? -1
}, widgetName)
expect(
index,
`${widgetName} widget should exist on ModelSamplingFlux`
).toBeGreaterThanOrEqual(0)
return index
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
@@ -72,6 +87,47 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
})
test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const maxShiftWidget = node.getByLabel('max_shift', { exact: true })
const baseShiftWidget = node.getByLabel('base_shift', { exact: true })
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeVisible()
const primitive = await comfyPage.nodeOps.addNode(
'PrimitiveFloat',
{},
FLOAT_SOURCE_POSITION_LEFT_OF_NODE
)
const [target] =
await comfyPage.nodeOps.getNodeRefsByType('ModelSamplingFlux')
const maxShiftIndex = await getWidgetIndex(comfyPage, 'max_shift')
await primitive.connectWidget(0, target, maxShiftIndex)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return (
node?.inputs.find((input) => input.widget?.name === 'max_shift')
?.link ?? null
)
})
)
.not.toBeNull()
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeHidden()
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {

View File

@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
test('displays control widgets with default state', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Int')
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
await expect(widget).toBeVisible()
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
await expect(valueControl).toBeVisible()
})
})

View File

@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push(node.widgets![0])
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = node.widgets![0]
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, node.widgets![0])
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
})
await expect(loadCheckpointNode).toHaveCount(4)
})
@@ -52,4 +55,24 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
})
await expect(loadCheckpointNode).toHaveCount(3)
})
test('Can load dynamic combos', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
await test.step('Update value of the dynamic combo widget', async () => {
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
await expect(widget).toHaveText('scale width')
})
await test.step('Swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await expect(widget).toBeHidden()
await comfyPage.menu.topbar.getTab(0).click()
await expect(widget).toBeVisible()
})
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
})

View File

@@ -4,6 +4,103 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
const generateUniqueFilename = () =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const waitForWorkflowTabState = async (comfyPage: ComfyPage, minPaths = 2) => {
await comfyPage.page.waitForFunction((expectedMinPaths) => {
let hasActivePath = false
let hasOpenPaths = false
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.ActivePath:')) {
hasActivePath = true
}
if (!key?.startsWith('Comfy.Workflow.OpenPaths:')) {
continue
}
const raw = window.sessionStorage.getItem(key)
if (!raw) continue
try {
const state = JSON.parse(raw) as { paths?: unknown[] }
hasOpenPaths =
Array.isArray(state.paths) && state.paths.length >= expectedMinPaths
if (hasActivePath && hasOpenPaths) return true
} catch {
return false
}
}
return hasActivePath && hasOpenPaths
}, minPaths)
}
type NodeRef = NonNullable<
Awaited<ReturnType<ComfyPage['nodeOps']['getFirstNodeRef']>>
>
const getRequiredFirstNodeRef = async (
comfyPage: ComfyPage,
message: string
): Promise<NodeRef> => {
const node = await comfyPage.nodeOps.getFirstNodeRef()
expect(node, message).toBeDefined()
if (!node) throw new Error(message)
return node
}
const makeActivePathStale = async (
comfyPage: ComfyPage,
staleWorkflowName: string,
activeWorkflowName: string
) => {
// Intentionally desync ActivePath from OpenPaths to exercise stale pointer recovery.
await comfyPage.page.evaluate(
([staleName, activeName]) => {
const findStorageKey = (prefix: string) => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith(prefix)) return key
}
throw new Error(`Missing ${prefix} persistence key`)
}
const activePathKey = findStorageKey('Comfy.Workflow.ActivePath:')
const openPathsKey = findStorageKey('Comfy.Workflow.OpenPaths:')
const activePointer = JSON.parse(
window.sessionStorage.getItem(activePathKey)!
) as { path: string }
const openPointer = JSON.parse(
window.sessionStorage.getItem(openPathsKey)!
) as { paths: string[]; activeIndex: number }
const pathForName = (name: string) => {
const path = openPointer.paths.find((candidate) =>
candidate.endsWith(`${name}.json`)
)
if (!path) throw new Error(`Missing stored path for ${name}`)
return path
}
const stalePath = pathForName(staleName)
const activePath = pathForName(activeName)
activePointer.path = stalePath
openPointer.paths = [stalePath, activePath]
openPointer.activeIndex = 1
window.sessionStorage.setItem(
activePathKey,
JSON.stringify(activePointer)
)
window.sessionStorage.setItem(openPathsKey, JSON.stringify(openPointer))
},
[staleWorkflowName, activeWorkflowName]
)
}
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
@@ -103,9 +200,11 @@ test.describe('Workflow Persistence', () => {
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = String(firstNode!.id)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading the default workflow'
)
const nodeId = String(firstNode.id)
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
@@ -382,6 +481,59 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeCountB)
})
test('Restores saved workflow drafts from inactive restored tabs', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const workflowA = generateUniqueFilename()
const workflowB = generateUniqueFilename()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await fitToViewInstant(comfyPage)
await comfyPage.menu.topbar.saveWorkflow(workflowA)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading single_ksampler'
)
await firstNode.centerOnNode()
const draftSaveStartedAt = Date.now()
await firstNode.toggleCollapse()
expect(await firstNode.isCollapsed()).toBe(true)
await comfyPage.workflow.waitForDraftIndexUpdatedSince(draftSaveStartedAt)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
await waitForWorkflowTabState(comfyPage)
await makeActivePathStale(comfyPage, workflowA, workflowB)
await comfyPage.workflow.reloadAndWaitForApp()
await expect
.poll(() => comfyPage.menu.topbar.getActiveTabName())
.toBe(workflowB)
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
await comfyPage.menu.topbar.getWorkflowTab(workflowA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const restoredNode = await getRequiredFirstNodeRef(
comfyPage,
'Restored node should be available after switching back to workflow A'
)
expect(await restoredNode.isCollapsed()).toBe(true)
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
})
test('Closing an inactive tab with save preserves its own content', async ({
comfyPage
}) => {

View File

@@ -4,7 +4,7 @@ Date: 2025-08-25
## Status
Proposed
Accepted (Nx tooling choice superseded by [ADR-0010](0010-remove-nx-orchestration.md))
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
@@ -31,6 +31,8 @@ For more information on Monorepos, check out [monorepo.tools](https://monorepo.t
For monorepo management, I'd probably go with [Nx](https://nx.dev/), but I could be conviced otherwise.
There's a [whole list here](https://monorepo.tools/#tools-review) if you're interested.
> **Update:** The Nx tooling choice has since been reversed. See [ADR-0010: Remove Nx Orchestration](0010-remove-nx-orchestration.md) for the migration to direct pnpm workspace scripts and native tool CLIs.
## Consequences
### Positive

View File

@@ -0,0 +1,92 @@
# 10. Remove Nx Orchestration
Date: 2026-05-19
## Status
Accepted
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
[ADR-0002](0002-monorepo-conversion.md) adopted [Nx](https://nx.dev/) as a tooling option for managing the
ComfyUI Frontend monorepo on top of pnpm workspaces. Nx was introduced as task
orchestration to coordinate builds, tests, lints, and types across the apps and
packages workspaces.
In practice, Nx provided little value beyond what pnpm workspaces and the
underlying native tool CLIs (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
TypeScript) already offer:
- pnpm's `--filter` and `--recursive` flags already provide topological,
parallel, and selective execution across workspaces.
- Each underlying tool already has fast, well-supported caching (Vite, Vitest,
ESLint, oxlint, TS incremental builds, etc.).
- Nx added an extra configuration surface (`nx.json`, `.nxignore`, per-package
`nx` blocks), an extra cache layer, an extra `node_modules/.cache/nx`
artifact, and an extra CI dimension to debug.
- Contributors and AI agents had to learn the Nx mental model in addition to
pnpm and the individual tool CLIs.
- The Nx daemon and remote-cache features were not in use, so the runtime
benefit was limited to local task graph caching, which is largely redundant
with the per-tool caches.
The cost (configuration, mental overhead, surprise behavior, occasional
cache-related failures) exceeded the benefit.
## Decision
Remove Nx from the repository and run monorepo tasks using:
- pnpm workspace scripts (`pnpm -r run <script>`,
`pnpm --filter <pkg> run <script>`).
- Each tool's native CLI (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
`vue-tsc`, etc.) invoked directly from the relevant workspace.
Concretely, this change:
- Deletes `nx.json` and `.nxignore`.
- Removes `nx` entries from root and per-package `package.json` files (the
`nx` block on each `package.json`, the dev dependency, and Nx-specific
scripts).
- Removes `nx`-related entries from `pnpm-workspace.yaml`'s `allowBuilds`.
- Rewrites the affected CI workflows (`.github/workflows/ci-tests-e2e.yaml`,
`.github/workflows/release-draft-create.yaml`) to call pnpm/native CLIs
directly.
- Updates `AGENTS.md`, `TROUBLESHOOTING.md`, and
[ADR-0002](0002-monorepo-conversion.md) to reflect the new tooling story.
- Cleans up Nx-specific lint/format/ignore rules in `.oxlintrc.json`,
`eslint.config.ts`, `vite.config.mts`, and `.gitignore`.
## Consequences
### Positive
- Fewer moving parts: no `nx.json`, no `.nx/` cache, no Nx daemon, no
Nx-specific scripts to maintain.
- Easier onboarding for contributors and AI agents: pnpm + each tool's CLI is
the only required knowledge.
- CI logs and failures are easier to read because tasks run directly under the
tool that owns them, instead of being wrapped by Nx.
- Faster, more predictable cache invalidation behavior — each tool owns its
own cache and we no longer hit Nx-cache edge cases.
- Smaller dependency tree (~2k fewer lines in `pnpm-lock.yaml`).
### Negative
- We lose Nx's unified task graph and project graph commands; coordination
across workspaces now relies on pnpm filters and explicit script wiring.
- We lose Nx's remote/distributed caching as a future option without
re-adopting Nx (or a comparable tool like Turborepo).
- Contributors who already knew Nx workflows need to relearn the equivalent
pnpm invocations.
## Notes
- The migration is purely a tooling change; no application behavior, public
API, or build output changes.
- If we later need more sophisticated task orchestration (e.g. distributed
remote cache, fine-grained affected-graph queries), revisit this decision and
evaluate Nx, Turborepo, or Moon at that time, with concrete CI/perf data to
justify the additional complexity.

View File

@@ -8,16 +8,18 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ----------------------------------------------------------- | ------------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
## Creating a New ADR

View File

@@ -76,7 +76,6 @@ export default defineConfig([
{
ignores: [
'.i18nrc.cjs',
'.nx/*',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'components.d.ts',

41
nx.json
View File

@@ -1,41 +0,0 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
},
{
"plugin": "@nx/storybook/plugin",
"options": {
"serveStorybookTargetName": "storybook",
"buildStorybookTargetName": "build-storybook",
"testStorybookTargetName": "test-storybook",
"staticStorybookTargetName": "static-storybook"
}
},
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"testTargetName": "test",
"serveTargetName": "serve",
"devTargetName": "dev",
"previewTargetName": "preview",
"serveStaticTargetName": "serve-static",
"typecheckTargetName": "typecheck",
"buildDepsTargetName": "build-deps",
"watchDepsTargetName": "watch-deps"
}
},
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
}
}
],
"analytics": false
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.13",
"version": "1.45.16",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -8,20 +8,22 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
"build-storybook": "storybook build",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
@@ -34,26 +36,25 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src browser_tests --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"lint:desktop": "pnpm --filter @comfyorg/desktop-ui run lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src browser_tests --type-aware",
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"preview": "vite preview --config vite.config.mts",
"storybook": "storybook dev -p 6006",
"storybook:desktop": "pnpm --filter @comfyorg/desktop-ui run storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser": "pnpm exec playwright test",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"typecheck:website": "nx run @comfyorg/website:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
"typecheck:desktop": "pnpm --filter @comfyorg/desktop-ui run typecheck",
"typecheck:website": "pnpm --filter @comfyorg/website run typecheck",
"zipdist": "node scripts/zipdist.js"
},
"dependencies": {
"@alloc/quick-lru": "catalog:",
@@ -131,10 +132,6 @@
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",
"@nx/eslint": "catalog:",
"@nx/playwright": "catalog:",
"@nx/storybook": "catalog:",
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -180,7 +177,6 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"monocart-coverage-reports": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",

View File

@@ -19,11 +19,5 @@
"devDependencies": {
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:design"
]
}
}

View File

@@ -15,11 +15,5 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "0.93.0"
},
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -20,11 +20,5 @@
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

View File

@@ -5,11 +5,5 @@
"type": "module",
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -17,11 +17,5 @@
},
"devDependencies": {
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

2246
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,6 @@ catalog:
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.6.1
'@nx/playwright': 22.6.1
'@nx/storybook': 22.6.1
'@nx/vite': 22.6.1
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -102,7 +98,6 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
nx: 22.6.1
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
@@ -155,7 +150,6 @@ allowBuilds:
'@tailwindcss/oxide': true
core-js: false
esbuild: true
nx: true
oxc-resolver: true
protobufjs: false
sharp: false

View File

@@ -159,7 +159,7 @@
<audio
:ref="(el) => (audioRef = el as HTMLAudioElement)"
:src="audioSrc"
:src
preload="metadata"
class="hidden"
/>
@@ -192,7 +192,6 @@ const progressRef = ref<HTMLElement>()
const {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying,

View File

@@ -87,42 +87,26 @@ const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { allErrorGroups, missingModelGroups } = useErrorGroups(ref(''), t)
const { allErrorGroups } = useErrorGroups(ref(''))
const singleErrorType = computed(() => {
const types = new Set(allErrorGroups.value.map((g) => g.type))
return types.size === 1 ? [...types][0] : null
})
const friendlyMessageMap: Record<string, () => string> = {
missing_node: () => t('errorOverlay.missingNodes'),
swap_nodes: () => t('errorOverlay.swapNodes'),
missing_media: () => t('errorOverlay.missingMedia'),
missing_model: () => {
const modelCount = missingModelGroups.value.reduce(
(count, g) => count + g.models.length,
0
)
return t('errorOverlay.missingModels', { count: modelCount }, modelCount)
}
}
function toFriendlyMessage(group: (typeof allErrorGroups.value)[number]) {
return friendlyMessageMap[group.type]?.() ?? null
}
const overlayMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
const friendly = toFriendlyMessage(group)
if (friendly) {
messages.add(friendly)
} else if (group.type === 'execution') {
if (group.type === 'execution') {
// TODO(FE-816 overlay-redesign): Keep runtime overlay copy raw until the
// overlay redesign decides how to use catalog toast fields.
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
} else {
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)

View File

@@ -0,0 +1,222 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n, te } from '@/i18n'
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import NodeTooltip from './NodeTooltip.vue'
type HitTest = (
node: MockNode,
x: number,
y: number,
offset: [number, number]
) => number
interface MockWidget {
name: string
tooltip?: string
}
interface MockNode {
type: string
flags: {
collapsed?: boolean
ghost?: boolean
}
pos: [number, number]
inputs: Array<{ name: string }>
constructor: {
title_mode?: 0 | 1 | 2 | 3
}
}
interface MockCanvas {
mouse: [number, number]
graph_mouse: [number, number]
node_over: MockNode | null
getWidgetAtCursor: () => MockWidget | null
}
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsDOMWidget = vi.hoisted(() =>
vi.fn<(widget: MockWidget) => boolean>()
)
const mockCanvas = vi.hoisted(
(): MockCanvas => ({
mouse: [100, 80],
graph_mouse: [10, 10],
node_over: null,
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
})
)
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LiteGraphModule>()
return {
...actual,
isOverNodeInput: mockIsOverNodeInput,
isOverNodeOutput: mockIsOverNodeOutput
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: mockCanvas
}
}))
vi.mock('@/scripts/domWidget', () => ({
isDOMWidget: mockIsDOMWidget
}))
const jsonTooltip =
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
const positiveCoordsTooltipKey =
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const sam3DetectNodeDef: ComfyNodeDef = {
name: 'SAM3_Detect',
display_name: 'SAM3 Detect',
category: 'detection/',
python_module: 'comfy_extras.nodes_sam3',
description: '',
input: {
required: {},
optional: {
positive_coords: [
'STRING',
{
tooltip: jsonTooltip,
forceInput: true
}
]
}
},
output: ['MASK'],
output_name: ['masks'],
output_tooltips: [jsonTooltip],
output_node: false,
deprecated: false,
experimental: false
}
function createSam3Node(): MockNode {
return {
type: 'SAM3_Detect',
flags: {},
pos: [0, 0],
inputs: [{ name: 'positive_coords' }],
constructor: {}
}
}
function mergeOutputTooltipMessage(tooltip: string | null) {
i18n.global.mergeLocaleMessage('en', {
nodeDefs: {
SAM3_Detect: {
outputs: {
0: {
tooltip
}
}
}
}
})
}
async function renderAndHoverCanvas() {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(NodeTooltip)
const canvas = document.createElement('canvas')
document.body.appendChild(canvas)
await user.hover(canvas)
await vi.runOnlyPendingTimersAsync()
await nextTick()
}
describe('NodeTooltip', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(useSettingStore(), 'get').mockImplementation(
<K extends keyof Settings>(key: K): Settings[K] => {
switch (key) {
case 'LiteGraph.Node.TooltipDelay':
return 0 as Settings[K]
default:
return undefined as Settings[K]
}
}
)
mockCanvas.mouse = [100, 80]
mockCanvas.graph_mouse = [10, 10]
mockCanvas.node_over = createSam3Node()
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
mergeOutputTooltipMessage(jsonTooltip)
})
afterEach(() => {
mergeOutputTooltipMessage(null)
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(outputTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
name: 'positive_coords'
})
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
})

View File

@@ -13,7 +13,7 @@
import { useEventListener } from '@vueuse/core'
import { nextTick, ref } from 'vue'
import { st } from '@/i18n'
import { stRaw } from '@/i18n'
import {
LiteGraph,
isOverNodeInput,
@@ -84,7 +84,7 @@ function onIdle() {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef?.inputs[inputName]?.tooltip ?? ''
)
@@ -98,7 +98,7 @@ function onIdle() {
[0, 0]
)
if (outputSlot !== -1) {
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
)
@@ -108,7 +108,7 @@ function onIdle() {
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef?.inputs[widget.name]?.tooltip ?? ''
)

View File

@@ -44,11 +44,14 @@
/>
</div>
<div
v-if="canFitToViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
>
<div class="flex flex-col rounded-lg bg-backdrop/30">
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
@@ -61,25 +64,29 @@
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
</div>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-24 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<div
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-24': !enable3DViewer,
'top-36': enable3DViewer
}"
>
<RecordingControls
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -142,6 +149,7 @@ const {
isRecording,
isPreview,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -175,6 +183,7 @@ const {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -6,24 +6,6 @@ import { createI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'UiSlider',

View File

@@ -11,7 +11,7 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
<i class="pi pi-camera text-lg text-base-foreground" />
</Button>
<PopupSlider
v-if="showFOVButton"

View File

@@ -6,23 +6,6 @@ import { createI18n } from 'vue-i18n'
import ViewerSceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
vi.mock('primevue/checkbox', () => ({
default: {
name: 'Checkbox',
props: ['modelValue', 'inputId', 'binary', 'name'],
emits: ['update:modelValue'],
template: `
<input
type="checkbox"
:id="inputId"
:name="name"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -209,6 +209,40 @@ describe('ErrorNodeCard.vue', () => {
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('displays catalog-resolved copy when available', async () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'Required input is missing',
details: 'model',
displayTitle: 'Missing connection',
displayMessage:
'Required input slots have no connection feeding them.',
displayDetails: 'KSampler is missing a required input: model',
displayItemLabel: 'KSampler - model'
}
]
})
await waitFor(() => {
expect(screen.getByText('Missing connection')).toBeInTheDocument()
})
expect(
screen.getByText('Required input slots have no connection feeding them.')
).toBeInTheDocument()
expect(
screen.getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
expect(
screen.queryByText('Required input is missing')
).not.toBeInTheDocument()
})
it('copies enriched report when copy button is clicked for runtime error', async () => {
const reportText = '# Full Report Content'
mockGenerateErrorReport.mockReturnValue(reportText)

View File

@@ -54,12 +54,20 @@
)
"
>
<!-- Human-friendly category/title when resolved by the error catalog. -->
<p
v-if="error.displayTitle"
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
>
{{ error.displayTitle }}
</p>
<!-- Error Message -->
<p
v-if="error.message"
v-if="getDisplayMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ error.message }}
{{ getDisplayMessage(error) }}
</p>
<!-- Traceback / Details (enriched with full report for runtime errors) -->
@@ -171,11 +179,15 @@ function handleEnterSubgraph() {
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = card.errors[idx]?.message
const message = getDisplayMessage(card.errors[idx])
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
}
function getDisplayMessage(error: ErrorItem | undefined) {
return error?.displayMessage ?? error?.message
}
</script>

View File

@@ -57,11 +57,6 @@ describe('TabErrors.vue', () => {
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
},
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
}
}
}
}
@@ -103,7 +98,7 @@ describe('TabErrors.vue', () => {
expect(screen.getByText('No errors')).toBeInTheDocument()
})
it('renders prompt-level errors (Group title = error message)', async () => {
it('renders prompt-level errors with resolved display message', async () => {
renderComponent({
executionError: {
lastPromptError: {
@@ -115,7 +110,11 @@ describe('TabErrors.vue', () => {
})
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getByText('Prompt has no outputs')).toBeInTheDocument()
expect(
screen.getByText(
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
)
).toBeInTheDocument()
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})

View File

@@ -21,7 +21,7 @@
<div
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
>
{{ singleRuntimeErrorGroup?.title }}
{{ singleRuntimeErrorGroup?.displayTitle }}
</div>
<ErrorNodeCard
:key="singleRuntimeErrorCard.id"
@@ -53,12 +53,12 @@
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@update:collapse="setSectionCollapsed(group.title, $event)"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #label>
<div class="flex min-w-0 flex-1 items-center gap-2">
@@ -67,13 +67,7 @@
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span class="truncate text-destructive-background-hover">
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
}}
{{ group.displayTitle }}
</span>
<span
v-if="group.type === 'execution' && group.cards.length > 1"
@@ -331,7 +325,7 @@ const {
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
@@ -366,22 +360,22 @@ const singleRuntimeErrorCard = computed(
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of tabErrorGroups.value) {
setSectionCollapsed(group.title, collapse)
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(title: string): boolean {
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[title] ?? false
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(title: string, collapsed: boolean) {
collapseState[title] = collapsed
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
@@ -403,7 +397,7 @@ watch(
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},

View File

@@ -27,6 +27,8 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/i18n', () => ({
te: vi.fn(() => false),
t: vi.fn((key: string) => key),
st: vi.fn((_key: string, fallback: string) => fallback)
}))
@@ -84,8 +86,7 @@ describe('swapNodeGroups computed', () => {
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
const { swapNodeGroups } = useErrorGroups(searchQuery)
return swapNodeGroups
}

View File

@@ -1,5 +1,9 @@
export interface ErrorItem {
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
message: string
/** Raw source/API-compatible details. */
details?: string
isRuntimeError?: boolean
exceptionType?: string
@@ -15,14 +19,28 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
interface ErrorGroupBase extends Omit<ResolvedErrorMessage, 'displayTitle'> {
/** Stable structural key used for rendering, collapse state, and cache identity. */
groupKey: string
/** Human-friendly title resolved for UI display. */
displayTitle: string
priority: number
}
export type ErrorGroup =
| {
| (ErrorGroupBase & {
type: 'execution'
title: string
cards: ErrorCardData[]
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }
| { type: 'missing_media'; title: string; priority: number }
})
| (ErrorGroupBase & {
type: 'missing_node'
})
| (ErrorGroupBase & {
type: 'swap_nodes'
})
| (ErrorGroupBase & {
type: 'missing_model'
})
| (ErrorGroupBase & {
type: 'missing_media'
})

View File

@@ -30,7 +30,15 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
te: vi.fn(() => false),
st: vi.fn((_key: string, fallback: string) => fallback),
t: vi.fn((key: string, params?: { count?: number }) => {
if (key === 'errorOverlay.missingModels') {
const count = params?.count ?? 0
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return key
})
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
@@ -113,8 +121,7 @@ function makeModel(
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const t = (key: string) => key
const groups = useErrorGroups(searchQuery, t)
const groups = useErrorGroups(searchQuery)
return { store, searchQuery, groups }
}
@@ -245,6 +252,11 @@ describe('useErrorGroups', () => {
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Some nodes are missing and need to be installed'
)
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
@@ -329,6 +341,54 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
})
it('resolves required_input_missing item display copy', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
const error = card.errors[0]
expect(error.message).toBe('Required input is missing')
expect(error.details).toBe('model')
expect(error.catalogId).toBe('missing_connection')
expect(error.displayTitle).toBe('Missing connection')
expect(error.displayMessage).toBe(
'Required input slots have no connection feeding them.'
)
expect(error.displayDetails).toBe(
'KSampler is missing a required input: model'
)
expect(error.displayItemLabel).toBe('KSampler - model')
expect(error.toastTitle).toBe('Required input missing')
expect(error.toastMessage).toBe(
'KSampler is missing a required input: model'
)
})
it('includes execution error from runtime errors', async () => {
@@ -351,6 +411,11 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
'KSampler failed'
)
})
it('includes prompt error when present', async () => {
@@ -363,7 +428,7 @@ describe('useErrorGroups', () => {
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.title === 'No outputs'
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
)
expect(promptGroup).toBeDefined()
})
@@ -546,7 +611,7 @@ describe('useErrorGroups', () => {
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group title as message', async () => {
it('includes missing node group display message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
@@ -558,7 +623,9 @@ describe('useErrorGroups', () => {
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
expect(groups.groupedErrorMessages.value).toContain(
missingGroup!.displayMessage
)
})
})
@@ -703,6 +770,8 @@ describe('useErrorGroups', () => {
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
expect(modelGroup?.groupKey).toBe('missing_model')
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
})
})

View File

@@ -33,6 +33,10 @@ import type {
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import {
resolveMissingErrorMessage,
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
isNodeExecutionId,
compareExecutionId
@@ -40,11 +44,6 @@ import {
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -66,6 +65,7 @@ export interface SwapNodeGroup {
interface GroupEntry {
type: 'execution'
displayTitle: string
priority: number
cards: Map<string, ErrorCardData>
}
@@ -104,13 +104,19 @@ function resolveNodeInfo(nodeId: string) {
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
groupKey: string,
displayTitle = groupKey,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
let entry = groupsMap.get(groupKey)
if (!entry) {
entry = { type: 'execution', priority, cards: new Map() }
groupsMap.set(title, entry)
entry = {
type: 'execution',
displayTitle,
priority,
cards: new Map()
}
groupsMap.set(groupKey, entry)
}
return entry.cards
}
@@ -160,7 +166,10 @@ function addCardErrorToGroup(
card: ErrorCardData,
error: ErrorItem
) {
const group = getOrCreateGroup(messageMap, error.message, 1)
const displayTitle =
error.displayTitle ?? error.displayMessage ?? error.message
const groupKey = error.catalogId ?? displayTitle
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
@@ -173,15 +182,16 @@ function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
.map(([rawGroupKey, groupData]) => ({
type: 'execution' as const,
title,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
return a.displayTitle.localeCompare(b.displayTitle)
})
}
@@ -199,8 +209,16 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e) =>
[e.displayTitle, e.displayMessage, e.message]
.filter(Boolean)
.join(' ')
)
.join(' '),
searchableDetails: card.errors
.map((e) => [e.displayDetails, e.details].filter(Boolean).join(' '))
.join(' ')
})
}
}
@@ -235,10 +253,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: MaybeRefOrGetter<string>,
t: (key: string) => string
) {
export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
@@ -323,11 +338,13 @@ export function useErrorGroups(
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
cards.get(nodeId)?.errors.push(...errors)
const card = cards.get(nodeId)
if (!card) return
card.errors.push(...errors)
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
@@ -335,24 +352,27 @@ export function useErrorGroups(
return
const error = executionErrorStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
// For server_error, resolve the i18n key based on the environment
let errorTypeKey = error.type
if (error.type === 'server_error') {
errorTypeKey = isCloud ? 'server_error_cloud' : 'server_error_local'
}
const i18nKey = `rightSidePanel.promptErrors.${errorTypeKey}.desc`
const resolvedDisplay = resolveRunErrorMessage({
kind: 'prompt',
error,
isCloud
})
const groupDisplayTitle = resolvedDisplay.displayTitle ?? error.message
const cards = getOrCreateGroup(
groupsMap,
`prompt:${error.type}`,
groupDisplayTitle,
0
)
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
cards.set(PROMPT_CARD_ID, {
id: PROMPT_CARD_ID,
title: groupTitle,
title: groupDisplayTitle,
errors: [
{
message: isKnown ? t(i18nKey) : error.message
message: error.message,
...resolvedDisplay
}
]
})
@@ -367,15 +387,24 @@ export function useErrorGroups(
for (const [nodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
})),
nodeError.errors.map((e) => {
return {
message: e.message,
details: e.details ?? undefined,
...resolveRunErrorMessage({
kind: 'node_validation',
error: e,
nodeDisplayName
})
}
}),
filterBySelection
)
}
@@ -388,6 +417,12 @@ export function useErrorGroups(
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const resolvedDisplay = resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName: e.node_type,
isCloud
})
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
@@ -398,7 +433,8 @@ export function useErrorGroups(
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type
exceptionType: e.exception_type,
...resolvedDisplay
}
],
filterBySelection
@@ -568,16 +604,28 @@ export function useErrorGroups(
if (swapNodeGroups.value.length > 0) {
groups.push({
type: 'swap_nodes' as const,
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
priority: 0
groupKey: 'swap_nodes',
priority: 0,
...resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
count: swapNodeGroups.value.length,
isCloud
})
})
}
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
title: error.message,
priority: 1
groupKey: 'missing_node',
priority: 1,
...resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: error.nodeTypes,
count: missingPackGroups.value.length,
isCloud
})
})
}
@@ -630,11 +678,21 @@ export function useErrorGroups(
function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
const count = missingModelGroups.value.reduce(
(total, group) => total + group.models.length,
0
)
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
groupKey: 'missing_model',
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: missingModelGroups.value,
count,
isCloud
})
}
]
}
@@ -654,8 +712,15 @@ export function useErrorGroups(
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
groupKey: 'missing_media',
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
isCloud
})
}
]
}
@@ -736,11 +801,21 @@ export function useErrorGroups(
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
const count = filteredMissingModelGroups.value.reduce(
(total, group) => total + group.models.length,
0
)
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${filteredMissingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
groupKey: 'missing_model',
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: filteredMissingModelGroups.value,
count,
isCloud
})
}
]
}
@@ -754,8 +829,17 @@ export function useErrorGroups(
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
groupKey: 'missing_media',
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
mediaTypes: filteredMissingMediaGroups.value.map(
(group) => group.mediaType
),
isCloud
})
}
]
}
@@ -813,11 +897,11 @@ export function useErrorGroups(
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
messages.add(err.displayMessage ?? err.message)
}
}
} else {
messages.add(group.title)
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)

View File

@@ -23,7 +23,7 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
return Object.fromEntries(
card.errors.map((error, idx) => [
idx,
enrichedDetails[idx] ?? error.details
enrichedDetails[idx] ?? error.displayDetails ?? error.details
])
)
})

View File

@@ -3,10 +3,20 @@ import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
const hoisted = vi.hoisted(() => ({
mockSearchNode: vi.fn<(query: string) => unknown[]>(() => [])
}))
vi.mock('@/services/nodeSearchService', () => ({
NodeSearchService: class {
searchNode = hoisted.mockSearchNode
}
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
@@ -72,8 +82,10 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',
template:
'<input data-testid="search-box" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'search'],
setup() {
return { focus: vi.fn() }
},
@@ -84,12 +96,22 @@ vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
messages: {
en: {
sideToolbar: {
nodeLibraryTab: {
noMatchingNodes: 'No nodes match "{query}"'
}
}
}
}
})
describe('NodeLibrarySidebarTabV2', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.mockSearchNode.mockReset()
hoisted.mockSearchNode.mockReturnValue([])
})
function renderComponent() {
@@ -123,4 +145,49 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
})
describe('search empty state', () => {
it('does not render the empty state when search query is empty', () => {
renderComponent()
expect(screen.queryByText(/No nodes match/)).not.toBeInTheDocument()
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
})
it('renders the empty state with the query when search has no matches', async () => {
hoisted.mockSearchNode.mockReturnValue([])
renderComponent()
await fireEvent.update(screen.getByTestId('search-box'), 'gibberish')
expect(screen.getByText('No nodes match "gibberish"')).toBeInTheDocument()
expect(screen.queryByTestId('essential-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
})
it('hides the empty state when the search has matches', async () => {
hoisted.mockSearchNode.mockReturnValue([{ name: 'KSampler' }])
renderComponent()
await fireEvent.update(screen.getByTestId('search-box'), 'ksampler')
expect(screen.queryByText(/No nodes match/)).not.toBeInTheDocument()
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
})
it('hides the empty state once the query is cleared', async () => {
hoisted.mockSearchNode.mockReturnValue([])
renderComponent()
const input = screen.getByTestId('search-box')
await fireEvent.update(input, 'gibberish')
expect(screen.getByText('No nodes match "gibberish"')).toBeInTheDocument()
await fireEvent.update(input, '')
expect(screen.queryByText(/No nodes match/)).not.toBeInTheDocument()
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
})
})
})

View File

@@ -117,7 +117,17 @@
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<div
v-if="hasNoMatches"
class="flex min-h-0 flex-1 items-center justify-center px-6 py-8 text-center text-sm text-muted-foreground"
>
{{
$t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
query: searchQuery
})
}}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
@@ -274,9 +284,13 @@ const filteredNodeDefs = computed(() => {
})
const activeNodes = computed(() =>
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
searchQuery.value.length === 0
? nodeDefStore.visibleNodeDefs
: filteredNodeDefs.value
)
const hasNoMatches = computed(
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
)
const sections = computed(() => {

View File

@@ -258,6 +258,34 @@ describe('useSelectedLiteGraphItems', () => {
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
})
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': node1, '1': node2 }
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
})
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
app.canvas.selected_nodes = { '0': bypassed, '1': active }
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('areAllSelectedNodesInMode returns false for empty selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
app.canvas.selected_nodes = {}
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('getSelectedNodes should include nodes from subgraphs', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode

View File

@@ -93,6 +93,22 @@ export function useSelectedLiteGraphItems() {
return collectFromNodes(nodeArray)
}
const getSelectedNodesShallow = (): LGraphNode[] =>
Object.values(app.canvas.selected_nodes ?? {})
/**
* True iff every selected node is in `mode`. Mirrors the predicate used by
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
* An empty selection returns `false` (no node is in the mode).
*/
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
const selectedNodeArray = getSelectedNodesShallow()
return (
selectedNodeArray.length > 0 &&
selectedNodeArray.every((node) => node.mode === mode)
)
}
/**
* Toggle the execution mode of all selected nodes
*
@@ -102,18 +118,10 @@ export function useSelectedLiteGraphItems() {
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
*/
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes) return
// Convert selected_nodes object to array
const selectedNodeArray: LGraphNode[] = []
for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i])
}
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
const selectedNodeArray = getSelectedNodesShallow()
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
? LGraphEventMode.ALWAYS
: mode
for (const selectedNode of selectedNodeArray)
selectedNode.mode = newModeForSelectedNode
@@ -126,6 +134,7 @@ export function useSelectedLiteGraphItems() {
hasSelectableItems,
hasMultipleSelectableItems,
getSelectedNodes,
areAllSelectedNodesInMode,
toggleSelectedNodesMode
}
}

View File

@@ -135,6 +135,51 @@ describe('contextMenuConverter', () => {
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
const legacyOptions = convertContextMenuToOptions(
[{ content: 'Bypass', callback: () => {} }],
undefined,
false
)
expect(
legacyOptions.find(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
).toBeUndefined()
const vueBypass: MenuOption = {
label: 'Remove Bypass',
icon: 'icon-[lucide--redo-dot]',
shortcut: 'Ctrl+B',
action: () => {},
source: 'vue'
}
const result = buildStructuredMenu([...legacyOptions, vueBypass])
const bypassItems = result.filter(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
expect(bypassItems).toHaveLength(1)
expect(bypassItems[0].source).toBe('vue')
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
})
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
const options: MenuOption[] = [
{ label: 'Bypass', action: () => {}, source: 'vue' },
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const labels = result
.map((opt) => opt.label)
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
expect(labels).toEqual(
expect.arrayContaining(['Bypass', 'Remove Bypass'])
)
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },

View File

@@ -21,7 +21,10 @@ const HARD_BLACKLIST = new Set([
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
'Copy (Clipspace)',
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
'Bypass',
'Remove Bypass'
])
/**

View File

@@ -5,6 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -285,14 +290,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
)
expect(promotedWidget).toBeDefined()
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedRequiredInputMissingNodeError(
store,
interiorExecId,
promotedWidget!.name
)
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
subgraphNode.onWidgetChanged!.call(
subgraphNode,
@@ -304,6 +302,227 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {

View File

@@ -7,6 +7,7 @@
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -45,22 +46,128 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
node: LGraphNode,
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
widget: IBaseWidget,
hostExecId: string
): string {
if (!isPromotedWidgetView(widget)) return hostExecId
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
const result = resolveConcretePromotedWidget(
node,
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved' && result.resolved.node) {
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
}
return hostExecId
}
const hookedNodes = new WeakSet<LGraphNode>()
@@ -103,23 +210,14 @@ function installNodeHooks(node: LGraphNode): void {
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const execId = resolvePromotedExecId(
const targets = resolveWidgetErrorTargets(
app.rootGraph,
node,
widget,
hostExecId
)
const widgetName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
execId,
widget.name,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
hostExecId,
newValue
)
clearWidgetErrorTargets(targets, newValue)
}
)
}

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