Compare commits

...

68 Commits

Author SHA1 Message Date
jaeone94
ceb4eb410f Preserve prompt error detail visibility 2026-05-22 00:34:16 +09:00
jaeone94
8aa2624dc6 Fix mobile error catalog log copy 2026-05-22 00:21:16 +09:00
jaeone94
ed15fc1d81 Add error catalog display resolver 2026-05-21 20:38:29 +09:00
jaeone94
f1f65cff61 feat: select top asset widget FormDropdown result on Enter (#12209)
## Summary

Allow asset/media FormDropdown searches to select the top filtered
result when the user presses Enter. This covers image, video, audio,
mesh, model-like asset selects, and other `WidgetSelectDropdown`-backed
media widgets.

## Implementation Scope

This PR implements a **top-result Enter shortcut** for the custom
asset/media dropdown path only:

- In scope: `WidgetSelectDropdown` -> `FormDropdown` asset/media
widgets.
- In scope: while the dropdown is open, single-select, and the search
text is non-empty, the first current search result becomes the Enter
candidate.
- In scope: pressing Enter in the search input selects that
candidate/top result through the existing selection path.
- In scope: candidate feedback for this shortcut, including visual
candidate styling and a polite screen-reader announcement for the
current top result.
- In scope: stale async search protection, empty-query/no-result no-op
behavior, multi-select guard behavior, and focus return to the trigger
after Enter selection closes the menu.
- Out of scope: plain combo widgets (`WidgetSelectDefault` /
`SelectPlus`). That path is PrimeVue-based and should be handled
separately from this focused asset-widget PR.
- Out of scope: full combobox/listbox keyboard navigation, including
Tab-to-list focus, ArrowUp/ArrowDown candidate movement, Home/End
behavior, scroll-to-active-item behavior, and a full ARIA
combobox/listbox refactor.

Follow-up arrow-key navigation should validate the interaction model
separately. This PR keeps the candidate state narrow and localized so
that future work can either extend it into movable active-item state or
replace it as part of a fuller combobox/listbox implementation.

## Changes

- **What**: Added an explicit Enter event from `FormSearchInput`, routed
it through the FormDropdown menu actions, and selected the current top
search result in `FormDropdown`.
- **What**: Kept the existing `computedAsync` + debounced filtering path
for normal typing, while Enter performs a one-off search against the
latest input before selecting. Stale async Enter results are ignored if
the query or item source changes before resolution.
- **What**: Prevented closed FormDropdown state from treating the full
unfiltered list as current search results, limited Enter-to-select to
single-select dropdowns, and made empty search Enter a no-op.
- **What**: Returned focus to the dropdown trigger after single-select
selection closes the menu.
- **What**: Added candidate styling for the first current FormDropdown
result while a search query is active so the Enter target is visible to
users.
- **What**: Added a polite screen-reader announcement for the current
top result candidate.
- **What**: Fixed the FormDropdownMenuActions `baseModelSelected` model
default to use a `Set` factory instead of a shared instance.
- **What**: Added unit coverage for the search Enter event, FormDropdown
selection behavior, focus return, debounce/Enter behavior, stale async
Enter protection, empty-query no-op behavior, closed-state stale result
protection, multi-select guard behavior, and candidate announcement
behavior. Added App Mode E2E coverage for asset FormDropdown Enter
selection.
- **What**: Extracted reusable app-mode dropdown fixture helpers and
updated the existing FormDropdown clipping test to use the shared
helper.

## Review Focus

Please focus review on the asset/media FormDropdown path, especially
`getTopSearchResult()`, the single-select/empty-query guards, stale
async search protection, trigger focus return after selection, and
candidate feedback in grid/list layouts.

The plain combo path and full arrow-key navigation are intentionally
left for separate follow-up work.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/3eb3456d-93a3-4959-91a3-188f8116ccc9



Validation performed:

- Latest final-commit validation:
- `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts`
- Commit hook: `pnpm exec stylelint ...`, `pnpm exec oxfmt --write ...`,
`pnpm exec oxlint --type-aware --fix ...`, `pnpm exec eslint --cache
--fix ...`, `pnpm typecheck`
  - Push hook: `pnpm knip --cache`
  - `git diff --check`
- Earlier branch validation for this flow:
  - `pnpm install`
  - `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "Drag and
Drop|FormDropdown search Enter selects the top filtered item"
--reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "FormDropdown
search Enter selects the top filtered item" --reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appModeDropdownClipping.spec.ts
-g "FormDropdown popup is not clipped" --reporter=list`
2026-05-21 02:26:26 +00:00
Alexander Brown
b0144db644 build: migrate pnpm config to v11 (#12195)
## Summary

Migrate pnpm configuration to the v11 layout and clean up stale v10-era
references.

## Changes

- **What**: Moves pnpm settings into `pnpm-workspace.yaml`, converts
build dependency policy to `allowBuilds`, removes stale workspace
`packageManager` pins, and updates global install commands in CI.
- **Dependencies**: No new dependencies.

## Review Focus

- Confirm pnpm v11 workspace settings match the former `.npmrc`
behavior.
- Confirm CI global install syntax is compatible with pnpm v11.

## Test Plan

- `pnpm install --frozen-lockfile`
- `pnpm exec oxfmt --check pnpm-workspace.yaml
packages/shared-frontend-utils/package.json
packages/registry-types/package.json packages/ingest-types/package.json
packages/design-system/package.json
.github/workflows/weekly-docs-check.yaml
.github/workflows/pr-claude-review.yaml`
- commit hook: `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12195-build-migrate-pnpm-config-to-v11-35e6d73d36508116a821dbc71db94cd1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-21 01:53:09 +00:00
Comfy Org PR Bot
8ee8dd03c4 1.45.12 (#12393)
Patch version increment to 1.45.12

**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-21 00:47:07 +00:00
Benjamin Lu
d472ca783b test: cover FE-130 assets sidebar route mocks (#12332)
## Summary

Adds a focused FE-130 assets sidebar browser-test slice without
extending the stateful asset helper path.

## Changes

- **What**: Extends `jobsRouteFixture` with job-detail and
history-delete helpers for generated asset flows.
- **What**: Adds an assets sidebar tab spec covering generated/imported
rendering, preview opening, generated selection footer actions, and
explicit delete refresh behavior.

## Review Focus

The delete test keeps backend state explicit: it captures the
`/api/history` request, then replaces the `/api/jobs` history mock with
the post-delete response. The imported-file and `/api/view` mocks stay
local to this focused spec instead of growing `AssetHelper` or adding a
sidebar-specific fixture.
2026-05-20 17:01:51 -07:00
Deep Mehta
e80ec6e3d4 feat: add model-to-node mappings for geometry_estimation and optical_flow (#12389)
## Summary

Add entries to `MODEL_NODE_MAPPINGS` so the model browser's "Use" button
creates the correct loader node for two model directories introduced in
recent node-pack updates.

## Changes

- **What**: 2 new entries in
`src/platform/assets/mappings/modelNodeMappings.ts`:
  - `geometry_estimation` → `LoadMoGeModel` / `model_name`
  - `optical_flow` → `OpticalFlowLoader` / `model_name`
- **Breaking**: none

## Review Focus

- Node class names and input keys cross-checked against the published
node definitions:
- `LoadMoGeModel` is the MoGe geometry-estimation loader (companion
nodes: `MoGeInference`, `MoGeRender`, `MoGeContextStrandModel`)
  - `OpticalFlowLoader` is the RAFT optical-flow loader
- Both directories accept a single model file via a COMBO widget on the
loader node

## Test plan

- [ ] Verify "Use" button works for each new model directory in the
model browser:
- One `geometry_estimation` model (e.g.
`moge_2_vitl_normal_fp16.safetensors`) → creates a `LoadMoGeModel` node
with the file preselected
- One `optical_flow` model → creates an `OpticalFlowLoader` node with
the file preselected

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12389-feat-add-model-to-node-mappings-for-geometry_estimation-and-optical_flow-3666d73d36508190981fcaf77f9d2ee4)
by [Unito](https://www.unito.io)
2026-05-20 23:26:40 +00:00
AustinMroz
2717d59451 Fix reactivity of vue subgraph price badges (#12029)
When a subgraph contains partner nodes with price badges, those badges
are also displayed on the subgraphNode. The reactivity here was spotty:
The price badges would fail to display unless the user had navigated
into the subgraph on the current page load. Fixing this is performed in
2 steps:
- Firing a `node:property:changed` event when the badges contained in a
subgraph are updated
- Extending the reactivity updates so that badges update in vue mode
despite using the litegraph badge getter.

This PR also includes a minor styling tweak to fix text alignment on
price badges
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/56a95cbe-12c9-43b0-8664-34e52b6415ac"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/bf4a0d81-21e4-4afc-946e-eba5967f1715"
/>|

Resolves FE-346

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12029-Fix-reactivity-of-vue-subgraph-price-badges-3586d73d3650813cb12fe265090940e4)
by [Unito](https://www.unito.io)
2026-05-20 11:22:42 -07:00
AustinMroz
d63b0f05bf Subgraph io fixes (#12281)
Fixes 3 different bugs when making links to and from subgraph IO from
vue nodes
- When dragging a link from a node to a subgraph IO, there is no
feedback if a slot is not a valid connection target or if a slot is
actively hovered
- When a link is made from a subgraph IO to a node, the reactivity is
not triggered on the node to indicate a change of link state.
- When dragging a link from a subgraph IO to a node, the link would not
snap to the valid connection targets on nodes
- The fix for this one is not as thorough as I would like. It only
allows connections to the slot, not connections to the hovered widget.
We have two deeply disconnected linking systems and properly reconciling
them would be a multi-week project.

Resolves FE-561

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12281-Subgraph-io-fixes-3606d73d365081089f7ef19331c6d70a)
by [Unito](https://www.unito.io)
2026-05-20 10:26:47 -07:00
Terry Jia
cd2f4677c2 FE-719 feat(load3d): add FBX export support (#12323)
## Summary
implement fbx export, using our own lib fbx-exporter-three

## Screenshots (if applicable)


https://github.com/user-attachments/assets/80012338-d065-4a00-a9a0-0a2e73d67db4

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12323-FE-719-feat-load3d-add-FBX-export-support-3656d73d365081ef901ffe880ae9568a)
by [Unito](https://www.unito.io)
2026-05-20 06:52:56 -04:00
Hunter
38fed22140 feat: env-var override for staging api/platform base URLs (#12221)
## Summary

Allow staging api/platform base URLs to be overridden by env vars so
non-cloud builds can target an alternate backend without source edits.

## Changes

- **What**: `BUILD_TIME_API_BASE_URL` / `BUILD_TIME_PLATFORM_BASE_URL`
in `src/config/comfyApi.ts` now read
`import.meta.env.VITE_STAGING_API_BASE_URL` /
`VITE_STAGING_PLATFORM_BASE_URL` first, falling back to the existing
`stagingapi.comfy.org` / `stagingplatform.comfy.org` constants. Vars
typed in `src/vite-env.d.ts` and documented in `.env_example`.
- **Breaking**: None. Defaults unchanged. The cloud-runtime override
path via the features endpoint (`comfy_api_base_url`,
`comfy_platform_base_url` in `RemoteConfig`) is untouched.

## Review Focus

Override only applies to the non-prod branch of the build-time ternary,
so prod builds (`USE_PROD_CONFIG=true`) cannot be redirected. Cloud
builds continue to resolve URLs at runtime via `remoteConfig` regardless
of these env vars.

## Note

Pre-commit `pnpm typecheck` fails on `origin/main` independently of this
change (`src/utils/nodeDefUtil.ts` and
`src/workbench/utils/nodeHelpUtil.ts` import non-existent exports from
`@/schemas/nodeDefSchema` / `@/types/nodeSource`). Verified by stashing
this PR's diff and re-running. Committed with `--no-verify`; please
address the underlying breakage separately.
2026-05-20 05:37:27 +00:00
AustinMroz
a95e53bf6d On subgraph conversion, always unpack group nodes (#12356)
This is a targeted small scope change to improve the availability for
converting group nodes into a subgraph.

The prior implementation would only apply on the litegraph context menu
option for converting a node to a subgraph. It failed to apply on any of
the other more common methods. The code for unpacking group nodes has
been moved directly into the setup for converting a group of nodes into
a subgraph and drastically simplified.

Of note, several other long lived bugs were found while working on this
fix, but they are out of scope for this targeted PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12356-On-subgraph-conversion-always-unpack-group-nodes-3666d73d365081d09774c00a851b8198)
by [Unito](https://www.unito.io)
2026-05-20 05:23:46 +00:00
AustinMroz
246b79dda9 Fix group selection selecting nodes (#12099)
Fix group selection incorrectly selecting nodes of equal id in vue mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12099-Fix-group-selection-selecting-nodes-35b6d73d365081e2bc73f16deb996f61)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-20 04:22:03 +00:00
Comfy Org PR Bot
7325c715c7 1.45.11 (#12349)
Patch version increment to 1.45.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12349-1-45-11-3666d73d3650812b899cd2c6177e9888)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-20 03:02:03 +00:00
jaeone94
98a8a614e8 fix: avoid false missing media errors after importing shared workflow assets (#12333)
## Summary

Import published media assets for shared workflows before loading the
graph so the first missing-media scan sees the user's newly imported
references instead of surfacing a false missing asset error. cc FE-773

## Changes

- **What**: Moves the shared workflow import step ahead of
`loadGraphData` for the copy-and-open flow, while still allowing the
workflow to open with a warning path if asset import fails.
- **What**: Clears the shared workflow URL intent consistently on
failure paths, including graph load failure after an import attempt, so
reloads do not repeatedly replay the same shared workflow side effects.
- **What**: Invalidates the input asset cache after published asset
import so graph loading and missing-media resolution can observe the
refreshed media state.
- **What**: Adds a global loading spinner while shared workflow asset
import and graph load are in progress, with `role="status"`,
`aria-live`, reduced-motion-safe animation, and body teleporting so it
stays visible above blocking UI.
- **What**: Adds stable TestIds for the shared workflow dialog and
updates existing shared workflow E2E selectors away from copy-dependent
role text.
- **What**: Adds a cloud E2E regression fixture and spec covering the
critical flow: shared URL opens the dialog, the user confirms asset
import, published media is imported before the public-inclusive input
asset scan, the workflow loads, the share query is removed, and missing
media UI is not surfaced.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

Shared workflow graph loading triggered the missing-media pipeline
before the user-selected published media import had completed. Because
`include_public=true` does not include published assets, the pre-import
scan could classify shared media as missing even when the user was about
to import those assets into their own library.

## Review Focus

- The ordering in `useSharedWorkflowUrlLoader`: import published assets
first, then load the graph, while keeping import failure non-fatal for
workflow opening.
- The failure cleanup behavior: the shared URL/preserved query intent is
now cleared for graph load failures too, avoiding repeated
reload-triggered imports.
- The spinner behavior in `App.vue`: it uses the existing
`workspaceStore.spinner` boolean and intentionally keeps broader
ref-counted spinner ownership as follow-up work.
- The E2E sentinel in `sharedWorkflowMissingMedia.spec.ts`: it asserts
no public-inclusive input asset scan occurs before `/api/assets/import`,
then waits for a settling window to ensure the missing-media overlay
does not appear.

## Validation

- `pnpm format`
- `pnpm lint` (passed with existing unrelated warnings only)
- `pnpm typecheck`
- `pnpm test:unit`
- Commit hook: lint-staged formatting/linting, `pnpm typecheck`, `pnpm
typecheck:browser`
- Push hook: `pnpm knip --cache` (passed with existing tag hint only)

## Follow-Up

- Consider a ref-counted or scoped global spinner API so long-running
flows do not directly toggle `workspaceStore.spinner`.
- Consider separating shared workflow load status into orthogonal result
fields instead of encoding partial success in a single string union.
- Consider moving published asset import/cache invalidation behind an
asset-service-owned API boundary.
- Backend follow-up remains needed for `include_public=true` not
including published assets; this PR only removes the frontend false
positive when the user explicitly imports the shared media.

## Screenshots

Before 


https://github.com/user-attachments/assets/dc790046-237c-4dd8-b773-2507f9a66650

After 


https://github.com/user-attachments/assets/6517cd38-2c3d-4bfe-a990-35892b7e50ae



https://github.com/user-attachments/assets/d89dc3d3-75d9-4251-998b-0c354414e25b




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12333-fix-avoid-false-missing-media-errors-after-importing-shared-workflow-assets-3656d73d365081b38634dcb7625cfc32)
by [Unito](https://www.unito.io)
2026-05-20 02:59:44 +00:00
Dante
95b5207c06 fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006)
## Summary

Two fixes for the cloud LoadImage form dropdown:

1. **Cloud root-cause fix** — outputs now come from a single
`getAssetsByTag('output')` call instead of walking the jobs API and
per-job `resolveOutputAssetItems` detail fetches. Per Christian's [Slack
feedback](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778051260476369?thread_ts=1776716352.588229&cid=C0A4XMHANP3):
*"on cloud, we can just grab the assets with a single GET, filtering
with input or output tag."* Sidebar's job-stack UX is untouched.
2. **Local / defense-in-depth** — even when the watch+expansion path is
in play (still used by local), batch all in-flight
`resolveOutputAssetItems` for the current `media` snapshot via
`Promise.all`, committing once into `resolvedByJobId`. This kills the
progressive head-shift symptom even on the legacy path.

The first attempt at (1) (`6a1a083c9`, reverted in `c175962e8`) broke
select+load on cloud prod because the dropdown wrote `asset.name` (human
filename) into the widget value, but cloud's `/api/view` resolves output
files by **`asset_hash`** (the blake3-keyed filename). Verified against
cloud prod that every output row carries `asset_hash` and that cloud's
own `preview_url` is hash-keyed, not name-keyed. Re-introduced in
`d7693377` with the dropdown value derived from `asset.asset_hash ||
asset.name`, with the human filename retained as the display label.

- Fixes FE-227

## Cloud / local divergence — what this PR clarifies

| | input | output (this PR) |
| ------------ | ---------------------------------------------- |
-------------------------------------------------------- |
| **cloud** | `getAssetsByTag('input')` (already correct) |
**`getAssetsByTag('output')` (new)** |
| **local** | `/files/input` (FS-listing) | `getHistory` + per-job
expansion (unchanged) |

Both directions are now symmetric on cloud: tag-based listing,
hash-keyed values. Local stays on the legacy path because core ComfyUI
doesn't have the assets/tags model — that's the deeper convergence
Jacob/Luke flagged in the FE-556 thread (now BE-757), which is BE/Core
work and not this PR.

## Red-Green verification

| Commit | CI: Tests Unit | Purpose |
|--------|----------------|---------|
| `3e8d42e7` test | 🔴 [failure
(25413987208)](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25413987208)
| Asserts the head of the list does not shift while one of two
multi-output jobs is still resolving. |
| `fe2608d4` fix (atomic batch) | 🟢 [success
(25414246791)](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25414246791)
| Resolutions awaited via `Promise.all` and merged in one
`resolvedByJobId` update. |
| `6a1a083c` simplification (broken) | — | First attempt — used
`asset.name`, broke select+load on cloud prod. |
| `c175962e` revert | — | Rolled back the broken simplification while
diagnosis was in flight. |
| `d7693377` simplification (fixed) | pending | Re-introduces
`useFlatOutputAssets` and uses `asset.asset_hash` for the dropdown
value. Adds 7 unit tests covering hash-as-value, name-fallback,
pagination, dedupe, and error path. |



## screenshot/ video 

### before


https://github.com/user-attachments/assets/239aa447-a260-4713-926c-04dd80a30408



### after 


https://github.com/user-attachments/assets/d68228c6-33f5-4bf0-ad24-bb83c876fdc2



## Test plan

- [x] New `useFlatOutputAssets.test.ts` — 7 tests for tag-based
fetching, pagination, dedupe, error path.
- [x] `useWidgetSelectItems.test.ts` — atomic-batching regression test +
new tests asserting hash-as-value and local name-fallback. 35 tests
pass.
- [x] `WidgetSelectDropdown.test.ts` — 5 tests pass with the new
conditional source.
- [x] CI red on test-only commit, CI green on first fix commit.
- [ ] CI green on the simplification (re-introduce) commit.
- [ ] Manual verification on cloud build: open LoadImage → switch to
Outputs → scroll → list head stays stable; select an output → LoadImage
preview loads (was broken in `6a1a083c`, restored in `d7693377`).
2026-05-20 01:33:47 +00:00
Deep Mehta
2ab1abb898 Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12344)
Reverts Comfy-Org/ComfyUI_frontend#12301

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12344-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3656d73d365081119ebad749a2e0d403)
by [Unito](https://www.unito.io)
2026-05-20 07:58:12 +09:00
Benjamin Lu
64c75bfce5 test: avoid job history double setup (#12324)
## Summary

Avoid a second `comfyPage.setup()` in the job history browser tests by
registering the initial jobs route mocks before the normal page boot.

## Changes

- **What**: Adds an `initialJobsScenario` Playwright option with an auto
fixture for the job-history spec, moves QPOV2 setup into
`initialSettings`, and keeps the sidebar helper focused on UI
navigation.
- **Dependencies**: None

## Review Focus

Confirm the auto fixture ordering matches the intended browser-test
setup: initial `/api/jobs` mocks should be installed before the
`comfyPage` fixture performs its normal setup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12324-test-avoid-job-history-double-setup-3656d73d365081778e24c11d3b65cbef)
by [Unito](https://www.unito.io)
2026-05-19 20:09:01 +00:00
Benjamin Lu
cfd3f9e67b fix: classify PLY assets as 3D media (#12319)
## Summary

Classify `.ply` as 3D media so PLY outputs are surfaced by queue/assets
preview flows.

## Changes

- **What**: adds `.ply` to shared 3D extension detection and falls back
to the asset `/view` URL when opening 3D assets without `preview_url`.
- **Breaking**: none.
- **Dependencies**: none.

## Review Focus

- This is the tactical FE fix for FE-129; it intentionally does not
solve the broader 3D media vs load3d-loadable split.
- Assets sidebar 3D viewer still prefers `preview_url`, but now has a
usable fallback for assets that only have the normal asset URL.

FE-129

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12319-fix-classify-PLY-assets-as-3D-media-3646d73d365081218f0bde401b1601bd)
by [Unito](https://www.unito.io)
2026-05-19 08:09:02 -07:00
pythongosssss
8af8a5f0b1 test: add tests for workflow extraction util (#12218)
## Summary

Adds test coverage for workflow extraction utils

## Changes

- **What**: 
- tests!

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12218-test-add-tests-for-workflow-extraction-util-35f6d73d36508189b15accc9f724d348)
by [Unito](https://www.unito.io)
2026-05-19 12:28:17 +00:00
Dante
3b37488eee fix: keep node context menu overflow visible when content fits (#12035)
## Summary

Stops the Shape submenu (and any other PrimeVue nested submenu) from
being clipped behind the node context menu when the menu fits in the
viewport.

## Changes

- **What**: `constrainMenuHeight` in `NodeContextMenu.vue` now applies
`max-height` + `overflow-y: auto` to the root `<ul>` only when
`scrollHeight > availableHeight`. The common case keeps `overflow:
visible`.
- Added `browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts`
regression spec.

## Review Focus

Root cause: setting only `overflow-y: auto` on a `<ul>` coerces
`overflow-x` to a non-visible value per CSS spec (`If one of
overflow-x/overflow-y is visible and the other isn't, the visible value
is computed as auto`). PrimeVue `ContextMenuSub` renders submenus
in-tree as a nested `<ul>` with `position: absolute; left: 100%`, so the
implicit horizontal clip hides them entirely.

The pre-existing overflow scenario (#10824 / #10854) is unchanged — when
the menu actually overflows, the clamp still applies and
`nodeContextMenuOverflow.spec.ts` continues to verify scroll. Submenu
clipping in that overflow case is a known limitation, not introduced by
this PR.

Fixes FE-570


## screenshot

### AS IS
<img width="788" height="505" alt="Screenshot 2026-05-07 at 12 43 26 PM"
src="https://github.com/user-attachments/assets/36d34070-0c57-4385-a130-0394f22f282e"
/>


### TO BE

<img width="779" height="627" alt="Screenshot 2026-05-07 at 12 42 44 PM"
src="https://github.com/user-attachments/assets/00956729-763b-4787-822f-209e8ea42331"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12035-fix-keep-node-context-menu-overflow-visible-when-content-fits-3586d73d365081ad9aaec82f220d401c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-19 10:56:41 +00:00
Comfy Org PR Bot
42dcb1cf7b 1.45.10 (#12321)
Patch version increment to 1.45.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12321-1-45-10-3656d73d365081c684e2c76ce92e2ff8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-19 08:29:48 +00:00
Christian Byrne
4931b0c4b2 fix(website): add dark-background favicon for legibility in search results (#12285)
*PR Created by the Glary-Bot Agent*

---

## Summary

The comfy.org favicon was reported as illegible in Google search
results. The current `logomark.svg` is a transparent yellow "C" — when
Google (or any client) composites it onto a white surface (search
results, light-theme tab strips), the yellow disappears into the
background.

Fix: ship a dedicated `/favicon.svg` that wraps the existing yellow
logomark in a solid black square, and point `<link rel="icon">` at it.
The in-page nav logo, `Organization.logo` Schema.org URL, and any other
consumer of `logomark.svg` are left untouched, so transparent-composite
contexts (knowledge panels, dark nav) continue to render cleanly.

## Changes

- `apps/website/public/favicon.svg` *(new)* — 48×48 SVG: black square +
scaled-down original logomark path. Existing path geometry is reused
verbatim inside a `<g transform>` so the C glyph is byte-identical to
the source.
- `apps/website/src/layouts/BaseLayout.astro` — `<link rel="icon"
href="/icons/logomark.svg">` → `<link rel="icon" href="/favicon.svg">`.
One-line change.

## Why a new file (vs editing `logomark.svg` in place)

`logomark.svg` is also used by `SiteNav.vue` (in-page header on the dark
`--color-primary-comfy-ink` background) and by the JSON-LD
`Organization.logo` URL. Both consumers want the transparent version.
Editing it in place would draw an ugly black square in the site's own
header.

## User report

> "just google searched comfyui and logo isnt legible. We should
update.."

## Verification

**Built site**
- `pnpm typecheck` (astro check): 0 errors, 0 warnings
- `pnpm build` (astro build): 280 pages built, exit 0
- Built `dist/index.html` contains exactly one `<link rel="icon"
href="/favicon.svg">` and zero references to the old icon path in
`<head>`
- `oxlint` on changed `.astro` file: 0 warnings, 0 errors

**Visual (Playwright on local astro dev server)**
- New favicon renders correctly at 16/32/64 px — yellow C centered on
black square, no clipping.
- In-page nav logo unchanged (yellow C floats cleanly on the dark
`--color-primary-comfy-ink` nav background, no black wrapper visible).
- Mock of Google search-result row shows the new favicon is
high-contrast inside Google's white circular wrapper; the old one is
nearly invisible.

## Screenshots

### Google-style search result simulation (before / after)
![Google search result simulation — before and
after](google-result-before-after)

### Favicon at native sizes + Google circular wrapper
![Favicon comparison at 16/32/64 px and inside a Google-style white
circle wrapper](favicon-comparison-preview)

### In-page nav header (unchanged after the fix)
![Site nav header showing the yellow Comfy logomark still sits cleanly
on the dark nav background](homepage-header-after)

## Notes for reviewers

- The change deliberately uses pure black `#000` (matching the user's
literal request "make the white background, black") rather than
`--color-primary-comfy-ink` (`#211927`). Either would work; happy to
switch if brand preference is the ink color.
- Search-engine cached favicons can take days/weeks to refresh on
Google's side after the new file is deployed.

## Screenshots


![google-result-before-after](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827200860-80e3877f-e1af-4cf3-962e-a1bf25ea9815.png)


![favicon-comparison-preview](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827201188-582fe0fb-aa7c-4fa1-af5b-fbc2a72387b7.png)


![homepage-header-after](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827201630-3a88502e-dd59-4e52-82a6-55f6b9768e4d.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12285-fix-website-add-dark-background-favicon-for-legibility-in-search-results-3616d73d365081babbcbedf0b86d3d67)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-18 19:24:16 -07:00
jaeone94
fc7e6a0935 fix(terminal): resync logs console on backend reconnect (#12270)
## Summary

When the built-in logs terminal stayed open during a backend restart,
the buffer froze on pre-restart entries and live log streaming silently
stopped — only closing and reopening the panel resynced. Listen for the
api `reconnected` event and rebuild the terminal contents the same way a
fresh open would.

## Changes

- **What**:
- Extract `useLogsTerminal` composable. The SFC is now a thin shell
holding `terminal: shallowRef<Terminal>` and forwarding to the
composable, so `onMounted`/`onScopeDispose` no longer rely on the
child's emit callback timing.
- Subscribe to `api`'s `reconnected` event via `useEventListener`,
registered synchronously before any awaits. On reconnect:
`terminal.reset()` → refetch raw logs → `scrollToBottom()` →
`subscribeLogs(true)` (the backend loses the per-client subscription on
restart, so re-subscribe is required for live streaming to resume).
- Wrap in-flight resync/mount fetches in AbortControllers. Overlapping
reconnects abort the prior resync, and unmount mid-fetch suppresses
writes to the disposed xterm.
- Hide BaseTerminal whenever `errorMessage` is set so the error layout
doesn't expose an empty xterm container behind the message;
`loading=false` after both load failure and resync success so a later
successful reconnect can clear a stuck spinner.
- Migrate the load/resync error strings to vue-i18n
(`logsTerminal.loadError`, `logsTerminal.resyncError`).

## Review Focus

- **Re-subscribe is the non-obvious half of the fix** — without it, even
after the WebSocket reconnects the backend never resumes streaming logs
to this client because its subscription state was wiped on restart. The
visible "stale buffer" is only one symptom; the silent "no new logs"
symptom needed the explicit `subscribeLogs(true)` re-call in resync.
- `terminal.reset()` lives after a successful raw-logs fetch (not
before) so a failed resync leaves the prior buffer visible instead of
blanking it; resync errors surface via the same inline error message the
mount path uses.
- 8 unit tests around the composable: mount + subscribe, resync ordering
(reset → write → scroll → subscribe via `invocationCallOrder`),
in-flight resync abort on double reconnect, resync error surfacing,
mount-failure-then-recovery, unmount-mid-fetch terminal-write
suppression, listener cleanup on unmount.
- 2 E2E tests using `ws.close()` on the proxied WebSocket as the
reconnect trigger and `subscribeLogs` HTTP fetch count as the sync point
(same pattern as `wsReconnectStaleJob.spec.ts`). Red-checked: disabling
the `reconnected` listener fails exactly the two new tests, all 8
pre-existing tests stay green.

Fixes FE-712

## Screenshots

Before - (After rebooting, the console window does not update from its
state before the reboot must remount the console window for it to
resync.)


https://github.com/user-attachments/assets/b1e49c2c-89a4-4a4a-82b4-064412acee12

After - (The console window syncs automatically after a reboot.)


https://github.com/user-attachments/assets/54b582c5-ad42-41c0-9886-18f4495859da




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12270-fix-terminal-resync-logs-console-on-backend-reconnect-3606d73d3650812fb13fd1934c632344)
by [Unito](https://www.unito.io)
2026-05-19 01:55:11 +00:00
Benjamin Lu
a97f46b497 test: cover job history sidebar with typed route mocks (#12272)
## Summary

Add the first product-area browser coverage on top of the merged typed
route mock foundation: the docked job history sidebar.

## Changes

- **What**: Adds `browser_tests/tests/sidebar/jobHistory.spec.ts` using
`jobsRouteFixture`.
- **What**: Covers direct sidebar entry, docked QPO history entry,
terminal history jobs, active queue jobs, tab filtering, search, clear
queue, and clear history.
- **What**: Adds typed `POST /api/queue` and `POST /api/history` route
helpers that validate request bodies with generated zod schemas.
- **What**: Adds stable test ids for the job history sidebar and queue
progress overlay so tests avoid structural CSS selectors.
- **Dependencies**: Builds on the typed route mock foundation merged in
#12267.

## Review Focus

Review the product assertions and whether this is the right first
coverage slice on top of the typed route mock foundation. This PR
intentionally avoids asset sidebar and floating QPO lifecycle coverage;
those should remain follow-up PRs.

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12272-test-cover-job-history-sidebar-with-typed-route-mocks-3606d73d3650817481d5f9fac4bfc93c)
by [Unito](https://www.unito.io)
2026-05-18 18:34:56 -07:00
Dante
448ad73fae refactor(assets): collapse useMediaAssets factory wrapper (FE-727) (#12283)
## Summary

https://linear.app/comfyorg/issue/FE-727/refactor-usemediaasset

`useMediaAssets` was a factory wrapper that branched on `isCloud` and
returned one of two near-identical implementations (`useAssetsApi` /
`useInternalFilesApi`). Both implementations delegated to the same
`assetsStore` actions (`updateInputs` / `updateHistory` /
`loadMoreHistory`) — the real cloud/local fork lives inside
`assetsStore.fetchInputFiles` (line 121), not at the composable layer.

Collapse the wrapper:

1. Delete `useInternalFilesApi` (identical to `useAssetsApi`).
2. Delete `useMediaAssets` (now a pass-through).
3. Switch the four callers to `useAssetsApi` directly.

## Changes

- **What**:
  - Delete `useInternalFilesApi.ts` and `useMediaAssets.ts`.
- `AssetsSidebarTab.vue`, `WidgetSelectDropdown.vue`,
`useOutputHistory.ts`: call `useAssetsApi` directly.
- `useWidgetSelectItems.ts`: narrow `outputMediaAssets` prop type to
`IAssetsProvider` (the interface `useOutputHistory` already implements
directly).
  - Test mocks repointed from `useMediaAssets` → `useAssetsApi`.
- Two unrelated tailwind class-order lint errors auto-fixed by `pnpm
lint:fix` to keep CI green.
- **Breaking**: None — no behavior change.

## Review Focus

The real cloud/local fork remains at `assetsStore.ts:121`
(`fetchInputFiles = isCloud ? fetchInputFilesFromCloud :
fetchInputFilesFromAPI`). That branch is M1-strict and clears once
BE-933 lands. This PR only collapses the dead wrapper layer above it.

`IAssetsProvider` is intentionally kept — `useOutputHistory.ts:83`
directly implements it for a different use case, so the interface still
has more than one consumer.

Surfaced while working on the FE-678 cloud/local asset branching survey.

## Screenshots (if applicable)

N/A — no UI change.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 00:05:40 +00:00
Deep Mehta
cf267acffe fix(cloud): stop bouncing working users to /cloud/survey mid-session (#12301)
## Summary

`getSurveyCompletedStatus` (auth.ts) now resolves ambiguous responses to
"completed" instead of "not completed", so transient backend errors no
longer bounce working users to `/cloud/survey`.

| Backend response | Old behavior | New behavior |
|---|---|---|
| 200 with non-empty `value` | `true` (completed) | `true` (completed) |
| 200 with empty `value` | `false` (not completed) | `false` (not
completed) |
| 404 | `false` → bounced | `true` (treat as completed) |
| 5xx | `false` → bounced | `true` (treat as completed) |
| 401 / 403 | `false` → bounced | `true` (treat as completed) |
| Network error | `false` → bounced | `true` (treat as completed) |

Only a definitive `200` with empty `value` is treated as "not
completed". Everything else fails open. The dedicated auth layer handles
re-authentication on the next API call, so 401/403 doesn't need a
separate branch here.

## Why

User reports from team-plan customers: _"I was working in a workflow,
hit run, and then got logged out and redirected to a survey screen."_
Datadog shows ~7,000 distinct users/day hitting the `setting key
onboarding_survey not found` path on prod ingest. With
`onboarding_survey_enabled: true` in prod dynamic config and the
catch-all `!response.ok` returning `false`, any mid-session reload
tripped a redirect to `/cloud/survey`.

User-validated requirement: rather miss showing the survey to a few
users than show it duplicately or interrupt working customers.

## Trade-off worth product review

A genuinely brand-new user whose `User.Settings` JSON is empty also
returns 404 from `/api/settings/onboarding_survey` — the backend doesn't
distinguish "key absent for existing user" from "user has no settings
yet". With this change, that 404 is treated as "completed", so the
survey gate does not fire on the strict 404 path. New users will still
see the survey if signup pre-populates the `onboarding_survey` key with
an empty object (`200` with empty `value`); if not, the survey is missed
on initial signup.

We picked this trade-off per the product call that false positives
(bouncing paying customers) are strictly worse than false negatives
(occasionally missing a new user).

The clean fix to recover the new-user signal is a backend change: return
`200` with `value: null` when the `User` row exists but the key is
absent — distinguishing "no survey saved" from "user not found". Out of
scope for this PR; filing as follow-up if accepted.

## Test plan

- [ ] Logged-in user with completed survey navigates around — no
redirect
- [ ] Logged-in user with no survey, fresh tab — redirected to
`/cloud/survey` (gate still works for new sessions)
- [ ] Logged-in user with no survey, after submitting — no redirect on
next nav
- [ ] Simulate transient 5xx on `/api/settings/onboarding_survey`
(DevTools blocking) — user stays on current page, no redirect

Unit coverage in `auth.test.ts` locks the resolution table above against
drift (one test per branch, 8 total).

## Companion PRs

None — frontend only.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12301-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3616d73d365081128ba7e266ad7ccff9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 23:37:02 +00:00
nav-tej
4e07fe3a43 feat(website): update Terms of Service to legal-approved 2026-05-13 copy (#12286)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces the `tos.*` i18n keys in
`apps/website/src/i18n/translations.ts` with the legal-approved Terms of
Service copy from `Comfy - Terms of Service (GP 5.12.26).docx` and
surfaces an effective date below the hero on `/terms-of-service`.

- Restructures the ToS into 14 sections (intro + 13 numbered sections)
to match the new legal-approved structure.
- Adds two new keys, `tos.effectiveDateLabel` and `tos.effectiveDate`,
rendered as a centered `Effective Date: May 13, 2026` line between the
hero and the content (matches the pattern used on the Affiliate Program
Terms page).
- Subsection labels (*Right to Access and Use Comfy Products.*,
*Customer Data.*, etc.) render as h3 headings via the existing
`block.N.heading` shape — no changes to `ContentSection.vue` or
`contentSections.ts`.
- English page meta description tightened to reflect the new scope
(Comfy Products: Cloud, API, Enterprise — explicitly excluding Comfy
OSS).

## Verbatim legal copy

Per request, the copy is **verbatim from the legal-approved `.docx`**,
including:

- `[URL]` placeholder in §2.7 (Data Retention) where the legal doc has a
placeholder pending the real docs page.
- `[Address]` placeholder in §12.8 (Notices) where the legal doc has a
placeholder pending the finalized mailing address.
- Mixed casing in §8 (Disclaimer) and §9 (Limitation of Liability) —
e.g. `THE Comfy Products AND OUTPUT…`, `…TOTAL LIABILITY OF Comfy…` —
preserved exactly as the legal doc presents it.
- §11(c) cross-reference left as written.

These are intentional and flagged for follow-up with legal/docs before
publishing. I have **not** silently substituted real values for the
placeholders or normalized casing — that would be editing legal-approved
text.

## Chinese (zh-CN) handling

The legal-approved copy was provided in English only. To avoid serving
English text under a Chinese page shell:

- `apps/website/src/pages/zh-CN/terms-of-service.astro` is **removed**.
- `getRoutes()` in `apps/website/src/config/routes.ts` treats
`termsOfService` as locale-invariant, so the Chinese footer link emits
`/terms-of-service` directly — no redirect hop.
- `astro.config.ts` adds a redirect from `/zh-CN/terms-of-service` →
`/terms-of-service` as a safety net for any stale external/cached links.
- All `zh-CN` values on the new `tos.*` keys are filler (mirrored from
English) so the `Record<Locale, string>` type contract holds; they are
never served.

## Files changed

- `apps/website/src/i18n/translations.ts` — 73 old `tos.*` keys removed,
136 new keys added matching the .docx structure.
- `apps/website/src/pages/terms-of-service.astro` — imports `t`, renders
effective date, updates meta description.
- `apps/website/src/pages/zh-CN/terms-of-service.astro` — **removed**.
- `apps/website/astro.config.ts` — adds `/zh-CN/terms-of-service` →
`/terms-of-service` redirect.
- `apps/website/src/config/routes.ts` — `termsOfService` route stays
un-prefixed in non-English locales.

## Verification

- `pnpm --filter=@comfyorg/website typecheck` — 0 errors (2 pre-existing
hints in unrelated files).
- `pnpm --filter=@comfyorg/website build` — 279 pages built,
`/terms-of-service/` (English page) and `/zh-CN/terms-of-service/`
(redirect stub with `noindex` + `canonical`) both emitted.
- Pre-commit lint-staged ran `oxfmt`, `oxlint --type-aware`, `eslint
--fix`, and `pnpm typecheck` on every commit — all green.
- Rendered HTML spot-checked: English `/terms-of-service` contains the
new content with verbatim `[URL]` and `[Address]` placeholders; zh-CN
homepage footer now links directly to `/terms-of-service` (no redirect
hop); `/zh-CN/privacy-policy` and other locale routes still correctly
emit `/zh-CN/…` prefixes.
- Manual visual check via `astro preview` + Playwright — sidebar nav, h2
section titles, h3 subsection headings, paragraph wrapping, and inline
mailto/href anchors all render correctly. Screenshots attached.

## Code-review follow-ups addressed

- **zh-CN regression** — Page removed, route override added, redirect
kept as safety net.
- **Page description mismatch** — Updated meta description to reflect
new scope.
- **`docs.comfy.org/data-retention` 404** — Now matches the docx
placeholder `[URL]`; flagged to legal/docs.
- **Disclaimer / Liability casing** — Restored to match docx verbatim.
- **Mailing address** — Now matches the docx placeholder `[Address]`;
flagged to legal.
- **Section 11(c) cross-reference** — Left verbatim per legal doc.

## Scope notes

- English-only legal update per request — no Chinese rewrite, no schema
changes, no acceptance-tracking infrastructure.
- The signup-flow link on `platform.comfy.org` (`website` repo) already
points at `https://www.comfy.org/terms-of-service` and renders the new
copy at the same URL — no change needed there.

## Screenshots

![Top of /terms-of-service showing 'Effective Date: May 13, 2026' below
the hero and new section
nav](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/57f1c6025f3f147cdc8916ecdd3ecbba847dcc10a971e40c830996f5d3685373/pr-images/1778833284897-43ef1f3a-5c7b-46eb-a73f-58bf38e857be.png)

![Section 2 (Comfy Products) showing h2 title and yellow-italic h3
subsection headings rendered from new tos.*.block.N.heading
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/57f1c6025f3f147cdc8916ecdd3ecbba847dcc10a971e40c830996f5d3685373/pr-images/1778833285304-47094d22-2dea-4192-a00c-2a857d92e0ab.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12286-feat-website-update-Terms-of-Service-to-legal-approved-2026-05-13-copy-3616d73d3650815b9262f84d12655dfa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-18 21:11:25 +00:00
Benjamin Lu
3e31de5bbb test: migrate MaxHistoryItems browser coverage (#12298)
## Summary

Migrates the MaxHistoryItems browser coverage to the accepted jobs route
fixture pattern.

## Changes

- **What**: Composes `jobsRouteFixture` into the queue settings spec and
removes the old `AssetsHelper` route setup.
- **What**: Adds a `responseLimit` option to `jobsRouteFixture` so tests
can match a requested history limit while intentionally returning more
jobs.
- **Dependencies**: None.

## Review Focus

The key behavior is preserving both FE-501 acceptance cases: `/api/jobs`
still receives the configured `limit`, and the queue panel still caps
rendered history even if the mocked backend returns more rows than
requested.

Fixes FE-501

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12298-test-migrate-MaxHistoryItems-browser-coverage-3616d73d365081d6bf77fb205fcd51d4)
by [Unito](https://www.unito.io)
2026-05-18 18:59:20 +00:00
jaeone94
0558740c78 refactor: migrate default combo widget select to Reka (#12288)
## Summary

Migrate the default combo widget select from the PrimeVue `SelectPlus`
wrapper to a Reka `Combobox` implementation while preserving the
existing Comfy combo widget contract and the node-canvas dropdown
behavior.

## Changes

- **What**: Rewrites `WidgetSelectDefault.vue` on top of Reka
`ComboboxRoot`, `ComboboxTrigger`, `ComboboxInput`, `ComboboxContent`,
and `ComboboxItem`.
- **What**: Preserves the default combo widget surface: `v-model`,
`widget` prop, `aria-label` from the widget name/label,
`data-capture-wheel`, disabled state, placeholder/filter placeholder,
default slot controls, invalid current value display, array values,
dynamic/factory values, and `getOptionLabel` fallback behavior.
- **What**: Keeps dynamic `values` compatibility by refreshing
function-backed options when the dropdown opens, without re-evaluating
the factory on every search keystroke.
- **What**: Deletes the now-unused PrimeVue `SelectPlus.vue` wrapper and
removes the PrimeVue test plugin/stub path from the default widget
select tests.
- **What**: Updates App Mode dropdown clipping coverage and combo-widget
browser coverage to target the new Reka overlay/viewport structure.
- **Breaking**: No breaking change is intended for the documented Comfy
combo widget contract. This migration does not preserve incidental
PrimeVue `Select` prop pass-through from `widget.options`; that was a
side effect of wrapping PrimeVue rather than a stable widget API.
- **Dependencies**: No new dependencies.

## Review Focus

### Compatibility choices

The goal of this PR is a migration PR, not a broad behavior redesign.
The new implementation keeps the Comfy-specific combo contract rather
than attempting to emulate PrimeVue internals. In particular:

- `values` still accepts arrays and functions, and function values are
re-read on open to support dynamic/custom node option sources.
- `getOptionLabel(value) || value` is intentionally preserved to match
the sibling dropdown path and avoid turning an empty-string label into a
blank rendered option.
- Invalid/current values that are not present in the option list are
still rendered in the trigger instead of disappearing.
- `WidgetWithControl` continues to render its default slot in the
control area, with trigger text truncation preserved.
- App Mode `OverlayAppendToKey='body'` continues to map to a body portal
to avoid panel clipping.

### Visual alignment and screenshot updates

The previous PrimeVue implementation passed `size="small"`, which
injected internal `.p-select-sm .p-select-label` styling. That internal
PrimeVue style used its own small-select font size and padding,
overriding the surrounding widget sizing intent and making the select
trigger subtly taller with slightly larger text than nearby inline node
widget controls.

The Reka implementation intentionally keeps the normal widget styling
path instead of recreating that PrimeVue-specific internal override.
This means the trigger follows the same inline widget sizing direction
as neighboring controls rather than preserving the incidental PrimeVue
height/text-size delta. Because this is an expected visual difference
from the migration, the affected E2E screenshots should be recaptured
instead of treating the old PrimeVue select height as the target.

### Scrollbar and focus behavior

Reka provides the combobox/listbox semantics we want, including search,
arrow navigation, highlighted items, and Enter selection. The tricky
part is the canvas dropdown scrollbar behavior. The native Reka viewport
path hides/owns scrollbar behavior in a way that made it hard to
preserve the previous widget dropdown affordances, especially visible
scrollbars and mouse wheel capture over the node canvas.

To keep the previous behavior, this PR renders a dedicated scrollable
viewport inside `ComboboxContent` with the project scrollbar utilities
(`scrollbar-thin`, stable gutter, transparent track). That preserves
visible scroll affordance and allows wheel events over the dropdown to
scroll the list instead of zooming the canvas.

There was one Reka interaction to account for: pressing the native
scrollbar can be treated as a focus-outside event from the search input,
which previously closed the dropdown on mouse down or caused subsequent
wheel events to leak back to the canvas. The new
`useRestoreFocusOnViewportPointer` composable handles only that short
pointer gesture:

- viewport pointerdown marks a short-lived scrollbar/viewport
interaction,
- the next focus-outside event is prevented only if the search input can
be restored,
- the guard is cleared by `pointerup`, `pointercancel`, and a timeout so
normal outside clicks still close the dropdown.

### Tests and regression coverage

Unit coverage was updated around the new Reka implementation:

- option sources from arrays and functions,
- dynamic values refreshed on open but not on each search keystroke,
- selection updates and blank/undefined Reka emissions being ignored,
- search filtering and Reka keyboard selection behavior,
- disabled state, invalid current values, `getOptionLabel`, empty
results status, and WidgetWithControl slot preservation,
- composable coverage for pointerup, pointercancel, repeated pointerdown
listener cleanup, and no-input/no-op behavior.

Browser regression coverage now checks the canvas-specific interaction
surface:

- opening and selecting default combo widget options,
- wheel over the dropdown scrolls the list instead of zooming the
canvas,
- pressing the scrollbar does not close the dropdown,
- wheel capture still works after pressing the scrollbar,
- opening another node widget closes the previous dropdown,
- switching between node widgets preserves dropdown scroll capture,
- serialize/reload retains selected combo values.

## Screenshots (if applicable)
New
<img width="527" height="753" alt="스크린샷 2026-05-18 오전 1 36 27"
src="https://github.com/user-attachments/assets/2293d510-6965-4b84-9b12-b8528f8a734f"
/>

Old 
<img width="496" height="473" alt="스크린샷 2026-05-18 오전 1 35 57"
src="https://github.com/user-attachments/assets/47c0e28a-27df-44a6-81a8-14fcc1f3bd8f"
/>

Reka Supports Auto highlight top item on search (Search -> Enter ->
Select 👍)


https://github.com/user-attachments/assets/9d633dfc-c23a-4e7a-8d39-b044c219f1f3

The default combo widget trigger has a small intentional visual delta
from the old PrimeVue path because the Reka implementation does not
recreate PrimeVue's internal `size="small"` label override.


https://github.com/user-attachments/assets/a9053a14-e39e-4d5e-a846-dcf9aeb0caed



## Validation

- `pnpm format`
- `pnpm lint` (passes; existing warning-only lint output remains in
unrelated tests)
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit`

- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:5174 pnpm
exec playwright test
browser_tests/tests/vueNodes/widgets/combo/comboWidget.spec.ts
--project=chromium`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12288-refactor-migrate-default-combo-widget-select-to-Reka-3616d73d365081fd8742c038a7dc7851)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-18 02:58:37 +00:00
Comfy Org PR Bot
8562816ffa 1.45.9 (#12303)
Patch version increment to 1.45.9

**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-18 00:44:24 +00:00
Terry Jia
5133ab6cf7 FE-416: route 3D node outputs to owner workflow on tab switch (#12295)
## Summary
When a workflow queues a 3D job (Preview3D / Load3D / SaveGLB) and the
user switches to another tab before the job completes, the WS `executed`
payload was silently dropped: `getNodeByExecutionId(app.rootGraph, …)`
resolves against the currently-visible workflow's graph, so the node
isn't found, `onExecuted` never fires, and the workflow JSON never
learns the path of the asset the backend just saved.

Worse, `setNodeOutputsByExecutionId` for top-level execution ids returns
the id verbatim without consulting rootGraph, so the old handler also
wrote the payload into the *active* (wrong) workflow's
`app.nodeOutputs`, polluting it and getting mis-attributed by
`ChangeTracker` snapshot/restore on subsequent tab switches.

This change introduces a single routing point — no new public API, no
new in-memory cache layer — that re-uses three pieces of infrastructure
that already exist independently:

1. `executionStore.jobIdToSessionWorkflowPath` already records which
workflow queued each prompt_id.
2. Each `ComfyWorkflow.ChangeTracker` already snapshots `nodeOutputs` on
`deactivate()` and replays it through the `app.nodeOutputs = …` setter
on `restore()`, which fires `onNodeOutputsUpdated` for every extension.
3. Audio nodes already implement `onNodeOutputsUpdated` to rehydrate
their widget from `nodeOutputs`. They have always been silently broken
in this same scenario (no one noticed because missing audio is less
visible than a missing 3D model); the fix here repairs audio for free as
a side effect.

The new behaviour:

- `app.ts` looks up the node against the active rootGraph *first*. Only
when found do we touch `nodeOutputStore` and call `node.onExecuted`
(existing path, unchanged). Moving the `nodeOutputStore` write inside
`if (node)` is the part that eliminates the cross-workflow pollution
above.
- When the node is not in the active rootGraph, the new
`routeExecutedToInactiveOwner` helper finds the owner workflow via
`jobIdToSessionWorkflowPath`, then writes the payload directly into
`owner.changeTracker.nodeOutputs` — the same field that `restore()`
already drains on tab switch back.
- Preview3D / SaveGLB add `onNodeOutputsUpdated` (mirroring the pattern
audio uses), normalise the path, write `node.properties['Last Time Model
File']` (already workflow-JSON-serialised), and apply it to the viewer.
The legacy `node.onExecuted` chain stays intact for live active-workflow
executions — both paths are idempotent.

## Screenshots (if applicable)
Before


https://github.com/user-attachments/assets/3803af1f-2eb6-41af-87ed-ac885a2eaad6


After


https://github.com/user-attachments/assets/72e1bed9-5f94-414d-ac31-fc925651d11b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12295-FE-416-route-3D-node-outputs-to-owner-workflow-on-tab-switch-3616d73d3650817b908de48a32b1d6bd)
by [Unito](https://www.unito.io)
2026-05-17 20:55:44 -04:00
Terry Jia
058acfe592 test: add basic E2E tests for CurveEditor widget (#10730)
## Summary
Add Playwright tests covering default rendering, interpolation selector,
and click-to-add-point interaction. Includes test workflow asset.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10730-test-add-basic-E2E-tests-for-CurveEditor-widget-3336d73d365081f5a403df837737bc12)
by [Unito](https://www.unito.io)
2026-05-17 20:54:07 -04:00
Christian Byrne
3b79917011 fix(website): restore registry metadata for cloud nodes catalog (#12307)
*PR Created by the Glary-Bot Agent*

---

## Summary

The
[`/cloud/supported-nodes`](https://comfy-website-preview-pr-12271.vercel.app/cloud/supported-nodes)
page was rendering packs without descriptions, icons, or download counts
(PR #12271 preview, [`Release: Website`
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25866708684/job/76010642686)).
The registry enrichment in
`apps/website/src/utils/cloudNodes.registry.ts` was silently failing for
**two** reasons:

1. **Missing `limit` query parameter.** `api.comfy.org /nodes` applies a
default page size of `10` when no `limit` is sent. Each batch of up to
50 `node_id` filters was therefore truncated to 10 results, dropping
metadata for every pack past the first ten.
2. **Schema rejected `null` for optional arrays.** The registry
serializes empty server-side slices as JSON `null`, so any pack with
`supported_os: null` or `supported_accelerators: null` failed Zod
validation — and because parse failure is not retryable, the **entire
batch** got `null` enrichment.

Both bugs produce the same user-visible symptom ("packs fetched, but no
metadata"), so they were entangled.

## Changes (2 files)

- `cloudNodes.registry.ts`: send `?limit=<batch length>` on every batch
and accept `null` for all optional registry fields. The schema
normalizes `null → undefined` at the parse boundary via `.transform()`,
so the parsed shape continues to match the generated OpenAPI `Node` type
contract; downstream code (`toDomainPack`, the rendered `Pack`) is
unchanged.
- `cloudNodes.registry.test.ts`: two new regression tests:
- Server-side default page size: simulates the pre-fix behavior (default
`limit=10` truncates) and asserts all 30 batched IDs are enriched.
- `null` registry fields: asserts `null` values are normalized to
`undefined` on the parsed pack.

## Verification

End-to-end fetch against the live registry on this branch (14 packs from
the current snapshot):

```
Requested: 14, enriched: 14
  comfyui-kjnodes                downloads=3,404,416
  rgthree-comfy                  downloads=3,105,034
  comfyui-easy-use               downloads=2,829,702
  comfyui-impact-pack            downloads=2,680,589
  comfyui_essentials             downloads=2,418,367  (supported_os null → undefined)
  ComfyUI-Crystools              downloads=1,729,087
  comfyui_layerstyle             downloads=1,696,809
  comfyui_ultimatesdupscale      downloads=1,478,763
  comfyui_ipadapter_plus         downloads=1,236,442  (supported_os null → undefined)
  was-node-suite-comfyui         downloads=993,960    (supported_os null → undefined)
  comfyui-advanced-controlnet    downloads=600,849
  comfyui-animatediff-evolved    downloads=503,831
  comfyui-cogvideoxwrapper       downloads=121,716    (supported_os null → undefined)
  comfyui_steudio                downloads=58,470     (supported_os null → undefined)
```

5 of 14 packs returned `null` arrays — all parse cleanly now.
Sort-by-downloads (already implemented in `useFilteredPacks.ts`) becomes
meaningful again once `downloads` is populated.

Quality gates:

- `pnpm --filter @comfyorg/website test:unit` → 77/77 pass (includes 2
new regression tests)
- `pnpm --filter @comfyorg/website typecheck` → 0 errors, 0 warnings on
changed files
- `pnpm exec eslint` on changed files → clean
- `pnpm exec oxfmt --check` on changed files → clean

## Follow-ups (separate tickets)

- "New" badge + `dateAdded` field for newly added packs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12307-fix-website-restore-registry-metadata-for-cloud-nodes-catalog-3626d73d365081288a2cfc30003160cf)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-16 20:27:24 +00:00
Dante
d955625c20 fix(model-library): auto-refresh after upload, plus 'r' key fallback (FE-695) (#12257)
## Summary

Model Library sidebar now refreshes automatically when an upload
completes, and the `r` keybinding refreshes the library in addition to
refreshing combo widgets inside graph nodes.

## Changes

- **What**:
- `modelStore`: new `refreshModelFolder(name)` for surgical reset+reload
of one folder, and `refresh()` that re-loads any folders that had been
loaded
- `ModelLibrarySidebarTab.vue`: watches
`assetDownloadStore.lastCompletedDownload` and refreshes the affected
folder; the in-panel refresh button now routes through `refresh()`
- `Comfy.RefreshNodeDefinitions` (`r` key): also calls
`modelStore.refresh()` so the keyboard fallback actually refreshes the
Model Library list

## Review Focus

- Both `modelStore` and `assetsStore` exist; the upload wizard was only
refreshing the latter, which is what caused the bug. Confirm the new
watcher path is the right hook (rather than wiring it inside the wizard)
— chose this so it also covers completions that happen after the wizard
has been closed.
- `refreshModelFolder` falls back to `refresh()` (not raw
`loadModelFolders()`) for unknown folder types, to avoid dropping other
folders' loaded contents.
- Generated tab half of the ticket is intentionally **deferred** until
BE-885 (cursor pagination on `GET /api/jobs`) lands — AC items around
"no duplicates" and "cursor state maintained" depend on it.

Fixes FE-695 (Model Library half).

## Screenshots (if applicable)

N/A — behavior change verified by unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12257-fix-model-library-auto-refresh-after-upload-plus-r-key-fallback-3606d73d3650811a8895ef6e3ef2b4b8)
by [Unito](https://www.unito.io)
2026-05-16 13:41:25 +00:00
Terry Jia
861d737041 FE-702: rehydrate 3D viewer on subgraph re-entry via persistent ready hook (#12294)
## Summary
When a Preview3D / Load3D / SaveGLB node lives inside a subgraph, the 3D
viewer correctly displays the model the first time you enter the
subgraph but is blank after exiting and re-entering — even though
`node.properties['Last Time Model File']` is still populated and the
underlying file is on disk.

Fix: introduce a persistent companion to `waitForLoad3d` in
`useLoad3d.ts`:

- `onLoad3dReady(callback)` — registers a callback that fires on *every*
(re-)initialization of the `Load3d` instance for a given node, not just
the first one. Cleared automatically when the node is removed from the
graph (chained into `node.onRemoved` alongside the existing
`pendingCallbacks` cleanup).
- `waitForLoad3d` keeps its original one-shot semantics so callbacks
that install per-node side effects (e.g. wrapping `node.onExecuted`,
setting `sceneWidget.serializeValue`) do not chain on remount.
- When `onLoad3dReady` is registered after a `Load3d` instance already
exists, the callback fires synchronously as well, so the same code path
covers both initial setup and subsequent rehydrations.

Preview3D / Load3D / SaveGLB move the "reapply state from
`node.properties` / `model_file` widget to the Load3d viewer" block from
`waitForLoad3d` to `onLoad3dReady`.
First mount and every subsequent remount now run identical rehydration
code, with `node.properties['Last Time Model File']` (already
workflow-JSON-serialised) as the single source of truth.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/e4b0fe6f-c898-4210-b545-7ad6883ed722

after

https://github.com/user-attachments/assets/a4a28490-071d-4694-87a8-5eaa501ac168

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12294-FE-702-rehydrate-3D-viewer-on-subgraph-re-entry-via-persistent-ready-hook-3616d73d3650811e93e7dedb32762711)
by [Unito](https://www.unito.io)
2026-05-16 05:42:04 -04:00
Csongor Czezar
7160a9ee3f fix: QPO progress bar now shows node name in subgraphs (#7688)
## Summary

Resolve the queue progress node label from queued prompt metadata so
subgraph execution IDs show the correct node name without depending on
the live canvas.

## Changes

- **What**: Store a prompt-scoped `executionId -> { title, type }`
lookup from `p.output` when queueing a job, and use that lookup for the
active job's executing node label.
- **What**: Reuse the same job-scoped node info for the browser tab
title so it stays aligned with the queue overlay.
- **What**: Add unit coverage for root and subgraph execution IDs, and
merge the branch forward to current `main`.

## Review Focus

This keeps the fix scoped to the existing singular `activeJobId` path.
It fixes subgraph labels and avoids the workflow-switching regression
from resolving against `app.rootGraph`, but it does not redesign
concurrent multi-job selection yet.

Longer term, the cleaner solution is still prompt-scoped execution
metadata from the backend rather than frontend reconstruction.

## Screenshots (if applicable)

N/A

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-15 14:00:33 -07:00
Rizumu Ayaka
71092b2011 fix: stop trackpad pinch/swipe gestures from breaking the UI (#12052)
## Summary

On macOS trackpads, several browser default gestures were leaking
through and breaking the workflow:

- **Pinch-zoom on a focused textarea widget** triggered page-level zoom,
pushing `position: fixed` UI (notably `ComfyActionbar`) off-screen until
a reload.
- **Horizontal swipe on a focused textarea widget** triggered browser
back/forward, leaving the workflow.
- **Pinch / horizontal swipe inside the image picker dropdown** had the
same two issues, because PrimeVue `Popover` teleports content to
`document.body` and the `LGraphNode` wheel handler never sees the
events.

Fixes FE-292.

## Why

- **`overscroll-behavior: none` on `html, body`** — horizontal swipe to
back/forward is decided by the browser at gesture start; JS
preventDefault can't reliably beat it. `overscroll-behavior` is the
standards-track signal for opting out, and ComfyUI is a full-screen
editor that never benefits from native overscroll.
- **`useCanvasInteractions` now treats pinch-zoom and
horizontal-dominant wheel as canvas gestures** that override widget
wheel consumption, so the gesture pans/zooms the canvas instead of
falling through to destructive browser defaults. The check is exported
as `isCanvasGestureWheel` for reuse.
- **`FormDropdownMenu` has its own minimal `onWheel`** that only
preventDefaults destructive gestures and deliberately does not forward
to the canvas. The dropdown is its own scroll container and shouldn't
leak interactions into the editor; vertical scrolling stays native.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12052-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3596d73d3650810aac3fcd8a71b29f9e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-15 14:35:54 +00:00
Comfy Org PR Bot
d05ec230bf 1.45.8 (#12282)
Patch version increment to 1.45.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12282-1-45-8-3616d73d365081efa628de04190d2a92)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-15 02:07:09 +00:00
Dante
d6f632477f feat(dialog): migrate Error / NodeSearchBox / SecretForm / VideoHelp / Customization to Reka-UI (Phase 2) (#12109)
## Summary

Phase 2 of the dialog migration kicked off in #11719 and continued in
#12041. Migrates four medium-complexity dialogs to the Reka-UI
primitives. Public API of `useDialogService` / `dialogStore` is
unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-574](https://linear.app/comfyorg/issue/FE-574/phase-2-migrate-error-nodesearchbox-secretform-videohelp-customization)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged)

> **NodeSearchBoxPopover deferred** — host of an inner PrimeVue Dialog
(filter panel) that teleports to body and conflicts with Reka's
DismissableLayer outside-pointer detection (CI dismissed the outer
dialog mid-interaction). Tracking as a follow-up PR; FE-574 stays open
for it.

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size |
| --- | --- | --- |
| `showExecutionErrorDialog()` | `'reka'` | `lg` |
| `showErrorDialog()` | `'reka'` | `lg` |

### `src/components/dialog/content/ErrorDialogContent.vue`
- Drops `import Divider from 'primevue/divider'` and `import ScrollPanel
from 'primevue/scrollpanel'`
- Replaces with `<hr class="border-t border-border-subtle">` + `<div
class="h-[400px] w-full max-w-[80vw] overflow-auto">`

### Direct PrimeVue → Reka swaps (no `dialogStore` involvement)
| File | Notes |
| --- | --- |
| `src/components/common/CustomizationDialog.vue` | Reka primitives +
DialogTitle/Header/Footer; drops PrimeVue Divider; `:modal="false"` and
`pointer-down-outside` overlay guard so the PrimeVue ColorPicker overlay
(teleported to body) does not auto-dismiss the dialog |
| `src/platform/assets/components/VideoHelpDialog.vue` | Headless Reka
content; preserves capture-phase ESC by stopping propagation on
`escape-key-down`; `VisuallyHidden` title for a11y |
| `src/platform/secrets/components/SecretFormDialog.vue` | Reka
primitives, retains `v-model:visible`, autofocus on the provider
trigger, form submit/validation |

### Tests
- `src/services/dialogService.renderer.test.ts`: extends the regression
net to cover both error-dialog call sites (renderer `'reka'`, size
`'lg'`)
- `src/components/common/CustomizationDialog.test.ts`: swaps PrimeVue
Dialog stub for Reka primitive stubs



### screenshot
<img width="1236" height="761" alt="Screenshot 2026-05-11 at 10 26
51 PM"
src="https://github.com/user-attachments/assets/086cb73f-a98d-41f8-96ee-21922da8dd73"
/>
<img width="1161" height="786" alt="Screenshot 2026-05-12 at 1 26 39 PM"
src="https://github.com/user-attachments/assets/db7383d8-f737-4472-91c0-dab5aa41547b"
/>


## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `dialogService.renderer.test.ts` — 5/5
  - `CustomizationDialog.test.ts` — 4/4
  - All `src/components/dialog` tests — 73/73
  - `src/platform/secrets` tests — 39/39
  - `NodeBookmarkTreeExplorer.test.ts` — 7/7
- [ ] CI Playwright matrix

## Public API impact

None. `useDialogService` / `dialogStore` signatures unchanged.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- NodeSearchBoxPopover — follow-up PR under FE-574
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Test plan

- [x] Unit: 73/73 dialog-area, 39/39 secrets
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Trigger an execution error → error dialog opens through Reka, scroll
body, copy-to-clipboard, close
- Add/edit a secret → form submits, validation errors render, ESC and
cancel close
- Open VideoHelpDialog from `UploadModelFooter` while inside the asset
modal → ESC closes only the help dialog
- Customize a node bookmark color/icon → apply/reset, color picker
overlay works
2026-05-15 02:05:46 +00:00
Alexander Brown
1ab63807e9 fix: reserve scrollbar gutter on textareas to prevent hover reflow (#12280)
*PR Created by the Glary-Bot Agent*

---

Adds the Tailwind 4.3 `scrollbar-gutter-stable` utility to the shared
shadcn `Textarea` wrapper and the `ModelInfoPanel` description textarea
so the layout no longer shifts when a vertical scrollbar appears on
hover or focus.

The wrapper edit propagates to all four consumers (`WidgetTextarea`,
`WidgetMarkdown`, `ComfyHubDescribeStep`, `ComfyHubCreateProfileForm`).
The `ModelInfoPanel` site uses a native `<textarea>` that bypasses the
wrapper, so it is fixed inline.

The `overflow-hidden hover:overflow-auto` performance pattern from
#10804 is intentionally preserved.

## Verification

- Typecheck, eslint, oxlint, oxfmt, stylelint clean on changed files
- `Textarea.test.ts`, `WidgetTextarea.test.ts`,
`WidgetMarkdown.test.ts`, `ComfyHubDescribeStep.test.ts`,
`ModelInfoPanel.test.ts` — all 68 tests pass
- `pnpm build` succeeds; the production CSS bundle contains the
`scrollbar-gutter:stable` rule
- Storybook (`UI/Textarea`): confirmed
`getComputedStyle(textarea).scrollbarGutter === 'stable'` on Default,
WithLabel, and overflowing-content scenarios

Fixes FE-697

## Screenshots

![Default Textarea story with scrollbar-gutter: stable
applied](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/be88568f9650862f90204bac94d0f79bf00a04fbfd32d37fcd46ad3c1fd3307b/pr-images/1778801341192-90974ee3-876c-4b1d-844c-487f687d80cd.png)

![Focused textarea showing vertical scrollbar with stable
gutter](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/be88568f9650862f90204bac94d0f79bf00a04fbfd32d37fcd46ad3c1fd3307b/pr-images/1778801341517-a6f52715-3ed8-4854-919e-02b5667f28b7.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12280-fix-reserve-scrollbar-gutter-on-textareas-to-prevent-hover-reflow-3606d73d3650816f9947e20134abf59e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-15 01:48:35 +00:00
Christian Byrne
e35bea51d6 refactor(browser-tests): centralize template mocking via TemplateHelper (#11837)
## Summary

Centralize template route mocking by mirroring the existing
`AssetHelper` fixture pattern, so tests no longer hand-roll
`page.route('**/templates/index.json', ...)` and
`page.route('**/templates/**.webp', ...)` blocks.

## Changes

- **What**:
- Expand `browser_tests/fixtures/data/templateFixtures.ts` with stable
distribution exports (`STABLE_CLOUD_TEMPLATE`,
`STABLE_DESKTOP_TEMPLATE`, `STABLE_LOCAL_TEMPLATE`,
`STABLE_UNRESTRICTED_TEMPLATE`), an `ALL_DISTRIBUTION_TEMPLATES` set,
and a `generateTemplates(count, distribution?)` bulk generator.
`makeTemplate` and `mockTemplateIndex` are preserved.
- Add `browser_tests/fixtures/helpers/TemplateHelper.ts` modeled on
`AssetHelper`: an immutable `TemplateConfig` driven by composable
operators (`withTemplates`, `withTemplate`, `withCloudTemplates`,
`withDesktopTemplates`, `withLocalTemplates`,
`withUnrestrictedTemplates`, `withRawIndex`), a `TemplateHelper` class
with `mock()` / `mockIndex()` / `mockThumbnails()` / `clearMocks()`, and
a `createTemplateHelper(page, ...operators)` factory.
- Add `browser_tests/fixtures/templateApiFixture.ts` exposing
`templateApi: TemplateHelper` as a Playwright fixture with automatic
`clearMocks()` teardown (mirrors `assetApiFixture`).
- Migrate `browser_tests/tests/templateFilteringCount.spec.ts` to
`mergeTests(comfyPageFixture, templateApiFixture)` and
`templateApi.configure(withTemplates(...))` + `templateApi.mockIndex()`.
The webp thumbnail mock moves into the helper.
- **Breaking**: None. `makeTemplate` and `mockTemplateIndex` exports
remain.

## Review Focus

- `TemplateHelper.mock()` is split into `mockIndex()` and
`mockThumbnails()` so the spec can install the thumbnail handler in
`beforeEach` (where it has no per-test data) and the index handler
per-test (after `configure(...)`). Both still register through
`routeHandlers` so `clearMocks()` unroutes everything.
- Operators are pure (`(config) => config`) and the helper deep-copies
the resulting `templates` array, matching the AssetHelper immutability
pattern.
- The thumbnail route is `**/templates/**.webp` and serves
`browser_tests/assets/example.webp` — identical to the previous inline
behavior.

Fixes #11431

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11837-refactor-browser-tests-centralize-template-mocking-via-TemplateHelper-3546d73d365081beac00d80c2ab5ea1c)
by [Unito](https://www.unito.io)
2026-05-14 15:54:40 -07:00
Rizumu Ayaka
f429e1e0c4 refactor: promote FormSearchInput to shared ui as AsyncSearchInput (#12185)
## Summary

Follow-up to #12183: move the debounced, searcher-driven search input
out of `src/renderer/...` and into the shared primitives folder, so both
the graph (form dropdown node widget) and the shell UI (templates
dialog, right side panel tabs) can use it without crossing the renderer
layer.

## Changes

- **What**: Renamed and relocated `FormSearchInput` → `AsyncSearchInput`
at `src/components/ui/search-input/AsyncSearchInput.vue`, joining the
existing `SearchInput` / `SearchAutocomplete` siblings.
- **What**: Updated all 9 callers (graph form dropdown, right side panel
tabs, templates dialog) to import from the new path/name. Test file
moved alongside the component.
- **Breaking**: None — pure rename + relocate, behavior is identical.

## Review Focus

- New name reflects the component's distinguishing feature (the async
`searcher` lifecycle: debounce + cancellation + loading state); see
Slack thread.
- Slack thread captured the discussion — splitting from #12183 so the
perf fix can backport cleanly to the release line.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12185-refactor-promote-FormSearchInput-to-shared-ui-as-AsyncSearchInput-35e6d73d365081c585d8d421ea4353fa)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-14 21:14:22 +00:00
Alexander Brown
d6b4137eec chore: upgrade tailwindcss to 4.3 (#12275)
*PR Created by the Glary-Bot Agent*

---

## Summary

Bump `tailwindcss` and `@tailwindcss/vite` from `^4.2.0` to `^4.3.0` in
the workspace catalog so the new first-class scrollbar utilities
(notably `scrollbar-gutter-stable`) are available — the missing utility
behind the FE-697 workaround. Release notes:
https://tailwindcss.com/blog/tailwindcss-v4-3.

## Changes

- **What**:
- `pnpm-workspace.yaml` catalog: `tailwindcss` and `@tailwindcss/vite` →
`^4.3.0` (lockfile resolves to `4.3.0` for `tailwindcss`,
`@tailwindcss/vite`, `@tailwindcss/node`, and all `@tailwindcss/oxide-*`
native packages).
- Auto-fix 21 lint errors that `eslint-plugin-better-tailwindcss` now
reports because the new utilities have canonical forms:
- `VirtualGrid.vue`: `[scrollbar-gutter:stable]` →
`scrollbar-gutter-stable` (the FE-697 case)
- `VirtualGrid.vue` + `RightSidePanel.vue`: `scrollbar-thin` class-order
fix
    - `TopBarHeader.vue`: `h-6.25 w-6.25` → `size-6.25` (6 button cells)
- `Dialogue.vue`: `translate-x-[-50%] translate-y-[-50%]` →
`translate-[-50%]`
- Minor `-mx-[…]`, `-inset-[…]`, `-ml-[…]` arbitrary-value reorderings
in `NodeSearchFilterBar.vue`, `LGraphNode.vue`,
`CloudNotificationContent.vue`
- All 21 are auto-fixes; all existed on `main` and would have started
failing CI on next merge.
- **Breaking**: None. v4.3 is purely additive; existing config
(CSS-first `@theme`/`@utility`/`@plugin`, custom `lucideStrokePlugin`,
`tailwindcss-primeui`, `@iconify/tailwind4`, `tw-animate-css`) is
unchanged.

## Review Focus

- Confirmed `scrollbar-gutter: stable` compiles into
`dist/assets/index-*.css` from `VirtualGrid.vue` after the
canonicalization.
- Runtime probe via Playwright (preview build) confirmed
`scrollbar-gutter-stable`, `scrollbar-thin`, `tab-4`, and `zoom-100` all
apply.
- `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm knip` all pass.
- `pnpm test:unit`: 10687/10696 pass. 1 failure in `GraphView.test.ts`
("reconnect wiring") confirmed to fail identically on `main` (flaky
`toHaveBeenCalledTimes(1)` getting `3`/`4`) — unrelated to this change.
- Oracle code review: 0 findings.

Refs FE-697.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12275-chore-upgrade-tailwindcss-to-4-3-3606d73d3650813185cece1c7315e1c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 14:28:11 -07:00
Christian Byrne
b578147714 fix(ci): add unmapped to genhtml ignore-errors, remove unreachable placeholder (#12273)
## Summary

- Add `unmapped` to genhtml `--ignore-errors` flag to fix GH Pages
deploy failure
- Remove unreachable placeholder block (dead code cleanup)

## Problem

PR #11381 added `--ignore-errors source` but genhtml is now failing with
a different error:

```
genhtml: ERROR: no data for line:4291, TLA:GNC, file:src/lib/litegraph/src/LGraphNode.ts
(use "genhtml --ignore-errors unmapped ..." to bypass this error)
```

This happens when LCOV data references lines that don't map to source
(from V8 coverage instrumentation).

## Changes

1. **Add `unmapped` to ignore-errors** — `--ignore-errors
source,unmapped` handles both missing source files and unmapped line
data

2. **Remove unreachable placeholder block** — The `if [ ! -s
coverage/playwright/coverage.lcov ]` check is dead code because the step
is already gated on `has-coverage == 'true'`, which only triggers when
the merged LCOV exists and is non-empty

## Test plan

- [ ] Verify workflow completes successfully on next push to main
- [ ] Verify https://comfy-org.github.io/ComfyUI_frontend/ returns 200

Fixes #12229

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12273-fix-ci-add-unmapped-to-genhtml-ignore-errors-remove-unreachable-placeholder-3606d73d36508198a4ffddfce3354d4a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-14 20:11:45 +00:00
Benjamin Lu
06e09df673 test: replace jobs mock fixture with typed route mocks (#12267)
## Summary

Replace the merged stateful jobs API browser mock fixture with a small
declarative typed route-mock foundation.

## Changes

- **What**: Removes `JobsApiMock`, `jobsApiMockFixture`, and the old
shared `jobFixtures` helper.
- **What**: Adds a generic `RouteMocker` primitive for explicit typed
JSON route responses.
- **What**: Adds `jobsRouteFixture`, which registers explicit
`/api/jobs` list/detail responses without filtering, mutation handling,
or hidden in-memory backend behavior.
- **What**: Migrates the current queue overlay and missing-media runtime
specs onto the new jobs route fixture.
- **What**: Keeps `./browser_tests/tsconfig.json` in the ESLint
TypeScript resolver config.
- **Dependencies**: None.

## Review Focus

This is intended to be the foundation PR for the test-strategy reset:
old stateful helper out, typed declarative route mocks in. It
intentionally does not add the full asset sidebar, job history sidebar,
or floating QPO coverage suite; those should stack on top after this
fixture shape is accepted.

The boundary this PR is trying to preserve: route mocks may describe
frontend-visible API responses, but should not implement Core
queue/history mutation semantics.

Context:
https://www.notion.so/comfy-org/E2E-Test-Strategy-for-Assets-Job-History-and-Queue-Progress-Overlay-35f6d73d365081209bc5f10e6c7eb8de

## Screenshots (if applicable)

Not applicable.
2026-05-14 20:09:48 +00:00
Christian Byrne
e972d658d3 feat(settings): lower Comfy.Pointer.ClickBufferTime default from 150ms to 32ms (#12032)
*PR Created by the Glary-Bot Agent*

---

## Summary

Click vs drag is disambiguated by two thresholds: distance
(`Comfy.Pointer.ClickDrift`, 6px) and time
(`Comfy.Pointer.ClickBufferTime`). Distance does the real work — any
intentional drag immediately exceeds 6px on the first move. The time
threshold only matters in the corner case where the pointer is held
still then released without crossing the distance threshold.

The previous 150ms default forces every pointerdown to wait up to 150ms
before drag begins, even when the user is clearly dragging. This is
visible as lag when click+dragging an unselected node, where the wait
stacks on top of selection-state work that runs at drag start
([FE-558](https://linear.app/comfyorg/issue/FE-558/bug-delay-when-clickdragging-an-unselected-node)).

32ms (~2 frames at 60fps) is well below human-perceptible click latency
and still safely above incidental jitter on pointerdown.

## Changes

- `src/platform/settings/constants/coreSettings.ts` —
`Comfy.Pointer.ClickBufferTime`:
  - `defaultValue: 150` → `32`
  - `versionAdded: '1.4.3'` retained, add `versionModified: '1.44.19'`
- Slider `step: 25` → `1` (the old step made `32` unrepresentable on the
slider)
- Tooltip extended with rationale (why distance is the real
disambiguator, why this should be small)
- `src/lib/litegraph/src/CanvasPointer.ts` — Static `bufferTime = 150` →
`32` for consistency with the user-setting default; the runtime
`watchEffect` in `useLitegraphSettings.ts` continues to override at
boot. JSDoc explains the rationale and that the user setting overrides
at runtime.

## Verification

- `pnpm typecheck` clean.
- Litegraph + settings test suites: 998/998 pass.
- `pnpm format` clean (lint-staged enforced on commit).

## Why not just leave it user-overridable?

It already is. But the default is what every user — including ones who
never open settings — experiences. 150ms was never a justified value (no
comment explains it, no benchmark, and 6px distance covers the
disambiguation). Defaults should reflect best UX.

Refs FE-558.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12032-feat-settings-lower-Comfy-Pointer-ClickBufferTime-default-from-150ms-to-32ms-3586d73d365081f5a29cdc84b4b736e3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 20:06:51 +00:00
Christian Byrne
f090ea3d28 fix: preserve app builder inputs through graph reconfiguration (#11422)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Fixes app mode bug where custom combo inputs (and other widget inputs)
unselect and show "Widget not visible" after refreshing or reopening a
saved app workflow
- Root cause: `resetSelectedToWorkflow()` loads from
`changeTracker.activeState` which may not have linearData yet after
refresh, and `pruneLinearData()` prunes valid entries during graph
loading when nodes aren't yet in the graph
- Two defensive guards: fallback to `initialState` for authoritative
data, and skip pruning during graph loading

## Changes

- `appModeStore.ts`: `resetSelectedToWorkflow()` now falls back to
`initialState.extra.linearData` when `activeState` has none
- `appModeStore.ts`: `pruneLinearData()` skips node-existence checks
when `ChangeTracker.isLoadingGraph` is true
- Unit tests: 4 new tests covering both fix paths (pruning during
loading, fallback to initialState)
- E2E test: Save-as → close → reopen → verify all inputs persist with no
"Widget not visible"

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11422-fix-preserve-app-builder-inputs-through-graph-reconfiguration-3476d73d36508166a563f7df3967665c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-14 12:28:21 -07:00
Christian Byrne
daab936d15 feat: add Cloud Status link to website footer (#12237)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a "Cloud Status" link to the Contact column of the website footer,
pointing to https://status.comfy.org so users can discover the service
status page from any page on the marketing site.

## Changes

- **What**: New external link in the Contact column of `SiteFooter.vue`,
plus matching i18n keys (`footer.cloudStatus` in `en`/`zh-CN`) and a
`cloudStatus` entry in the `externalLinks` config.

## Review Focus

- Placement: Slotted between Support and Press in the Contact column
(alongside the other support/operational links). Open to moving to the
Resources column instead if preferred.
- URL: `https://status.comfy.org` — assumed convention; swap if a
different status page URL is preferred.
- Also tightened the `contactColumn` type annotation to `{ title:
string; links: FooterLink[] }` to match `companyColumn` / `topColumns`
so the `external: true` field is properly typed.

## Screenshots

Desktop (Contact column, right side):

![Footer desktop](.glary/screenshots/footer-desktop.png)

Mobile (2×2 grid, Contact column bottom-right):

![Footer mobile](.glary/screenshots/footer-mobile.png)

## Screenshots

![Site footer desktop view showing Cloud Status link in the Contact
column with external arrow
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9ba647494446aa0233ca1f81ce96aa1778a59c5f4bbec394dc8074d4cff611a9/pr-images/1778717012660-56f4efd5-0f20-4570-8a19-0390b573b274.png)

![Site footer mobile view showing Cloud Status link in the Contact
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9ba647494446aa0233ca1f81ce96aa1778a59c5f4bbec394dc8074d4cff611a9/pr-images/1778717013059-fe6803d3-94ae-49ed-bcd9-b1eeee8f5ebd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12237-feat-add-Cloud-Status-link-to-website-footer-3606d73d36508190a906fdde6d86706d)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 15:02:41 +00:00
Christian Byrne
f176d18fe0 fix(website): refresh cloud nodes snapshot in release workflow + strict production builds (#12219)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Release: Website` only refreshed the Ashby snapshot, so the cloud-nodes
snapshot (`apps/website/src/data/cloud-nodes.snapshot.json`) was stale
on every release. `loadPacksForBuild()` then silently fell back to that
snapshot because `WEBSITE_CLOUD_API_KEY` was never plumbed through CI or
Vercel, leaving production at `/cloud/supported-nodes` with placeholder
data (e.g. `rgthree-comfy` listed as supported when it isn't — visible
at line 104 of the committed snapshot, last fetched 2026-05-04).

## Changes

- **New composite action `.github/actions/cloud-nodes-pull`** mirroring
`ashby-pull`: runs `pnpm --filter @comfyorg/website
cloud-nodes:refresh-snapshot` with `WEBSITE_CLOUD_API_KEY`. The script
already `process.exit(1)`s on any non-`fresh` outcome, so refresh
failures are loud.
- **`release-website.yaml`** now runs both refreshes and opens a single
PR with both updated snapshots. Renamed the job to `refresh-snapshots`,
updated branch/commit/title/body for the wider scope, and kept the
existing `Release:Website` label so downstream automation is unaffected.
- **`cloudNodes.build.ts`** throws when the outcome is `'stale'` **and**
`VERCEL_ENV === 'production'`. Preview / local builds keep the snapshot
fallback so contributors without key access are unaffected. The CI
reporter still runs first so the GitHub annotation explaining *why* it's
stale is visible in the failed job.
- **`ci-vercel-website-preview.yaml`**: passes `WEBSITE_CLOUD_API_KEY`
to `vercel build` in both preview and production jobs, and adds a
preflight step on `deploy-production` that hard-fails before `vercel
build --prod` if the secret is missing — surfacing config drift with a
maintainer-friendly error annotation instead of mid-build.
- **`apps/website/README.md`**: documents the production-strictness
behavior, the new required secret (GitHub Actions + Vercel env), and the
manual refresh path.
- **New unit tests** in `cloudNodes.build.test.ts` (6 cases): fresh,
stale-no-VERCEL_ENV, stale-on-preview, stale-on-production,
failed-regardless, and "still reports on stale-in-production before
throwing".

## Manual / one-time steps required before merging

This PR cannot finish the job alone. A maintainer must also:

1. Add `WEBSITE_CLOUD_API_KEY` as a **GitHub Actions repo secret** in
`Comfy-Org/ComfyUI_frontend`.
2. Add `WEBSITE_CLOUD_API_KEY` to the **Vercel project environment**
(`production` env at minimum; `preview` recommended).
3. Investigate why `rgthree-comfy` is in the current snapshot — either
the Cloud API was actually returning it on 2026-05-04, the snapshot was
generated against a non-production environment, or it was hand-edited.
The first manual run of `Release: Website` after this PR merges will
confirm.

Without step 1, the new `Release: Website` job will fail loudly (the
refresh script exits 1 with `missing WEBSITE_CLOUD_API_KEY`). Without
step 2, the new preflight will fail the production deploy with a clear
error annotation pointing at `apps/website/README.md`. Both failure
modes are intentional — they replace today's silent stale snapshot.

## Related (out of scope for this PR)

The other half of the original report — production 404s on
`/p/supported-models/*`, `/cloud/supported-nodes/*`,
`/demos/community-workflows` from PRs #11892 / #11903 / #11942 — is a
`comfy-router` allow-list gap (those paths exist in the Vercel build as
pre-rendered static HTML). That fix needs to land in
`Comfy-Org/comfy-router` and is being handled separately since glary
doesn't have access to that repo.

## Verification

- `pnpm --filter @comfyorg/website test:unit` — 75/75 pass (6 new in
`cloudNodes.build.test.ts`)
- `pnpm --filter @comfyorg/website typecheck` — 0 errors, 0 warnings (2
pre-existing hints unrelated to this PR)
- `pnpm format` + `pnpm exec eslint` on changed files — clean
- `js-yaml` validates `release-website.yaml`,
`cloud-nodes-pull/action.yaml`, `ci-vercel-website-preview.yaml`
- Oracle code review (round 1) raised 1 warning + 1 suggestion; both
addressed in commit 2.

**Manual verification not applicable**: the runtime changes are GitHub
Actions workflows and a Vercel-env-gated branch in a build-time module —
they cannot meaningfully run outside of GitHub Actions / Vercel, and the
strict-on-stale path is exhaustively covered by the 6 unit tests
(including the exact assertions a manual run would check: throws on
`VERCEL_ENV=production` + stale, passes on preview, reports
observability annotation before throwing). The end-to-end behavior will
be verified by the first `Release: Website` dispatch and the next
production deploy after the maintainer adds the secret.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12219-fix-website-refresh-cloud-nodes-snapshot-in-release-workflow-strict-production-build-35f6d73d3650816d8f32d403cb39d733)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary <bot@glary.dev>
2026-05-14 09:49:00 +00:00
Christian Byrne
01742672bb feat: extend version warning to all comfy_package_versions entries (#12167)
*PR Created by the Glary-Bot Agent*

---

## Summary

Extend the existing frontend version-mismatch warning UI to consume the
new `comfy_package_versions` array now exposed by ComfyUI's
`/system_stats` endpoint. The backend ships installed/required versions
for every `comfy*` package pinned in `requirements.txt` (frontend,
workflow-templates, embedded-docs, kitchen, aimdo, …); the frontend now
surfaces one toast per outdated package, reusing the existing N=1
frontend-version warning shape.

Backend PR: https://github.com/Comfy-Org/ComfyUI/pull/13875

## Changes

- `src/schemas/apiSchema.ts` — add `comfy_package_versions: Array<{name,
installed, required}>` to the `SystemStats` schema and export a
`ComfyPackageVersion` type. The field is `.optional()` so older backends
remain compatible.
- `src/platform/updates/common/versionCompatibilityStore.ts` — new
`outdatedComfyPackages` / `packageWarningMessages` computeds that mirror
the existing semver `gt` comparison (same `valid` guard). Skip
`comfyui-frontend-package` because the dedicated frontend warning above
already covers it (and uses the running bundle's version rather than the
installed pip version). Outdated packages are sorted by
`name`/`installed`/`required` before being folded into the dismissal
storage key so the key is stable across response orderings — a fresh
package bump re-shows the warning, but the same outdated set in a
different order does not.
- `src/platform/updates/common/useFrontendVersionMismatchWarning.ts` —
emit one toast per outdated package in addition to the existing frontend
toast, reusing the same i18n wrapper and the existing `hasShownWarning`
once-per-session guard.
- `src/locales/en/main.json` — new `g.comfyPackageOutdated` string.
- Unit tests — added coverage for outdated/skip/invalid-version paths,
dismissal-key inclusion, and stable ordering. Existing 21 store + 8
composable tests untouched and still passing.

CI suppression is unchanged: warnings still gate on
`versionCompatibilityStore.shouldShowWarning` (which respects the
`Comfy.VersionCompatibility.DisableWarnings` setting and the 7-day
dismissal cache), and unit tests continue to mock the store the same
way.

## Verification

- `pnpm vitest run` for the version-warning module: **31/31** passing.
- Targeted sweep across `src/platform/updates`,
`src/stores/systemStatsStore.test.ts`, `src/schemas`: **160/160**
passing.
- `pnpm typecheck`: clean.
- `pnpm lint`: 0 errors (3 pre-existing warnings in unrelated 3D test
files).
- `pnpm format`: applied, no incidental changes.
- **Manual Playwright run** against the real dev server with
`/api/system_stats` intercepted to return outdated package data —
produced exactly the expected toasts and correctly skipped
`comfyui-frontend-package` and the up-to-date `comfy-kitchen` entry.
Same run with all-up-to-date data produced zero toasts.

### Toasts produced (manual verification)

The fixture used: `required_frontend_version=99.99.99`, plus
`comfy_package_versions=[frontend-package (outdated, skipped),
workflow-templates 0.9.0→0.9.5 (outdated), embedded-docs 0.4.0→0.5.0
(outdated), comfy-kitchen 0.2.8→0.2.8 (up to date)]`.

## Screenshots

![Three version warning toasts displayed: outdated frontend (1.45.1 <
99.99.99), outdated workflow-templates (0.9.0 < 0.9.5), and outdated
embedded-docs (0.4.0 < 0.5.0). The skipped comfyui-frontend-package
entry and the up-to-date comfy-kitchen entry produced no extra
toasts.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562152770-11307c64-2863-41af-a42d-1c42fb332ca6.png)

![Zoomed view of the three Alert toasts in the top-right toast
stack.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562153211-df80e3b5-48d5-416e-a11f-3a6d25b6618c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12167-feat-extend-version-warning-to-all-comfy_package_versions-entries-35e6d73d365081e7b993d0f06c9e5c98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 08:24:32 +00:00
Christian Byrne
b7fe0365af fix: remove "400 Free Credits Monthly" line from cloud paywall modal (#12251)
*PR Created by the Glary-Bot Agent*

---

## Summary

The in-app cloud paywall modal shown on first install of ComfyUI Desktop
1.0 advertised "400 Free Credits Monthly" as a benefit, but the free
tier is currently disabled, making that copy misleading.

Per the thread direction, this skips the feature-flag plumbing (which
isn't available on Desktop anyway) and simply removes the line.

## Changes

- `CloudNotificationContent.vue`: change the feature loop from `n in 4`
to `n in [2, 3, 4]` so feature1 is skipped.
- `src/locales/*/main.json` (12 locales): remove the now-unused
`cloudNotification.feature1Title` key.
- `browser_tests/tests/dialog.spec.ts`: add a regression assertion that
"400" / "Free Credits" copy is no longer present in the modal.

## Test plan

- `pnpm typecheck` — passes
- `pnpm typecheck:browser` — passes
- `pnpm exec vitest run
src/platform/cloud/notification/components/DesktopCloudNotificationController.test.ts`
— 3/3 pass
- Pre-commit hooks (oxfmt, oxlint, eslint, stylelint, typecheck) — all
pass
- Manual: triggered `showCloudNotification()` against the dev server and
confirmed only 3 benefit rows render (screenshot attached); the "400
Free Credits Monthly" line is gone.

- Fixes DESK2-90

## Screenshots

![Cloud paywall modal after fix — shows 3 benefits without the '400 Free
Credits Monthly'
line](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d96d51ee1f37a5138014eeff4c46a901e5178c013e0412f288e49c17bddb88e4/pr-images/1778728094191-423f01df-f004-4ae5-a939-7d28413f6fbd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12251-fix-remove-400-Free-Credits-Monthly-line-from-cloud-paywall-modal-3606d73d365081eaa572e6cd995278d8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 22:39:32 -07:00
Dante
bdb92c845e fix: include share_id when importing published assets (FE-603) (#12055)
*PR Created by the Glary-Bot Agent*

https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778150003248989
FE-603
---

## Summary

Send `share_id` alongside `published_asset_ids` from
`workflowShareService.importPublishedAssets`, and type / zod-validate
the body against `ImportPublishedAssetsRequest` +
`zImportPublishedAssetsRequest` from `@comfyorg/ingest-types`.

This is a **parameter-schema change**, not a live bug fix. The original
`BAD_REQUEST: share_id is required` failure is **no longer reproducible
in production** — the backend rolled back the required-field enforcement
in [BE-855](https://linear.app/comfyorg/issue/BE-855) (cloud
[#3587](https://github.com/Comfy-Org/cloud/pull/3587) /
[#3588](https://github.com/Comfy-Org/cloud/pull/3588)). Today the import
succeeds without `share_id`.

- Closes FE-603

## Why we still ship this

Frontend goes first so the backend can later re-tighten the contract
without breaking shared-workflow imports a second time. Agreed sequence
in
[Slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778535349236419)
— BE-6 was split into two sub-issues to make the chain explicit:

1. **[BE-898](https://linear.app/comfyorg/issue/BE-898)** (blocks
FE-603) — BE makes `share_id` **optional**, validating when present.
[cloud PR #3633](https://github.com/Comfy-Org/cloud/pull/3633), In
Review.
2. **FE-603** (this PR) — FE sends `share_id`.
3. **[BE-899](https://linear.app/comfyorg/issue/BE-899)** (blocked by
FE-603) — BE flips `share_id` back to **required** after FE-603 deploys.

Steps 1 and 2 ship in order: BE-898 → FE-603 → BE-899.

## Changes

- `workflowShareService.importPublishedAssets` now takes `shareId:
string` and types the request body with `ImportPublishedAssetsRequest`.
The body is parsed through `zImportPublishedAssetsRequest` before the
network call so future contract drift surfaces as a typecheck or zod
parse failure rather than a silent runtime 400.
- `useSharedWorkflowUrlLoader.ts` threads `payload.shareId` (already in
scope from `SharedWorkflowPayload`) into the import call.
- Unit tests assert `share_id` is sent and that an empty `share_id` is
rejected before fetch.

## Verification

- `pnpm typecheck` — clean
- `pnpm lint` — 0 errors / 0 warnings on changed files (3 pre-existing
warnings in unrelated files)
- `pnpm format` — applied
- `pnpm vitest run` on both affected files — 34/34 passing

### Live in-browser smoke test (Vite dev server)

Loaded the built module in the browser, intercepted `fetch`, and
exercised the new code path. The new `importPublishedAssets` produces
the correct wire format and zod rejects bad input before fetch:

```json
// Happy path — sent to /api/assets/import (POST)
{
  "published_asset_ids": ["pa-1", "pa-2", "pa-3"],
  "share_id": "share-abc"
}
```

```text
// Empty share_id — zod rejects, fetch is never called
[ { code: "too_small", minimum: 1, path: ["share_id"], message: "String must contain at least 1 character(s)" } ]
fetchCallsBetweenAttempts: 0
```

## Why typecheck didn't catch the original miss

`api.fetchApi(route: string, options?: RequestInit)` accepts the
standard DOM `RequestInit`, so `body` is just `BodyInit | null`. Once
the call site does `JSON.stringify({ ... })`, the inline object is
erased into a string and TS has no schema to enforce. There was no
`@ts-ignore` or `as any` — the generated types were simply never
imported. This PR plugs that one call site; the same pattern should be
applied wherever the frontend hits ingest endpoints (the broader hey-zod
migration gap mentioned in #bug-dump).

Reported in #bug-dump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12055-fix-include-share_id-when-importing-published-assets-3596d73d365081c0a1c7e69102f5cfcc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-14 04:13:16 +00:00
Christian Byrne
d96be3d668 feat(#3410): add centralized assert() utility in src/base/ (#11824)
## Summary

Add a shared `assert(condition, message)` utility in `src/base/` that
centralizes DEV-throw / Desktop-Sentry / nightly-toast / `console.error`
policy for invariant reporting across the codebase.

## Changes

- **`src/base/assert.ts`**: New `assert()` utility with
`setAssertReporter()` registration pattern
  - `console.error` always fires on failure
  - Throws `Error` in DEV mode (surfaces bugs immediately)
  - Delegates to registered reporter otherwise (Sentry, toast, etc.)
- No imports from `platform/` — respects layer architecture (`base →
platform → workbench → renderer`)
- **`src/main.ts`**: Registers Sentry + nightly-toast reporter after
`Sentry.init()`
- **`src/scripts/changeTracker.ts`**: Migrates
`reportInactiveTrackerCall()` to use `assert()`, removing inline
`Sentry.captureMessage` + `console.warn` calls
- **`src/scripts/changeTracker.test.ts`**: Mocks `@/base/assert` to
prevent DEV-mode throws in existing no-op tests

## Testing

### Automated

- `src/base/assert.test.ts` — 6 tests covering: no-op on true,
console.error on false, DEV throw, non-DEV no-throw, reporter
invocation, reporter not called on true
- `src/scripts/changeTracker.test.ts` — 16 tests all pass (pre-existing)
- Coverage: 100% for assert.ts

### E2E Verification Steps

1. Run `pnpm test:unit` — all tests pass
2. Build the app and open browser devtools
3. In DEV mode: trigger a lifecycle violation (call an inactive tracker
method) — should see error thrown in console
4. In production build: same trigger — should see `console.error` only,
no throw

## Review Focus

- `setAssertReporter()` is called in `main.ts` once at startup —
appropriate for a singleton reporter. In tests that import `assert`, the
reporter is reset to a no-op in `afterEach`.
- Layer architecture respected: `base/assert.ts` has zero imports, upper
layers wire in side effects via `setAssertReporter()`.

Fixes #11373

<!-- Pipeline-Ticket: pick-issue-3410 -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11824-feat-3410-add-centralized-assert-utility-in-src-base-3546d73d3650819d96afdf4018161c26)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-14 02:42:16 +00:00
Dante
1869416185 fix: surface error dialog when Open Workflow from Job Queue fails (FE-215) (#12071)
## Summary

`openJobWorkflow` (Job Queue → "Open Workflow in New Tab") had no error
handling around `workflowService.openWorkflow`. When workflow JSON
contained nodes that fail `LiteGraph.configure()` (e.g. rgthree
`DisplayAny`, stale `GetNode/SetNode aux_id`), the menu action appeared
to do nothing — either an early return after a generic "Load Workflow
Error" dialog (`app.ts:1340-1347`) or a context-less toast from the
surrounding `wrapWithErrorHandlingAsync`.

This PR catches the error inside `openJobWorkflow` and routes it to
`dialogService.showErrorDialog` with a Job-Queue-specific `reportType`
so users get a clear, actionable message tied to the action they
invoked.

- Fixes #8841
- Linear:
[FE-215](https://linear.app/comfyorg/issue/FE-215/open-workflow-from-frontend-not-working)

## Red-Green Verification

| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: add failing test for openJobWorkflow swallowing load errors`
(a422b392d) | 🔴 failure |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531063427 |
| `fix: surface error dialog when Open Workflow from Job Queue fails`
(86b2a3a9) | 🟢 success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531374096 |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [ ] Manual: load a workflow containing rgthree `DisplayAny` from Job
Queue → "Open Workflow in New Tab" → confirm error dialog appears with
Job-Queue context instead of nothing
<img width="972" height="662" alt="Screenshot 2026-05-13 at 5 58 31 PM"
src="https://github.com/user-attachments/assets/bf1d8d96-85b7-47a8-89d8-b3bb69a526cb"
/>
2026-05-14 02:32:32 +00:00
jaeone94
a3106c4d53 fix: open node info panel from context menu (#12205)
## Summary

Replaces #12164.

Right-clicking a Vue node, using the selection toolbox More Options
menu, or clicking the selection toolbox Node Info button now opens the
right-side Info tab only when the new-menu UI makes that panel
available. Legacy-menu contexts hide the no-op action even when the
legacy node library design is selected; node-library help remains
isolated to the node library itself. The existing
`selection_toolbox_node_info_opened` telemetry fires only after the
toolbox button successfully opens node info. No new context-menu
telemetry event is added in this PR.

## Changes

- **What**: Share the node-info availability/action path across the
context menu and selection toolbox, keep legacy-menu state out of the
right-side panel public store API, tighten node-info settings tests, and
add unit plus E2E regression coverage for new-menu and legacy-menu
modes.
- **Dependencies**: None

## Review Focus

Confirm the node context menu, selection toolbox direct Info button, and
selection toolbox More Options entry all respect right-side panel
availability, including legacy menu + legacy node library mode, while
node-library help behavior remains isolated to the node library.

## Validation

- Self-review: checked production path, unit mocks, and Playwright
coverage; only gap found was weak E2E coverage for the toolbox direct
Info path, now strengthened.
- `pnpm test:unit -- src/composables/graph/useSelectionState.test.ts
src/components/graph/SelectionToolbox.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/selectionToolboxActions.spec.ts
browser_tests/tests/selectionToolboxSubmenus.spec.ts
browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts
--grep "info button opens the right-side info tab|info button is
hidden|hides Node Info|should open node info"`
- `pnpm typecheck:browser`
- `pnpm exec oxlint --type-aware
browser_tests/tests/selectionToolboxActions.spec.ts`
- `pnpm exec eslint --cache --no-warn-ignored
browser_tests/tests/selectionToolboxActions.spec.ts`
- `pnpm exec oxfmt --check
browser_tests/tests/selectionToolboxActions.spec.ts`
- `git diff --check`
- Commit hooks: lint-staged + `pnpm typecheck` + `pnpm
typecheck:browser`
- Push hook: `knip --cache` (existing tag hint only)

## Screenshots (if applicable)
Before 


https://github.com/user-attachments/assets/4b1f6ddb-a01c-4958-81ab-36167f434e59


https://github.com/user-attachments/assets/83433f0d-24f1-46b7-a81d-f0f065812496

After 


https://github.com/user-attachments/assets/30bd61e5-f8d4-48b7-97e0-26c93e3cb362


https://github.com/user-attachments/assets/afce9f51-a43d-434f-a006-6b357a61ac8f

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 02:26:11 +00:00
Comfy Org PR Bot
b59b342a43 1.45.7 (#12238)
Patch version increment to 1.45.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12238-1-45-7-3606d73d36508138981ee641470344e4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 02:21:20 +00:00
jaeone94
95e616b894 fix: clear media upload errors via widget change (#12212)
## Summary

Clear missing media validation errors after paste/drop media uploads by
emitting the existing widget-change event path.

## Changes

- **What**: Emit `node.onWidgetChanged` after image/video upload
completion updates the file combo widget.
- **What**: Emit the same widget-change path after Load Audio upload
completion.
- **What**: Add unit coverage for upload completion emitting
`onWidgetChanged` and for missing media clearing through that existing
hook path.
- **What**: Add E2E coverage for Load Image drag/drop and paste clearing
validation rings, with red/green verified from a fresh `main` base.
- **Dependencies**: None.

## Review Focus

Please check that paste/drop upload paths now reuse the existing
widget-change error-clearing path instead of expanding `widget.callback`
patching.

Also check the Load Image E2E helper path for synthetic paste/drop
behavior.

Supersedes #12207.

Ref: FE-687

## Screenshots

Before 


https://github.com/user-attachments/assets/2cee52bc-b1c8-4dff-8a02-5b18a69ae639

After 


https://github.com/user-attachments/assets/e1ecd147-1d8a-470e-b77d-13345d473ef3



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12212-fix-clear-media-upload-errors-via-widget-change-35f6d73d365081bcb1a0dfc042d417eb)
by [Unito](https://www.unito.io)
2026-05-14 01:28:41 +00:00
Alexis Rolland
5738c7a539 Add SaveAudioAdvanced to whitelisted nodes (#12213)
## Summary

Add `SaveAudioAdvanced` to whitelisted nodes in order to display the
audio player. This PR goes with the core PR:
https://github.com/Comfy-Org/ComfyUI/pull/13871

## Changes

- **What**: Display audio player on new node `SaveAudioAdvanced`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12213-Add-SaveAudioAdvanced-to-whitelisted-nodes-35f6d73d3650815783fac52d3d37e1e1)
by [Unito](https://www.unito.io)
2026-05-14 00:10:06 +00:00
Christian Byrne
8abfa678a3 fix: harden e2e coverage workflow and fix GH Pages deploy (#11381)
## Problem

The GH Pages coverage deploy has been failing since #11291 merged —
every `CI: E2E Coverage` workflow run errors out and
https://comfy-org.github.io/ComfyUI_frontend/ returns 404.

Additionally, two correctness/security issues were identified in the
workflow (filed as #11374 and #11375).

## Changes

1. **`--ignore-errors source` on genhtml** — merged LCOV data includes
paths like `localhost-8188/assets/main-BRkC1B8m.js` from Playwright V8
coverage instrumented runtime bundles that don't exist as source files
in CI, causing genhtml to error out
2. **Pin checkout to `workflow_run.head_sha`** — in `workflow_run`
context, the default checkout ref points to the default branch, not the
commit that triggered the upstream run; genhtml could annotate against
wrong source files (#11375)
3. **Gate deploy on `event == 'push'`** — a fork branch named `main`
could satisfy the branch check and overwrite production coverage; adding
the event guard prevents this (#11375)
4. **Include workflow run link in placeholder HTML** — when no coverage
data is available, the placeholder page now links back to the workflow
run for debugging (#11374)

## Fixes

- Fixes the GH Pages 404 caused by #11291
- Fixes #11374
- Fixes #11375
2026-05-13 23:06:36 +00:00
Christian Byrne
b36b601a1c refactor(litegraph): prune dead surfaces (AUDIT-LG implementation, draft) (#12228)
Implements the AUDIT-LG verdicts (#12223, #12224, #12225) as a single
deletion PR off main.

> **DRAFT — sequencing.** Per the AUDIT-LG framing doc, deletions land
**after Phase B ECS migration** (Alex's #11939 + #11811). Open early to
capture the diff and let CI cascade-flag any unused-export fallout. Flip
to ready-for-review post-Alex-rebase.

## Status

| commit | scope | status |
|---|---|---|
| 1 | Delete 6 LGraph stepping hooks (`onAfterStep`, `onBeforeStep`,
`onPlayEvent`, `onStopEvent`, `onAfterExecute`, `onExecuteStep`) + their
dispatch sites in `start()`/`stop()`/`runStep()` |  landed (this
commit) |
| 2 | Delete the rest of the dead executor cluster
(`start()`/`stop()`/`runStep()`/`sendEventToAllNodes()` + state fields +
`STATUS_*` constants) | follow-up |
| 3 | Delete `LGraphNode` dead event hooks (~22 fields per #12224) |
follow-up |
| 4 | Delete trigger/action subsystem (~22 symbols, #12223) | follow-up
|
| 5 | `ON_EVENT` deprecation cycle, release N (#12225) | follow-up |

## Verification (commit 1)

```
$ pnpm lint && pnpm format:check && pnpm knip
✓ format: All matched files use the correct format
✓ lint: pre-existing icon-name warnings only
✓ knip: no new unused exports flagged
```

## Verdicts the deletion is grounded in

- AUDIT-LG.7 master verdict table (146 surfaces classified DELETE-NOW /
DEPRECATE / KEEP)
- AUDIT-LG.9 per-symbol attribution sweep (confirmed zero functional
callers for the trigger cluster + the dead hooks)

For each surface in this PR:

- **`internal_count` from rg over `src/`, `browser_tests/`, `packages/`
excluding `lib/litegraph/`:** 0
- **External use from touch-points DB:** 0 (per AUDIT-LG.3 + AUDIT-LG.9
per-symbol attribution)
- **Host methods (`start()`/`stop()`/`runStep()`):** `@deprecated 'Will
be removed in 0.9'` already

The dispatch sites (`this.onAfterStep?.()` etc.) are inside the
deprecated host methods — removing the dispatchers does not change
observable behaviour because no listener is attached to begin with.

## Why batched, why draft

Per AUDIT-LG framing, the deletion sequences behind Alex's PR #11939
(ECS world-combo). Opening as draft lets CI run early and flags any
unused-export cascades the audit script missed. Each follow-up commit
will be its own atomic deletion (one feature per commit) so any single
one can be reverted in isolation if needed.

cc @drjkl @christian-byrne

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12228-refactor-litegraph-prune-dead-surfaces-AUDIT-LG-implementation-draft-35f6d73d365081e0b72cd292228e2ca6)
by [Unito](https://www.unito.io)

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-13 23:03:32 +00:00
jaeone94
ad63f7cb9b test: cover missing media runtime sources (#12126)
## Summary

Adds browser coverage for the missing-media runtime paths introduced by
#12069 and #12111:

- OSS: annotated `[output]` media is resolved from job history.
- Cloud: compact `[output]` media is resolved from output assets.
- OSS and Cloud: dropped video uploads do not surface a missing-media
error while the upload is still in progress.

This PR is now rebased directly onto `main`; the parent fix PRs have
been squash-merged, so this branch only contains the E2E coverage
commit.

## Test Fixtures

- Adds workflow fixtures for OSS spaced output annotations and Cloud
compact output annotations.
- Adds a small plain MP4 fixture for video drag/drop upload coverage.

## Validation

- `pnpm exec oxfmt --check
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
browser_tests/assets/missing/missing_media_cloud_output_annotation.json
browser_tests/assets/missing/missing_media_output_annotations.json`
- `pnpm typecheck:browser`
- `pnpm exec oxlint
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--type-aware`
- `git diff --check origin/main..HEAD`

Note: before the rebase, the Cloud project target for this spec passed
locally. Local OSS project execution against the currently running Cloud
dist did not reach the test body because `ComfyPage.waitForAppReady`
timed out in `beforeEach`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12126-test-cover-missing-media-runtime-sources-35d6d73d365081f0a981c02f33c0ff84)
by [Unito](https://www.unito.io)
2026-05-13 22:46:47 +00:00
Terry Jia
f7ef563b46 FE-657: prevent browser zoom on ctrl+wheel in mask editor (#12215)
## Summary

Wheel events on the mask editor pointer zone now call preventDefault,
matching the main canvas behavior so ctrl+wheel only zooms the mask
canvas instead of also triggering page zoom.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12215-FE-657-prevent-browser-zoom-on-ctrl-wheel-in-mask-editor-35f6d73d36508131a9b8dbf2f6640d72)
by [Unito](https://www.unito.io)
2026-05-13 18:24:22 -04:00
AustinMroz
9cc09cd46c Add additional subgraph test fixtures and tests (#11806)
- Adds functions to SubgraphHelper to perform widget promotion by
standard user means
  - Right Click -> Promote
  - Properties Panel
- Adds new slot fixture code that works with simple `locator.dragTo`
operations.
- Adds multiple subgraph tests with a focus on historically difficult
operations.
- Fixes a bug where the litegraph `node.selected` state would not be
unset when switching graphs. This made it so 'Selecting a node ->
leaving subgraph -> re-enter subgraph -> right click on node' would fail
to select the node because it is marked as already selected.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11806-Add-helper-functions-for-widget-promotion-3536d73d365081f58dd9cd730c1a91a9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-13 20:35:57 +00:00
pythongosssss
de1c1ee1f2 fix: add support for parsing python generated json with NaN/infinite (#12217)
## Summary

API and other legacy JSON generated by python `json.dumps` can contain
`NaN` and `Infinity` which cannot be parsed with JS `JSON.parse`. This
adds regex to replace these invalid tokens with `null`.

## Changes

- **What**: 
- add regex replace on bare NaN/infinity tokens after JSON.parse fails
- update call sites
- tests

## Review Focus
- The regex should only rewrite bare NaN/-Infinity/Infinity and not
touch string values or other invalid tokens.
- A small regex was chosen over JSON5 due to package size (30.3kB
Minified, 9kB Minified + Gzipped) or a manual parser due to the
unnecessarily complexity vs a single regex replace.
- The happy path is run first, the safe parse is only executed if that
failed, meaning no overhead the vast majority of the time and no
possiblity of corrupting valid workflows due to a bug in the fallback
parser
- Multiple call sites had to be updated due to pre-existing architecture
of the various parsers, an issue for unifying these is logged for future
cleanup
- New binary fixtures added for validating e2e import using real files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12217-fix-add-support-for-parsing-python-generated-json-with-NaN-infinite-35f6d73d365081889fc7f4af823f29c1)
by [Unito](https://www.unito.io)
2026-05-13 20:33:19 +00:00
AustinMroz
86b1e1a965 Fix descriptions on core blueprints (#12220)
Core blueprints were storing the description under a different key than
expected, which resulted in them displaying a placeholder description.
When initializing the description for a subgraph, this alternative field
is also checked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/ed51c4a8-00cf-4927-9cba-880532a9e926"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/f19bf80d-adcc-4e9b-a9ba-a5ac8e089e2d"
/>|

Resolves FE-681

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12220-Austin-blueprint-descriptions-35f6d73d3650812fa04df48c203bebd1)
by [Unito](https://www.unito.io)
2026-05-13 20:30:45 +00:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
348 changed files with 17522 additions and 3800 deletions

View File

@@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -0,0 +1,19 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

View File

@@ -106,17 +106,12 @@ jobs:
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
--precision 1 \
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
@@ -130,7 +125,8 @@ jobs:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -58,6 +58,7 @@ jobs:
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
@@ -151,10 +152,20 @@ jobs:
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshot:
refresh-snapshots:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,28 +31,39 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
branch: chore/refresh-website-snapshots-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available locally"
echo "TypeScript and Vue compiler already available globally"
fi
- name: Run Claude Documentation Review

3
.npmrc
View File

@@ -1,3 +0,0 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

View File

@@ -1,12 +1,12 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
class="border-y border-solid border-neutral-700"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<td class="w-16 text-center">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
@@ -14,7 +14,7 @@
{{ task.name }}
</p>
<Button
class="inline-block mx-2"
class="mx-2 inline-block"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
@@ -22,11 +22,11 @@
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<td class="px-4 text-right">
<Button
:icon="task.button?.icon"
:label="task.button?.text"

View File

@@ -119,6 +119,44 @@ snapshots can't be accidentally committed.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
### Production strictness
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
### Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
`apps/website/src/data/cloud-nodes.snapshot.json` via
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
`deploy-production` job hard-fails before `vercel build --prod` if the secret
is missing.
### Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs
to Comfy Cloud):
```bash
WEBSITE_CLOUD_API_KEY=\
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the `Release: Website` GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:

View File

@@ -28,7 +28,8 @@ export default defineConfig({
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
'/customers/series-entertainment/',
'/zh-CN/terms-of-service': '/terms-of-service'
},
build: {
assets: '_website'

View File

@@ -0,0 +1,14 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
]
}
const contactColumn = {
const contactColumn: { title: string; links: FooterLink[] } = {
title: t('footer.contact', locale),
links: [
{ label: t('footer.sales', locale), href: routes.contact },
@@ -91,6 +91,11 @@ const contactColumn = {
href: externalLinks.support,
external: true
},
{
label: t('footer.cloudStatus', locale),
href: externalLinks.cloudStatus,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
]
}

View File

@@ -20,11 +20,16 @@ const baseRoutes = {
type Routes = typeof baseRoutes
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`])
Object.entries(baseRoutes).map(([k, v]) => [
k,
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -32,6 +37,7 @@ export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',

View File

@@ -1773,6 +1773,7 @@ const translations = {
'footer.support': { en: 'Support', 'zh-CN': '支持' },
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.location': {
en: 'San Francisco, USA',
@@ -2049,269 +2050,594 @@ const translations = {
},
// ── Terms of Service ──────────────────────────────────────────────
'tos.intro.label': { en: 'INTRO', 'zh-CN': '简介' },
'tos.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': 'Effective Date'
},
'tos.effectiveDate': {
en: 'May 13, 2026',
'zh-CN': 'May 13, 2026'
},
'tos.intro.label': { en: 'INTRO', 'zh-CN': 'INTRO' },
'tos.intro.block.0': {
en: 'Welcome to the ComfyUI offering, provided by Comfy Organization, Inc.',
'zh-CN': '欢迎使用由 Comfy Organization, Inc. 提供的 ComfyUI 产品。'
en: 'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).',
'zh-CN':
'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).'
},
'tos.intro.block.1': {
en: 'Please read these Terms of Service (these "Terms") carefully, as they constitute a legally binding agreement between Comfy Organization, Inc., a Delaware corporation ("Comfy Org," "We," "Us," or "Our"), and an end-user ("You" and "Your") and apply to Your use of the Services (as defined below). In case You are subscribing to the Services as a representative of or on behalf of an entity (e.g., Your employer, the "Client" or "Entity"), Your acceptance of these Terms also binds the Client or Entity, and any reference in these Terms to "You" shall also mean the "Client" or "Entity" and its affiliates.',
en: 'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.',
'zh-CN':
'请仔细阅读本服务条款(以下简称"条款"),因为它们构成 Comfy Organization, Inc.(一家特拉华州公司,以下简称"Comfy Org"、"我们")与最终用户("您")之间具有法律约束力的协议,并适用于您对服务(定义见下文)的使用。如果您以实体(例如您的雇主,即"客户"或"实体")的代表身份或代表其订阅服务,您对本条款的接受也约束该客户或实体,本条款中对"您"的任何引用也应指"客户"或"实体"及其关联方。'
'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.'
},
'tos.intro.block.2': {
en: 'You hereby agree to accept these Terms by (a) either using the Services, or (b) by opening an account under a username. BEFORE YOU DO EITHER OF THOSE, PLEASE READ THESE TERMS CAREFULLY. IF YOU DO NOT WANT TO AGREE TO THESE TERMS, YOU MUST NOT USE THE SERVICES OR SET UP AN ACCOUNT.',
en: 'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.',
'zh-CN':
'您特此同意通过以下方式接受本条款:(a) 使用服务,或 (b) 以用户名开设账户。在您执行上述任何操作之前,请仔细阅读本条款。如果您不同意本条款,则不得使用服务或设置账户。'
'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.'
},
'tos.intro.block.3': {
en: 'You also agree to abide by other Comfy Org rules and policies, including our Privacy Policy https://www.comfy.org/privacy-policy (which explains what information we collect from You and how we protect it) that are expressly incorporated into and are a part of these Terms. Please read them carefully.',
'zh-CN':
'您还同意遵守 Comfy Org 的其他规则和政策,包括我们的隐私政策 https://www.comfy.org/privacy-policy该政策说明了我们从您处收集的信息以及如何保护这些信息这些规则和政策明确纳入本条款并构成其组成部分。请仔细阅读。'
},
'tos.intro.block.4': {
en: 'Once you accept these Terms You are bound by them until they are terminated. See Section 10 (Term and Termination).',
'zh-CN':
'一旦您接受本条款,您将受其约束,直至条款终止。请参阅第 10 条(期限和终止)。'
},
'tos.intro.block.5': {
en: 'By accessing or using the Software or Services in any way, You represent that (1) You have read, understand, and hereby agree to be bound by these Terms, (2) You are of legal age to form a binding contract with Comfy Org, and (3) You have the authority to enter into these Terms personally or on behalf of the Client Entity. If You do not agree to be bound by, or cannot conform with, these Terms, You may not use the Services. You will be legally and financially responsible for all actions using or accessing the Services, including the actions of anyone You allow to access Your Account.',
'zh-CN':
'通过以任何方式访问或使用软件或服务,您声明:(1) 您已阅读、理解并特此同意受本条款的约束,(2) 您已达到与 Comfy Org 签订具有约束力的合同的法定年龄,(3) 您有权以个人身份或代表客户实体签订本条款。如果您不同意受本条款约束或无法遵守本条款,则不得使用服务。您将对使用或访问服务的所有行为承担法律和财务责任,包括您允许访问您账户的任何人的行为。'
},
'tos.intro.block.6': {
en: 'IF YOU ACCEPT THESE TERMS, YOU AND COMFY ORG AGREE TO RESOLVE DISPUTES IN BINDING, INDIVIDUAL ARBITRATION AND GIVE UP THE RIGHT TO GO TO COURT INDIVIDUALLY OR AS PART OF A CLASS ACTION.',
'zh-CN':
'如果您接受本条款,您和 COMFY ORG 同意通过具有约束力的个人仲裁解决争议,并放弃以个人身份或作为集体诉讼一部分提起诉讼的权利。'
},
'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': '定义' },
'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. 定义' },
'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': 'DEFINITIONS' },
'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. Definitions' },
'tos.definitions.block.0': {
en: '"Business User" mean an entity or individual using the Software or Services primarily for business, commercial, or professional purposes.',
en: '“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
'zh-CN':
'"商业用户"指主要出于商业、贸易或专业目的使用软件或服务的实体或个人。'
'“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
},
'tos.definitions.block.1': {
en: '"ComfyUI Branding" means the names, logos, and associated trademarks owned or in progress of being owned by Comfy Org, Inc.',
en: '“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
'zh-CN':
'"ComfyUI 品牌"指 Comfy Org, Inc. 拥有或正在申请拥有的名称、标志和相关商标。'
'“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
},
'tos.definitions.block.2': {
en: '"ComfyUI Software" or "Software" means the open-source software product named "ComfyUI," including its desktop applications, source code, and user interface elements.',
en: 'Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.',
'zh-CN':
'"ComfyUI 软件"或"软件"指名为"ComfyUI"的开源软件产品,包括其桌面应用程序、源代码和用户界面元素。'
'Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.'
},
'tos.definitions.block.3': {
en: '"Customer Data" means any data, content, information, prompts, or workflows that You submit, upload, transmit, or process through the Software or Services.',
en: '“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
'zh-CN':
'"客户数据"指您通过软件或服务提交、上传、传输或处理的任何数据、内容、信息、提示词或工作流。'
'“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
},
'tos.definitions.block.4': {
en: '"Consumer User" means an individual using the Software or Services primarily for personal, family, or household purposes.',
'zh-CN': '"消费者用户"指主要出于个人、家庭或家用目的使用软件或服务的个人。'
en: 'Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.',
'zh-CN':
'“Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.'
},
'tos.definitions.block.5': {
en: '"Intellectual Property Rights" means all (i) patents, patent disclosures, and inventions (whether patentable or not), (ii) trademarks, (iii) copyrights and copyrightable works (including computer programs), and rights in data and databases, and (iv) all other intellectual property rights, in each case whether registered or unregistered and including all applications for, and renewals or extensions of, such rights, and all similar or equivalent rights or forms of protection in any part of the world.',
en: '“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
'zh-CN':
'"知识产权"指所有 (i) 专利、专利披露和发明(无论是否可获得专利),(ii) 商标,(iii) 版权和可受版权保护的作品(包括计算机程序)以及数据和数据库权利,(iv) 所有其他知识产权,在每种情况下无论已注册或未注册,包括所有此类权利的申请、续展或延期,以及世界任何地区的所有类似或等同的权利或保护形式。'
'“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
},
'tos.definitions.block.6': {
en: '"Open Source License" means the specific open-source license(s) governing the ComfyUI Software, primarily the GNU General Public License v3 (GPLv3) for its UI elements and potentially other components.',
en: '“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.',
'zh-CN':
'"开源许可证"指管辖 ComfyUI 软件的特定开源许可证,主要是用于其 UI 元素的 GNU 通用公共许可证第 3 版 (GPLv3) 以及可能适用于其他组件的许可证。'
'“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.'
},
'tos.definitions.block.7': {
en: '"Providers" means certain third-party service providers utilized by Comfy Org for certain functionality, including hosting and payment processing.',
en: '“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.',
'zh-CN':
'"提供商"指 Comfy Org 用于某些功能的特定第三方服务提供商,包括托管和支付处理。'
'“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.'
},
'tos.definitions.block.8': {
en: '"Services" means all current and future commercial and auxiliary services provided by Comfy Org in connection with the ComfyUI Software, including but not limited to:',
en: '“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
'zh-CN':
'"服务"指 Comfy Org 与 ComfyUI 软件相关的所有当前和未来的商业及辅助服务,包括但不限于:'
'“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
},
'tos.definitions.block.9': {
en: 'Commercial services:',
'zh-CN': '商业服务:'
en: '“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
'zh-CN':
'“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
},
'tos.definitions.block.10': {
en: 'Comfy Cloud — paid and fully managed cloud based ComfyUI hosted in our data centers\nAPI Nodes — paid integrations with third-party API services available within ComfyUI\nSupport, Training, Consulting — paid services related to ComfyUI',
en: '“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
'zh-CN':
'Comfy Cloud——付费的、完全托管的、基于云的 ComfyUI托管在我们的数据中心\nAPI 节点——ComfyUI 中可用的与第三方 API 服务的付费集成\n支持、培训、咨询——与 ComfyUI 相关的付费服务'
'“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
},
'tos.definitions.block.11': {
en: 'Open source services:',
'zh-CN': '开源服务:'
en: '“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.',
'zh-CN':
'“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.'
},
'tos.definitions.block.12': {
en: 'Custom Node Registry — marketplace of custom nodes freely available to ComfyUI users\nAny other hosted experiences or tools offered by Comfy Org.',
en: '“User” means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.',
'zh-CN':
'自定义节点 Registry——ComfyUI 用户免费使用的自定义节点市场\nComfy Org 提供的任何其他托管体验或工具。'
'“User” means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.'
},
'tos.license.label': { en: 'LICENSE', 'zh-CN': '许可' },
'tos.license.title': {
en: '2. ComfyUI Software License',
'zh-CN': '2. ComfyUI 软件许可'
'tos.comfy-products.label': {
en: 'COMFY PRODUCTS',
'zh-CN': 'COMFY PRODUCTS'
},
'tos.license.block.0': {
en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.',
'tos.comfy-products.title': {
en: '2. Comfy Products',
'zh-CN': '2. Comfy Products'
},
'tos.comfy-products.block.0.heading': {
en: 'Right to Access and Use Comfy Products.',
'zh-CN': 'Right to Access and Use Comfy Products.'
},
'tos.comfy-products.block.1': {
en: 'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.',
'zh-CN':
'开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。'
'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.'
},
'tos.license.block.1': {
en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.',
'tos.comfy-products.block.2.heading': {
en: 'Customer Data.',
'zh-CN': 'Customer Data.'
},
'tos.comfy-products.block.3': {
en: 'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
'zh-CN':
'软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。'
'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
},
'tos.license.block.2': {
en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.',
'tos.comfy-products.block.4.heading': {
en: 'No AI Training.',
'zh-CN': 'No AI Training.'
},
'tos.comfy-products.block.5': {
en: 'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
'zh-CN':
'服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。'
'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
},
'tos.using-services.label': { en: 'USAGE', 'zh-CN': '使用服务' },
'tos.using-services.title': {
en: '3. Using the Services',
'zh-CN': '3. 使用服务'
'tos.comfy-products.block.6.heading': {
en: 'Comfy OSS.',
'zh-CN': 'Comfy OSS.'
},
'tos.using-services.block.0': {
en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.',
'tos.comfy-products.block.7': {
en: 'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.',
'zh-CN':
'开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。'
'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.'
},
'tos.using-services.block.1': {
en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.',
'tos.comfy-products.block.8.heading': {
en: 'Partner Nodes.',
'zh-CN': 'Partner Nodes.'
},
'tos.comfy-products.block.9': {
en: 'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
'zh-CN':
'软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。'
'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
},
'tos.using-services.block.2': {
en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.',
'tos.comfy-products.block.10.heading': {
en: 'Modification of Comfy Products.',
'zh-CN': 'Modification of Comfy Products.'
},
'tos.comfy-products.block.11': {
en: 'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
'zh-CN':
'服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。'
'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
},
'tos.responsibilities.label': { en: 'RESPONSIBILITIES', 'zh-CN': '您的责任' },
'tos.responsibilities.title': {
en: '4. Your Responsibilities',
'zh-CN': '4. 您的责任'
'tos.comfy-products.block.12.heading': {
en: 'Data Retention and Deletion.',
'zh-CN': 'Data Retention and Deletion.'
},
'tos.responsibilities.block.0': {
en: 'You are responsible for your use of the Services and any content you create, share, or distribute through them. You agree to use the Services in a manner that is lawful, respectful, and consistent with these Terms. You are solely responsible for maintaining the security of your account credentials.',
'tos.comfy-products.block.13': {
en: 'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.',
'zh-CN':
'您应对使用服务以及通过服务创建、共享或分发的任何内容负责。您同意以合法、尊重他人且符合本条款的方式使用服务。您全权负责维护账户凭据的安全。'
'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.'
},
'tos.restrictions.label': { en: 'RESTRICTIONS', 'zh-CN': '限制' },
'tos.restrictions.title': {
en: '5. Use Restrictions',
'zh-CN': '5. 使用限制'
'tos.customer-responsibilities.label': {
en: 'RESPONSIBILITIES',
'zh-CN': 'RESPONSIBILITIES'
},
'tos.restrictions.block.0': {
en: 'You agree not to misuse the Services. This includes, but is not limited to:',
'zh-CN': '您同意不滥用服务,包括但不限于:'
'tos.customer-responsibilities.title': {
en: '3. Customer Responsibilities',
'zh-CN': '3. Customer Responsibilities'
},
'tos.restrictions.block.1': {
en: 'Attempting to gain unauthorized access to any part of the Services\nUsing the Services to distribute malware, viruses, or harmful code\nInterfering with or disrupting the integrity or performance of the Services\nScraping, crawling, or using automated means to access the Services without permission\nPublishing custom nodes or workflows that contain malicious code or violate third-party rights',
'tos.customer-responsibilities.block.0.heading': {
en: 'Registration.',
'zh-CN': 'Registration.'
},
'tos.customer-responsibilities.block.1': {
en: 'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.',
'zh-CN':
'试图未经授权访问服务的任何部分\n利用服务传播恶意软件、病毒或有害代码\n干扰或破坏服务的完整性或性能\n未经许可使用自动化手段抓取或爬取服务\n发布包含恶意代码或侵犯第三方权利的自定义节点或工作流'
'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.'
},
'tos.accounts.label': { en: 'ACCOUNTS', 'zh-CN': '账户' },
'tos.accounts.title': {
en: '6. Accounts and User Information',
'zh-CN': '6. 账户和用户信息'
'tos.customer-responsibilities.block.2.heading': {
en: 'General Technology Restrictions.',
'zh-CN': 'General Technology Restrictions.'
},
'tos.accounts.block.0': {
en: 'Certain features of the Services may require you to create an account. You agree to provide accurate and complete information when creating your account and to keep this information up to date. You are responsible for all activity that occurs under your account. We reserve the right to suspend or terminate accounts that violate these Terms.',
'tos.customer-responsibilities.block.3': {
en: 'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
'zh-CN':
'服务的某些功能可能要求您创建账户。您同意在创建账户时提供准确、完整的信息,并及时更新。您对账户下发生的所有活动负责。我们保留暂停或终止违反本条款的账户的权利。'
'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
},
'tos.ip.label': { en: 'IP RIGHTS', 'zh-CN': '知识产权' },
'tos.ip.title': {
en: '7. Intellectual Property Rights',
'zh-CN': '7. 知识产权'
'tos.customer-responsibilities.block.4.heading': {
en: 'Acceptable Use; Prohibited Customer Data.',
'zh-CN': 'Acceptable Use; Prohibited Customer Data.'
},
'tos.ip.block.0': {
en: 'The Services, excluding open-source components, are owned by Comfy and are protected by intellectual property laws. The Comfy name, logo, and branding are trademarks of Comfy Org, Inc. You retain ownership of any User Content you create. By submitting User Content to the Services, you grant Comfy a non-exclusive, worldwide, royalty-free license to host, display, and distribute such content as necessary to operate the Services.',
'tos.customer-responsibilities.block.5': {
en: 'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
'zh-CN':
'除开源组件外,服务归 Comfy 所有并受知识产权法保护。Comfy 名称、标志和品牌是 Comfy Org, Inc. 的商标。您保留您创建的任何用户内容的所有权。向服务提交用户内容即表示您授予 Comfy 一项非排他性、全球性、免版税的许可,以在运营服务所需的范围内托管、展示和分发此类内容。'
'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
},
'tos.distribution.label': { en: 'DISTRIBUTION', 'zh-CN': '分发' },
'tos.distribution.title': {
en: '8. Model and Workflow Distribution',
'zh-CN': '8. 模型和工作流分发'
'tos.payment.label': { en: 'PAYMENT', 'zh-CN': 'PAYMENT' },
'tos.payment.title': { en: '4. Payment', 'zh-CN': '4. Payment' },
'tos.payment.block.0.heading': {
en: 'Plans; Fees; Free Tier.',
'zh-CN': 'Plans; Fees; Free Tier.'
},
'tos.distribution.block.0': {
en: 'When you distribute models, workflows, or custom nodes through the Registry or Services, you represent that you have the right to distribute such content and that it does not infringe any third-party rights. You are responsible for specifying an appropriate license for any content you distribute. Comfy does not claim ownership of content distributed through the Registry.',
'tos.payment.block.1': {
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
'zh-CN':
'当您通过 Registry 或服务分发模型、工作流或自定义节点时您声明您有权分发此类内容且其不侵犯任何第三方权利。您有责任为分发的内容指定适当的许可证。Comfy 不主张对通过 Registry 分发的内容的所有权。'
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
},
'tos.fees.label': { en: 'FEES', 'zh-CN': '费用' },
'tos.fees.title': { en: '9. Fees and Payment', 'zh-CN': '9. 费用和付款' },
'tos.fees.block.0': {
en: 'Certain Services may be offered for a fee. If you choose to use paid features, you agree to pay all applicable fees as described at the time of purchase. Fees are non-refundable except as required by law or as expressly stated in these Terms. Comfy reserves the right to change pricing with reasonable notice.',
'tos.payment.block.2.heading': {
en: 'Self-Serve Credit Card Billing.',
'zh-CN': 'Self-Serve Credit Card Billing.'
},
'tos.payment.block.3': {
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
'zh-CN':
'某些服务可能需要付费。如果您选择使用付费功能则同意支付购买时所述的所有适用费用。除法律要求或本条款明确规定外费用不予退还。Comfy 保留在合理通知后变更定价的权利。'
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
},
'tos.termination.label': { en: 'TERMINATION', 'zh-CN': '终止' },
'tos.termination.title': {
en: '10. Term and Termination',
'zh-CN': '10. 期限和终止'
'tos.payment.block.4.heading': {
en: 'Invoiced Billing.',
'zh-CN': 'Invoiced Billing.'
},
'tos.termination.block.0': {
en: 'These Terms remain in effect while you use the Services. You may stop using the Services at any time. Comfy may suspend or terminate your access to the Services at any time, with or without cause and with or without notice. Upon termination, your right to use the Services will immediately cease. Sections that by their nature should survive termination will continue to apply.',
'tos.payment.block.5': {
en: 'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.',
'zh-CN':
'在您使用服务期间本条款持续有效。您可随时停止使用服务。Comfy 可随时暂停或终止您对服务的访问,无论是否有原因,也无论是否事先通知。终止后,您使用服务的权利将立即终止。按其性质应在终止后继续有效的条款将继续适用。'
'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.'
},
'tos.warranties.label': { en: 'WARRANTIES', 'zh-CN': '免责' },
'tos.warranties.title': {
en: '11. Disclaimer of Warranties',
'zh-CN': '11. 免责声明'
'tos.payment.block.6.heading': {
en: 'Prepaid Credits.',
'zh-CN': 'Prepaid Credits.'
},
'tos.warranties.block.0': {
en: 'THE SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. COMFY DOES NOT WARRANT THAT THE SERVICES WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.',
'tos.payment.block.7': {
en: 'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
'zh-CN':
'服务按"现状"和"可用"基础提供不附带任何形式的明示或暗示保证包括但不限于对适销性、特定用途适用性和非侵权性的暗示保证。Comfy 不保证服务将不间断、无错误或安全。'
'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
},
'tos.liability.label': { en: 'LIABILITY', 'zh-CN': '责任限制' },
'tos.payment.block.8.heading': {
en: 'Taxes; Price Changes; No Refunds.',
'zh-CN': 'Taxes; Price Changes; No Refunds.'
},
'tos.payment.block.9': {
en: 'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.',
'zh-CN':
'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.'
},
'tos.payment.block.10.heading': {
en: 'Late Payments; Suspension.',
'zh-CN': 'Late Payments; Suspension.'
},
'tos.payment.block.11': {
en: 'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
'zh-CN':
'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
},
'tos.term-termination.label': {
en: 'TERM; TERMINATION',
'zh-CN': 'TERM; TERMINATION'
},
'tos.term-termination.title': {
en: '5. Term; Termination',
'zh-CN': '5. Term; Termination'
},
'tos.term-termination.block.0.heading': {
en: 'Termination of Agreement.',
'zh-CN': 'Termination of Agreement.'
},
'tos.term-termination.block.1': {
en: 'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.',
'zh-CN':
'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.'
},
'tos.term-termination.block.2.heading': {
en: 'Effect of Termination.',
'zh-CN': 'Effect of Termination.'
},
'tos.term-termination.block.3': {
en: 'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customers access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customers use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.',
'zh-CN':
'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customers access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customers use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.'
},
'tos.term-termination.block.4.heading': {
en: 'Survival.',
'zh-CN': 'Survival.'
},
'tos.term-termination.block.5': {
en: 'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
'zh-CN':
'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
},
'tos.confidentiality.label': {
en: 'CONFIDENTIALITY',
'zh-CN': 'CONFIDENTIALITY'
},
'tos.confidentiality.title': {
en: '6. Confidentiality',
'zh-CN': '6. Confidentiality'
},
'tos.confidentiality.block.0.heading': {
en: 'Definition of Confidential Information.',
'zh-CN': 'Definition of Confidential Information.'
},
'tos.confidentiality.block.1': {
en: '“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.',
'zh-CN':
'“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.'
},
'tos.confidentiality.block.2.heading': {
en: 'Protection of Confidential Information.',
'zh-CN': 'Protection of Confidential Information.'
},
'tos.confidentiality.block.3': {
en: 'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
'zh-CN':
'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
},
'tos.confidentiality.block.4.heading': {
en: 'Compelled Disclosure.',
'zh-CN': 'Compelled Disclosure.'
},
'tos.confidentiality.block.5': {
en: 'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.',
'zh-CN':
'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.'
},
'tos.confidentiality.block.6.heading': {
en: 'Data Security.',
'zh-CN': 'Data Security.'
},
'tos.confidentiality.block.7': {
en: 'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
'zh-CN':
'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
},
'tos.proprietary-rights.label': {
en: 'PROPRIETARY RIGHTS',
'zh-CN': 'PROPRIETARY RIGHTS'
},
'tos.proprietary-rights.title': {
en: '7. Proprietary Rights',
'zh-CN': '7. Proprietary Rights'
},
'tos.proprietary-rights.block.0.heading': {
en: 'Reservation of Rights.',
'zh-CN': 'Reservation of Rights.'
},
'tos.proprietary-rights.block.1': {
en: 'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
'zh-CN':
'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
},
'tos.proprietary-rights.block.2.heading': {
en: 'Feedback.',
'zh-CN': 'Feedback.'
},
'tos.proprietary-rights.block.3': {
en: 'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.',
'zh-CN':
'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.'
},
'tos.proprietary-rights.block.4.heading': {
en: 'Operational Metadata.',
'zh-CN': 'Operational Metadata.'
},
'tos.proprietary-rights.block.5': {
en: 'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
'zh-CN':
'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
},
'tos.disclaimer.label': { en: 'DISCLAIMER', 'zh-CN': 'DISCLAIMER' },
'tos.disclaimer.title': { en: '8. Disclaimer', 'zh-CN': '8. Disclaimer' },
'tos.disclaimer.block.0': {
en: 'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.',
'zh-CN':
'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.'
},
'tos.disclaimer.block.1': {
en: 'Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customers use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.',
'zh-CN':
'Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customers use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.'
},
'tos.liability.label': { en: 'LIABILITY', 'zh-CN': 'LIABILITY' },
'tos.liability.title': {
en: '12. Limitation of Liability',
'zh-CN': '12. 责任限制'
en: '9. Limitation of Liability',
'zh-CN': '9. Limitation of Liability'
},
'tos.liability.block.0': {
en: "TO THE MAXIMUM EXTENT PERMITTED BY LAW, COMFY SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES RESULTING FROM YOUR USE OF THE SERVICES. COMFY'S TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNTS PAID BY YOU TO COMFY IN THE TWELVE MONTHS PRECEDING THE CLAIM.",
en: 'WHEN PERMITTED BY LAW, COMFY, AND COMFYS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.',
'zh-CN':
'在法律允许的最大范围内Comfy 不对任何间接、附带、特殊、后果性或惩罚性损害或任何利润或收入损失无论是直接还是间接产生的或任何数据、使用、商誉或其他无形损失承担责任。Comfy 的总责任不超过您在索赔前十二个月内向 Comfy 支付的金额。'
'WHEN PERMITTED BY LAW, COMFY, AND COMFYS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.'
},
'tos.indemnification.label': {
en: 'INDEMNIFICATION',
'zh-CN': 'INDEMNIFICATION'
},
'tos.indemnification.label': { en: 'INDEMNIFICATION', 'zh-CN': '赔偿' },
'tos.indemnification.title': {
en: '13. Indemnification',
'zh-CN': '13. 赔偿'
en: '10. Indemnification',
'zh-CN': '10. Indemnification'
},
'tos.indemnification.block.0': {
en: 'You agree to indemnify, defend, and hold harmless Comfy, its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses arising out of or in any way connected with your access to or use of the Services, your User Content, or your violation of these Terms.',
en: 'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfys prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.',
'zh-CN':
'您同意赔偿、辩护并使 Comfy 及其管理人员、董事、员工和代理人免受因您访问或使用服务、您的用户内容或您违反本条款而产生的或与之相关的任何索赔、责任、损害、损失和费用。'
'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfys prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.'
},
'tos.governing-law.label': { en: 'GOVERNING LAW', 'zh-CN': '适用法律' },
'tos.governing-law.title': {
en: '14. Governing Law and Dispute Resolution',
'zh-CN': '14. 适用法律和争议解决'
'tos.dispute-resolution.label': {
en: 'DISPUTE RESOLUTION',
'zh-CN': 'DISPUTE RESOLUTION'
},
'tos.governing-law.block.0': {
en: 'These Terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to its conflict of laws principles. Any disputes arising under these Terms shall be resolved through binding arbitration in accordance with the rules of the American Arbitration Association, except that either party may seek injunctive relief in any court of competent jurisdiction.',
'tos.dispute-resolution.title': {
en: '11. Governing Law and Dispute Resolution',
'zh-CN': '11. Governing Law and Dispute Resolution'
},
'tos.dispute-resolution.block.0.heading': {
en: 'Governing Law.',
'zh-CN': 'Governing Law.'
},
'tos.dispute-resolution.block.1': {
en: 'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.',
'zh-CN':
'本条款受特拉华州法律管辖并据其解释,不适用其冲突法原则。因本条款引起的任何争议应根据美国仲裁协会的规则通过有约束力的仲裁解决,但任何一方均可在有管辖权的法院寻求禁令救济。'
'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.'
},
'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': '其他' },
'tos.miscellaneous.title': { en: '15. Miscellaneous', 'zh-CN': '15. 其他' },
'tos.miscellaneous.block.0': {
en: 'These Terms constitute the entire agreement between you and Comfy regarding the Services. If any provision of these Terms is found to be unenforceable, the remaining provisions will continue in effect. Our failure to enforce any right or provision of these Terms will not be considered a waiver. We may assign our rights under these Terms. You may not assign your rights without our prior written consent.',
'tos.dispute-resolution.block.2.heading': {
en: 'Binding Arbitration; JAMS.',
'zh-CN': 'Binding Arbitration; JAMS.'
},
'tos.dispute-resolution.block.3': {
en: 'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.',
'zh-CN':
'本条款构成您与 Comfy 之间关于服务的完整协议。如果本条款的任何条款被认定为不可执行,其余条款将继续有效。我们未能执行本条款的任何权利或条款不构成放弃。我们可以转让本条款下的权利。未经我们事先书面同意,您不得转让您的权利。'
'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.'
},
'tos.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'tos.contact.title': { en: 'Contact Us', 'zh-CN': '联系我们' },
'tos.dispute-resolution.block.4.heading': {
en: 'Exceptions; Injunctive Relief.',
'zh-CN': 'Exceptions; Injunctive Relief.'
},
'tos.dispute-resolution.block.5': {
en: 'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.',
'zh-CN':
'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.'
},
'tos.dispute-resolution.block.6.heading': {
en: 'Class Action Waiver.',
'zh-CN': 'Class Action Waiver.'
},
'tos.dispute-resolution.block.7': {
en: 'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.'
},
'tos.dispute-resolution.block.8.heading': {
en: 'Waiver of Jury Trial.',
'zh-CN': 'Waiver of Jury Trial.'
},
'tos.dispute-resolution.block.9': {
en: 'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.'
},
'tos.dispute-resolution.block.10.heading': {
en: 'Exclusive Forum for Court Proceedings.',
'zh-CN': 'Exclusive Forum for Court Proceedings.'
},
'tos.dispute-resolution.block.11': {
en: 'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.',
'zh-CN':
'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.'
},
'tos.dispute-resolution.block.12.heading': {
en: 'Confidentiality.',
'zh-CN': 'Confidentiality.'
},
'tos.dispute-resolution.block.13': {
en: 'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.',
'zh-CN':
'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.'
},
'tos.dispute-resolution.block.14.heading': {
en: 'Time Limit.',
'zh-CN': 'Time Limit.'
},
'tos.dispute-resolution.block.15': {
en: 'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.'
},
'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': 'MISCELLANEOUS' },
'tos.miscellaneous.title': {
en: '12. Miscellaneous',
'zh-CN': '12. Miscellaneous'
},
'tos.miscellaneous.block.0.heading': {
en: 'Export Compliance.',
'zh-CN': 'Export Compliance.'
},
'tos.miscellaneous.block.1': {
en: 'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.',
'zh-CN':
'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.'
},
'tos.miscellaneous.block.2.heading': {
en: 'Publicity.',
'zh-CN': 'Publicity.'
},
'tos.miscellaneous.block.3': {
en: 'You agree that Comfy may refer to your name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
'zh-CN':
'You agree that Comfy may refer to your name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
},
'tos.miscellaneous.block.4.heading': {
en: 'Third-Party Infrastructure.',
'zh-CN': 'Third-Party Infrastructure.'
},
'tos.miscellaneous.block.5': {
en: 'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.',
'zh-CN':
'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.'
},
'tos.miscellaneous.block.6.heading': {
en: 'Assignment; Delegation.',
'zh-CN': 'Assignment; Delegation.'
},
'tos.miscellaneous.block.7': {
en: 'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
'zh-CN':
'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
},
'tos.miscellaneous.block.8.heading': {
en: 'Amendment; Waiver.',
'zh-CN': 'Amendment; Waiver.'
},
'tos.miscellaneous.block.9': {
en: 'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
'zh-CN':
'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
},
'tos.miscellaneous.block.10.heading': {
en: 'Relationship.',
'zh-CN': 'Relationship.'
},
'tos.miscellaneous.block.11': {
en: 'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
'zh-CN':
'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
},
'tos.miscellaneous.block.12.heading': {
en: 'Unenforceability.',
'zh-CN': 'Unenforceability.'
},
'tos.miscellaneous.block.13': {
en: 'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
'zh-CN':
'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
},
'tos.miscellaneous.block.14.heading': {
en: 'Notices.',
'zh-CN': 'Notices.'
},
'tos.miscellaneous.block.15': {
en: 'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.',
'zh-CN':
'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.'
},
'tos.miscellaneous.block.16.heading': {
en: 'Entire Agreement.',
'zh-CN': 'Entire Agreement.'
},
'tos.miscellaneous.block.17': {
en: 'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
'zh-CN':
'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
},
'tos.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
'tos.contact.title': { en: '13. Contact Us', 'zh-CN': '13. Contact Us' },
'tos.contact.block.0': {
en: 'If you have questions about these Terms, please contact us at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.',
en: 'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.',
'zh-CN':
'如果您对本条款有任何疑问,请通过 <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a> 与我们联系。'
'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.'
},
// Customers page

View File

@@ -71,7 +71,7 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<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

@@ -2,13 +2,17 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import ContentSection from '../components/common/ContentSection.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service — Comfy"
description="Terms of Service for ComfyUI and related Comfy services."
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>
<HeroSection title="Terms of Service" />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('tos.effectiveDateLabel')}: {t('tos.effectiveDate')}
</p>
<ContentSection prefix="tos" client:load />
</BaseLayout>

View File

@@ -1,14 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ContentSection from '../../components/common/ContentSection.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="服务条款 — Comfy"
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<HeroSection title="服务条款" />
<ContentSection prefix="tos" locale="zh-CN" client:load />
</BaseLayout>

View File

@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<() => Promise<FetchOutcome>>()
)
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes', () => ({
fetchCloudNodesForBuild: fetchCloudNodesMock
}))
vi.mock('./cloudNodes.ci', () => ({
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
}))
import { loadPacksForBuild } from './cloudNodes.build'
const SNAPSHOT: NodesSnapshot = {
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'snapshot-pack',
displayName: 'Snapshot Pack',
nodes: [
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
]
}
]
}
describe('loadPacksForBuild', () => {
const savedVercelEnv = process.env.VERCEL_ENV
beforeEach(() => {
fetchCloudNodesMock.mockReset()
reportCloudNodesOutcomeMock.mockReset()
delete process.env.VERCEL_ENV
})
afterEach(() => {
if (savedVercelEnv === undefined) {
delete process.env.VERCEL_ENV
return
}
process.env.VERCEL_ENV = savedVercelEnv
})
it('returns packs when fetch is fresh', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'fresh',
snapshot: SNAPSHOT,
droppedCount: 0,
droppedNodes: []
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale outside production', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
process.env.VERCEL_ENV = 'preview'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('throws when outcome is stale on Vercel production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/stale data in a production build/
)
await expect(loadPacksForBuild()).rejects.toThrow(
/missing WEBSITE_CLOUD_API_KEY/
)
})
it('throws when outcome is failed regardless of environment', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'failed',
reason: 'network error: ECONNREFUSED'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/Cloud nodes fetch failed and no snapshot is available/
)
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
})
it('still reports outcome before throwing on stale-in-production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
await expect(loadPacksForBuild()).rejects.toThrow()
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -3,6 +3,14 @@ import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
import { reportCloudNodesOutcome } from './cloudNodes.ci'
const REFRESH_HINT =
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
/**
* Resolve the list of packs to render at build time.
*
@@ -11,6 +19,10 @@ import { reportCloudNodesOutcome } from './cloudNodes.ci'
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
* `inflight` promise, so repeated calls in the same build process share a
* single network round-trip and the same outcome.
*
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
* to prevent silently shipping out-of-date snapshot data. Preview and
* local builds continue to use the committed snapshot.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const outcome = await fetchCloudNodesForBuild()
@@ -18,8 +30,14 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
if (outcome.status === 'failed') {
throw new Error(
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}
if (outcome.status === 'stale' && isProductionBuild()) {
throw new Error(
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}

View File

@@ -17,7 +17,7 @@ function jsonResponse(
}
describe('fetchRegistryPacks', () => {
it('requests node ids in batches of 50', async () => {
it('requests node ids in batches of 50 with matching limit param', async () => {
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
@@ -40,6 +40,73 @@ describe('fetchRegistryPacks', () => {
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
expect(firstCallUrl.pathname).toBe('/nodes')
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
expect(firstCallUrl.searchParams.get('limit')).toBe('50')
const lastCallUrl = new URL(String(fetchImpl.mock.calls[2]?.[0]))
expect(lastCallUrl.searchParams.getAll('node_id')).toHaveLength(20)
expect(lastCallUrl.searchParams.get('limit')).toBe('20')
})
it('survives the server defaulting to a small page size (regression for missing limit)', async () => {
// Mock applies the server's pre-fix behavior: default limit=10 silently
// truncates batches with more node_id filters than the page size.
const ids = Array.from({ length: 30 }, (_, i) => `pack-${i}`)
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
const requestedLimit = Number(url.searchParams.get('limit') ?? '10')
const batchIds = url.searchParams
.getAll('node_id')
.slice(0, requestedLimit)
return jsonResponse({
nodes: batchIds.map((id) => ({ id, name: id })),
total: batchIds.length,
page: 1,
limit: requestedLimit
})
})
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
const enriched = [...result.values()].filter((pack) => pack !== null)
expect(enriched).toHaveLength(30)
})
it('accepts null values for optional registry fields and normalizes them to undefined', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse({
nodes: [
{
id: 'pack-with-nulls',
name: 'Pack With Nulls',
description: null,
icon: null,
banner_url: null,
supported_os: null,
supported_accelerators: null,
publisher: null,
latest_version: null,
downloads: 42
}
],
total: 1,
page: 1,
limit: 50
})
)
const result = await fetchRegistryPacks(['pack-with-nulls'], {
fetchImpl: fetchImpl as typeof fetch
})
const pack = result.get('pack-with-nulls')
expect(pack).not.toBeNull()
expect(pack?.downloads).toBe(42)
expect(pack?.description).toBeUndefined()
expect(pack?.supported_os).toBeUndefined()
expect(pack?.supported_accelerators).toBeUndefined()
expect(pack?.publisher).toBeUndefined()
expect(pack?.latest_version).toBeUndefined()
})
it('retries a failed batch once and then succeeds', async () => {

View File

@@ -8,34 +8,47 @@ const BATCH_SIZE = 50
export type RegistryPack = components['schemas']['Node']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
}
const optionalString = z.string().nullish().transform(nullToUndefined)
const optionalNumber = z.number().nullish().transform(nullToUndefined)
const optionalStringArray = z
.array(z.string())
.nullish()
.transform(nullToUndefined)
const RegistryPackSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
banner_url: z.string().optional(),
repository: z.string().optional(),
license: z.string().optional(),
downloads: z.number().optional(),
github_stars: z.number().optional(),
created_at: z.string().optional(),
supported_os: z.array(z.string()).optional(),
supported_accelerators: z.array(z.string()).optional(),
id: optionalString,
name: optionalString,
description: optionalString,
icon: optionalString,
banner_url: optionalString,
repository: optionalString,
license: optionalString,
downloads: optionalNumber,
github_stars: optionalNumber,
created_at: optionalString,
supported_os: optionalStringArray,
supported_accelerators: optionalStringArray,
publisher: z
.object({
id: z.string().optional(),
name: z.string().optional()
id: optionalString,
name: optionalString
})
.passthrough()
.optional(),
.nullish()
.transform(nullToUndefined),
latest_version: z
.object({
version: z.string().optional(),
createdAt: z.string().optional()
version: optionalString,
createdAt: optionalString
})
.passthrough()
.optional()
.nullish()
.transform(nullToUndefined)
})
.passthrough()
@@ -141,6 +154,7 @@ async function fetchBatch(
timeoutMs: number
): Promise<BatchResponse> {
const params = new URLSearchParams()
params.set('limit', String(packIds.length))
for (const packId of packIds) {
params.append('node_id', packId)
}

View File

@@ -0,0 +1,45 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png[output]",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,84 @@
{
"last_node_id": 3,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["ComfyUI_00001_.png [output]", "image"]
},
{
"id": 2,
"type": "LoadVideo",
"pos": [430, 120],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["clip.mp4 [output]", "image"]
},
{
"id": 3,
"type": "LoadAudio",
"pos": [810, 120],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": ["sound.wav [output]", null, ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

Binary file not shown.

View File

@@ -0,0 +1,90 @@
{
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [230, 110],
"size": [270, 317.5666809082031],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveFloat",
"pos": [-80.55032348632812, 375.2260443115233],
"size": [270, 80.23332977294922],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveFloat"
},
"widgets_values": [0]
}
],
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [1335.8909766107738, 692.7345403667316]
},
"frontendVersion": "1.45.4"
},
"version": 0.4
}

View File

@@ -0,0 +1,51 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CurveEditor",
"pos": [50, 50],
"size": [400, 400],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "histogram",
"type": "HISTOGRAM",
"link": null
}
],
"outputs": [
{
"name": "curve",
"type": "CURVE",
"links": null
}
],
"properties": {
"Node name for S&R": "CurveEditor"
},
"widgets_values": [
{
"points": [
[0.2, 0.7],
[0.8, 0.3]
],
"interpolation": "linear"
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -246,4 +246,18 @@ export class VueNodeHelpers {
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
async isSlotConnected(slot: Locator) {
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
if (!key) return false
return await this.page.evaluate((key) => {
const [nodeId, type, slotId] = key.split('-')
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)].links?.length
}, key)
}
}

View File

@@ -6,12 +6,16 @@ export class ContextMenu {
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
protected readonly anyMenu: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
this.anyMenu = this.primeVueMenu
.or(this.litegraphMenu)
.or(this.litegraphContextMenu)
}
async clickMenuItem(name: string): Promise<void> {
@@ -36,16 +40,7 @@ export class ContextMenu {
}
async isVisible(): Promise<boolean> {
const primeVueVisible = await this.primeVueMenu
.isVisible()
.catch(() => false)
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
const litegraphContextVisible = await this.litegraphContextMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible || litegraphContextVisible
return await this.anyMenu.isVisible()
}
async assertHasItems(items: string[]): Promise<void> {
@@ -58,7 +53,7 @@ export class ContextMenu {
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect.poll(() => this.isVisible()).toBe(true)
await expect(this.anyMenu).toBeVisible()
return this
}

View File

@@ -5,11 +5,13 @@ import { TestIds } from '@e2e/fixtures/selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly overlay: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
}
async openClearHistoryDialog() {

View File

@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -0,0 +1,81 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export class SubgraphEditor {
public readonly root: Locator
public readonly promotionItems: Locator
constructor(protected readonly comfyPage: ComfyPage) {
this.root = this.comfyPage.menu.propertiesPanel.root
this.promotionItems = this.root.getByTestId(
TestIds.subgraphEditor.widgetItem
)
}
async open(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
}
resolveItem(options: {
nodeName?: string
nodeId?: string
widgetName: string
}): Locator {
const nodeItems =
options.nodeId !== undefined
? this.comfyPage.page.locator(`[data-nodeid="${options.nodeId}"]`)
: options.nodeName !== undefined
? this.promotionItems.filter({
has: this.comfyPage.page
.getByTestId(TestIds.subgraphEditor.nodeName)
.filter({ hasText: options.nodeName })
})
: this.promotionItems
return nodeItems.filter({
has: this.comfyPage.page
.getByTestId(TestIds.subgraphEditor.widgetLabel)
.filter({ hasText: options.widgetName })
})
}
getToggleButton(item: Locator) {
return item.getByTestId(TestIds.subgraphEditor.widgetToggle)
}
async togglePromotionOnItem(item: Locator, toState?: boolean) {
const toggleIcon = item.getByTestId(TestIds.subgraphEditor.iconEye)
if (toState !== undefined) {
const expectedIcon = `icon-[lucide--eye${toState ? '-off' : ''}]`
await expect(toggleIcon).toContainClass(expectedIcon)
}
await toggleIcon.click()
}
async togglePromotion(
subgraphNode: Locator,
options: {
nodeName?: string
nodeId?: string
widgetName: string
toState?: boolean
}
) {
await this.open(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)
}
async dragItem(fromIndex: number, toIndex: number) {
await dragByIndex(this.promotionItems, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
}

View File

@@ -2,10 +2,24 @@ import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
public readonly trigger: Locator
constructor(public readonly root: Locator) {
this.trigger = root.locator('button:has(> span)').first()
this.selection = root.locator('button span span')
}
async open(): Promise<void> {
await this.trigger.click()
}
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
await this.open()
const searchInput = popover.getByRole('textbox')
await searchInput.fill(query)
await searchInput.press('Enter')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}

View File

@@ -2,6 +2,11 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
@@ -24,6 +25,11 @@ export class AppModeWidgetHelper {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)

View File

@@ -2,34 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
export class BuilderSelectHelper {
/** All IoItem locators in the current step sidebar. */

View File

@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -20,43 +85,20 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
}
}

View File

@@ -1,176 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -1,19 +1,26 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
)
})
return () => count
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -21,7 +28,8 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -33,15 +41,39 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -9,12 +9,18 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
export class SubgraphHelper {
constructor(private readonly comfyPage: ComfyPage) {}
public readonly editor: SubgraphEditor
constructor(private readonly comfyPage: ComfyPage) {
this.editor = new SubgraphEditor(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
@@ -236,6 +242,17 @@ export class SubgraphHelper {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
async getInputBounds(): Promise<Position & Size> {
return await this.comfyPage.page.evaluate(() => {
const graph = app!.canvas.graph as Subgraph
const inputNode = graph.inputNode
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
const width = inputNode.size[0] * app!.canvas.ds.scale
const height = inputNode.size[1] * app!.canvas.ds.scale
return { x, y, width, height }
})
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
@@ -327,6 +344,23 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
}
async promoteWidget(nodeLocator: Locator, widgetName: string): Promise<void> {
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
await this.comfyPage.contextMenu
.openFor(widget)
.then((m) => m.clickMenuItemExact(`Promote Widget: ${widgetName}`))
}
async unpromoteWidget(
nodeLocator: Locator,
widgetName: string
): Promise<void> {
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
await this.comfyPage.contextMenu
.openFor(widget)
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph

View File

@@ -0,0 +1,198 @@
import type { Page, Route } from '@playwright/test'
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
}
function cloneIndex(
index: readonly WorkflowTemplates[] | null
): WorkflowTemplates[] | null {
return index ? index.map((m) => structuredClone(m)) : null
}
function addTemplates(
config: TemplateConfig,
templates: TemplateInfo[]
): TemplateConfig {
return { ...config, templates: [...config.templates, ...templates] }
}
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
config: TemplateConfig = emptyConfig()
) {
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
configure(...operators: TemplateOperator[]): void {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
async mock(): Promise<void> {
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
status: 200,
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
}
async mockThumbnails(): Promise<void> {
const thumbnailHandler = async (route: Route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
return cloneTemplates(this.templates)
}
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(
page: Page,
...operators: TemplateOperator[]
): TemplateHelper {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new TemplateHelper(page, config)
}

View File

@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -0,0 +1,236 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import {
zHistoryManageRequest,
zQueueManageRequest,
zQueueManageResponse
} from '@comfyorg/ingest-types/zod'
import type {
JobDetail,
JobStatus,
RawJobListItem,
zJobsListResponse
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
const terminalJobStatuses = [
'completed',
'failed',
'cancelled'
] as const satisfies readonly JobStatus[]
const activeJobStatuses = [
'in_progress',
'pending'
] as const satisfies readonly JobStatus[]
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
jobs: readonly RawJobListItem[]
limit?: number
offset?: number
responseLimit?: number
}
export interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
const requestedStatuses = new Set(
url.searchParams.get('status')?.split(',') ?? []
)
return (
requestedStatuses.size === statuses.length &&
statuses.every((status) => requestedStatuses.has(status))
)
}
function searchParamNumber(url: URL, name: string, fallback: number): number {
const value = url.searchParams.get(name)
return value === null ? fallback : Number(value)
}
function hasJobsListPageParams(
url: URL,
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
): boolean {
return (
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
(limit ?? defaultJobsListLimit) &&
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
(offset ?? defaultJobsListOffset)
)
}
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
return (
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
)
}
function createJobsListResponse({
jobs,
limit = defaultJobsListLimit,
offset = defaultJobsListOffset,
responseLimit = limit
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
const pageJobs = jobs.slice(offset, offset + responseLimit)
return {
jobs: pageJobs,
pagination: {
offset,
limit: responseLimit,
total: jobs.length,
has_more: offset + pageJobs.length < jobs.length
}
}
}
export function createRouteMockJob({
id,
...overrides
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
return {
id,
status: 'completed',
create_time: routeMockJobTimestamp,
execution_start_time: routeMockJobTimestamp,
execution_end_time: routeMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
export class JobsRouteMocker {
constructor(private readonly page: Page) {}
async mockJobsHistory(
jobs: readonly RawJobListItem[],
limit = defaultJobsListLimit,
options: Pick<JobsListRoute, 'responseLimit'> = {}
): Promise<void> {
await this.mockJobsList({
statuses: terminalJobStatuses,
jobs,
limit,
...options
})
}
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
await this.mockJobsList({
statuses: activeJobStatuses,
jobs
})
}
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
if (history) {
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
}
if (queue) {
await this.mockJobsQueue(queue)
}
}
async mockJobsList(route: JobsListRoute): Promise<void> {
const response = createJobsListResponse(route)
await this.page.route(
(url) =>
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: response })
}
)
}
async mockClearQueue(): Promise<QueueManageRequest[]> {
const response = zQueueManageResponse.parse({ cleared: true })
return await this.mockPostManageRoute(
'queue',
zQueueManageRequest,
response
)
}
async mockClearHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
await this.page.route(
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: detail })
}
)
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,
response: unknown
): Promise<TRequest[]> {
const requests: TRequest[] = []
await this.page.route(
(url) => url.pathname.endsWith(`/api/${type}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'POST') {
await requestRoute.fallback()
return
}
requests.push(
requestSchema.parse(requestRoute.request().postDataJSON())
)
await requestRoute.fulfill({ json: response })
}
)
return requests
}
}
export const jobsRouteFixture = base.extend<{
jobsRoutes: JobsRouteMocker
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
}
})

View File

@@ -8,6 +8,7 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -75,7 +76,15 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -103,14 +112,16 @@ export const TestIds = {
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
iconLink: 'icon-link',
nodeName: 'subgraph-widget-node-name',
shownSection: 'subgraph-editor-shown-section',
toggle: 'subgraph-editor-toggle',
widgetActionsMenuButton: 'widget-actions-menu-button',
widgetItem: 'subgraph-widget-item',
widgetLabel: 'subgraph-widget-label',
widgetToggle: 'subgraph-widget-toggle'
},
node: {
titleInput: 'node-title-input',
@@ -142,7 +153,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',
selectDefaultViewport: 'widget-select-default-viewport'
},
linear: {
centerPanel: 'linear-center-panel',
@@ -221,7 +234,10 @@ export const TestIds = {
currentUserIndicator: 'current-user-indicator'
},
queue: {
jobHistorySidebar: 'job-history-sidebar',
progressOverlay: 'queue-progress-overlay',
overlayToggle: 'queue-overlay-toggle',
dockedJobHistoryAction: 'docked-job-history-action',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',

View File

@@ -0,0 +1,250 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
}
})

View File

@@ -122,3 +122,19 @@ export async function saveAndReopenInAppMode(
await comfyPage.appMode.toggleAppMode()
}
export async function saveCloseAndReopenInBuilder(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
}

View File

@@ -0,0 +1,33 @@
import type { Locator } from '@playwright/test'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
export async function dragByIndex(
items: Locator,
fromIndex: number,
toIndex: number
) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}

View File

@@ -1,52 +0,0 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -15,6 +15,7 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly content: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -27,6 +28,7 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.content = locator.locator('.lg-node-content')
}
async getTitle(): Promise<string> {
@@ -39,6 +41,10 @@ export class VueNodeFixture {
await this.titleEditor.setTitle(value)
}
async select() {
await this.header.click()
}
async toggleCollapse(): Promise<void> {
await this.collapseButton.click()
}
@@ -60,4 +66,15 @@ export class VueNodeFixture {
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
}
getSlot(nameOrLocator: string | Locator) {
const slotLocators = this.root
.getByTestId('node-widget')
.or(this.root.locator('.lg-slot'))
const filteredLocator =
typeof nameOrLocator === 'string'
? slotLocators.filter({ hasText: nameOrLocator })
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
}

View File

@@ -2,7 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
@@ -30,9 +30,7 @@ test.describe('App mode usage', () => {
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
@@ -97,14 +95,54 @@ test.describe('App mode usage', () => {
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page
.getByTestId(TestIds.widgets.selectDefaultSearchInput)
.fill('uni')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test('FormDropdown search Enter selects the top filtered item', async ({
comfyPage
}) => {
await comfyPage.appMode.enableLinearMode()
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
await comfyPage.nextFrame()
const fileComboWidget = await loadImageNode.getWidget(0)
const targetImage = String(await fileComboWidget.getValue())
const initialImage = 'not-selected.png'
await comfyPage.page.evaluate(
([nodeId, value]) => {
const node = window.app!.graph!.getNodeById(nodeId)
const widget = node?.widgets?.[0]
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
widget.value = value
},
[loadImageNode.id, initialImage] as const
)
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
await comfyPage.appMode.enterAppModeWithInputs([
[String(loadImageNode.id), 'image']
])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageNode.id}:image`
)
const popover = comfyPage.appMode.imagePickerPopover
await expect(imageInput.root).toBeVisible()
await imageInput.searchAndSelectTop(popover, targetImage)
await expect(popover).toBeHidden()
await expect(imageInput.selection).toHaveText(targetImage)
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -87,7 +87,9 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
const overlay = comfyPage.page
.getByTestId('widget-select-default-overlay')
.first()
await expect(overlay).toBeVisible()
await expect
@@ -129,13 +131,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageId}:image`
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
await imageInput.open()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").

View File

@@ -0,0 +1,44 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
test.describe(
'App builder input persistence after reload',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('persists selected inputs after save and reopen without visibility errors', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
const workflowName = `${Date.now()} input-persistence`
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
for (const widget of WIDGETS) {
await expect(
appMode.select.getInputItemSubtitle(widget)
).not.toContainText('Widget not visible')
}
})
}
)

View File

@@ -147,5 +147,68 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -5,26 +5,18 @@ import {
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
builderSaveAs,
openWorkflowFromSidebar,
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
async function saveCloseAndReopenAsApp(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await appMode.toggleAppMode()
}

View File

@@ -0,0 +1,78 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Curve Widget', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/curve_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Loads control points and interpolation from workflow',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
const svg = node.getByTestId('curve-editor')
await expect(svg).toBeVisible()
const points = svg.getByTestId('curve-point')
await expect(points).toHaveCount(2)
const [cxs, cys] = await Promise.all([
points.evaluateAll((els) =>
els.map((e) => Number(e.getAttribute('cx')))
),
points.evaluateAll((els) =>
els.map((e) => Number(e.getAttribute('cy')))
)
])
expect(cxs[0]).toBeCloseTo(0.2, 5)
expect(cxs[1]).toBeCloseTo(0.8, 5)
expect(cys[0]).toBeCloseTo(0.3, 5)
expect(cys[1]).toBeCloseTo(0.7, 5)
}
)
test(
'Interpolation selector reflects loaded value (Linear)',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Linear', { exact: true })).toBeVisible()
await expect(node.getByText('Smooth', { exact: true })).toHaveCount(0)
}
)
test(
'Click on SVG canvas adds a control point',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const svg = node.getByTestId('curve-editor')
await expect(svg).toBeVisible()
await expect(svg.getByTestId('curve-point')).toHaveCount(2)
const position = await svg.evaluate((el) => {
const svgEl = el as SVGSVGElement
const ctm = svgEl.getScreenCTM()
if (!ctm) throw new Error('SVG has no screen CTM')
const pt = svgEl.createSVGPoint()
pt.x = 0.5
pt.y = 1 - 0.5 // curve-Y is inverted vs SVG-Y
const screen = pt.matrixTransform(ctm)
const rect = svgEl.getBoundingClientRect()
return { x: screen.x - rect.left, y: screen.y - rect.top }
})
await svg.click({ position })
await expect(svg.getByTestId('curve-point')).toHaveCount(3)
}
)
})

View File

@@ -247,6 +247,14 @@ test.describe('Cloud notification dialog', () => {
await dialog.back.click()
await expect(dialog.root).toBeHidden()
})
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
const dialog = new CloudNotification(comfyPage.page)
await dialog.open()
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
})
})
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {

View File

@@ -361,3 +361,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -25,6 +25,21 @@ const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
// NaN-variant fixtures embed only an API-format prompt containing bare
// `NaN`/`Infinity` tokens (Python's `json.dumps` default). The loader must
// tolerate Python generated JSON for these to import successfully.
const NAN_FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_nan_metadata.json', parser: 'json' },
{ fileName: 'with_nan_metadata.png', parser: 'png' },
{ fileName: 'with_nan_metadata.avif', parser: 'avif' },
{ fileName: 'with_nan_metadata.webp', parser: 'webp' },
{ fileName: 'with_nan_metadata.flac', parser: 'flac' },
{ fileName: 'with_nan_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_nan_metadata.opus', parser: 'ogg' },
{ fileName: 'with_nan_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_nan_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
@@ -58,5 +73,42 @@ test.describe(
})
})
}
for (const { fileName, parser } of NAN_FIXTURES) {
test(`loads Python JSON prompt with NaN/Infinity from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the NaN-laden prompt'
).toHaveLength(1)
})
await test.step('NaN-coerced widget values are 0', async () => {
const [ksampler] =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
for (const widgetName of ['cfg', 'denoise']) {
const widget = await ksampler.getWidgetByName(widgetName)
expect(
await widget.getValue(),
`${widgetName} should be 0 after NaN coercion to null`
).toBe(0)
}
})
})
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -1,7 +1,7 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
} from '@e2e/fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({

View File

@@ -0,0 +1,41 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName)
await expect(comfyPage.searchBox.input).toBeHidden()
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})

View File

@@ -0,0 +1,358 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const cloudOutputAsset: Asset = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
cloudUploadAssetStateByPage.set(page, state)
const assetsRouteHandler = async (route: Route) => {
const allAssets = [
cloudDefaultGraphInputAsset,
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
]
const includeTags =
new URL(route.request().url()).searchParams
.get('include_tags')
?.split(',')
.filter(Boolean) ?? []
const assets = includeTags.length
? allAssets.filter((asset) =>
asset.tags?.some((tag) => includeTags.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
const state = cloudUploadAssetStateByPage.get(page)
if (state) state.isUploadedAssetAvailable = true
})
}
})
async function enableErrorsTab(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
}
async function delayNextUpload(comfyPage: ComfyPage) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
resolveUploadStarted = resolve
})
const release = new Promise<void>((resolve) => {
releaseUpload = resolve
})
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
await route.continue()
}
await comfyPage.page.route('**/upload/image', uploadRouteHandler)
return {
waitForUploadStarted: () => uploadStarted,
finishUpload: async () => {
const uploadResponse = comfyPage.page.waitForResponse(
(response) =>
response.url().includes('/upload/image') && response.status() === 200,
{ timeout: 10_000 }
)
releaseUpload()
try {
await uploadResponse
} finally {
await comfyPage.page.unroute('**/upload/image', uploadRouteHandler)
}
}
}
}
async function expectLoadVideoUploading(comfyPage: ComfyPage) {
await expect
.poll(
() =>
comfyPage.page.evaluate(() =>
window.app!.graph.nodes.some(
(node) => node.type === 'LoadVideo' && node.isUploading
)
),
{ timeout: 5_000 }
)
.toBe(true)
}
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
const startedAt = Date.now()
await expect
.poll(
async () => {
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
Date.now() - startedAt >= missingMediaUploadObservationMs
)
},
{
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
}
)
.toBe(true)
}
function outputHistoryJobs(): RawJobListItem[] {
return [
createRouteMockJob({
id: 'history-output-image',
preview_output: {
filename: 'ComfyUI_00001_.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createRouteMockJob({
id: 'history-output-video',
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
}
}),
createRouteMockJob({
id: 'history-output-audio',
preview_output: {
filename: 'sound.wav',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
}
})
]
}
ossTest.describe(
'Errors tab - OSS missing media runtime sources',
{ tag: '@ui' },
() => {
ossTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
ossTest(
'resolves annotated output media from job history',
async ({ comfyPage, jobsRoutes }) => {
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
await jobsRoutes.mockJobsQueue([])
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_output_annotations'
)
await expectNoErrorsTab(comfyPage)
}
)
ossTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition
})
await delayedUpload.waitForUploadStarted()
comfyFiles.deleteAfterTest({
filename: plainVideoFileName,
type: 'input'
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudUploadRaceTest.describe(
'Errors tab - Cloud missing media upload race',
{ tag: '@cloud' },
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition
})
await delayedUpload.waitForUploadStarted()
comfyFiles.deleteAfterTest({
filename: plainVideoFileName,
type: 'input'
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
}
)
}
)

View File

@@ -1,56 +1,54 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
const now = Date.now()
const MOCK_JOBS: JobEntry[] = [
createMockJob({
const MOCK_JOBS: RawJobListItem[] = [
createRouteMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: now - 60_000,
execution_start_time: now - 60_000,
execution_end_time: now - 50_000,
create_time: mockJobTimestamp - 60_000,
execution_start_time: mockJobTimestamp - 60_000,
execution_end_time: mockJobTimestamp - 50_000,
outputs_count: 2
}),
createMockJob({
createRouteMockJob({
id: 'job-completed-2',
status: 'completed',
create_time: now - 120_000,
execution_start_time: now - 120_000,
execution_end_time: now - 115_000,
create_time: mockJobTimestamp - 120_000,
execution_start_time: mockJobTimestamp - 120_000,
execution_end_time: mockJobTimestamp - 115_000,
outputs_count: 1
}),
createMockJob({
createRouteMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: now - 30_000,
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
create_time: mockJobTimestamp - 30_000,
execution_start_time: mockJobTimestamp - 30_000,
execution_end_time: mockJobTimestamp - 28_000,
outputs_count: 0
}),
createMockJob({
createRouteMockJob({
id: 'job-failed-bottom',
status: 'failed',
create_time: now - 180_000,
execution_start_time: now - 180_000,
execution_end_time: now - 178_000,
create_time: mockJobTimestamp - 180_000,
execution_start_time: mockJobTimestamp - 180_000,
execution_end_time: mockJobTimestamp - 178_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()

View File

@@ -1,22 +1,36 @@
import { mergeTests } from '@playwright/test'
import type { Locator, Page, Request } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const test = mergeTests(comfyPageFixture, webSocketFixture, jobsRouteFixture)
const TOTAL_MOCK_JOBS = 20
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
const overflowJobsListRoutePattern = '**/api/jobs?*'
function createMockJobs(count: number): RawJobListItem[] {
const now = Date.now()
return Array.from({ length: count }, (_, i) =>
createRouteMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000
})
)
}
function isHistoryJobsRequest(url: string): boolean {
if (!url.includes('/api/jobs')) return false
@@ -44,21 +58,16 @@ function getJobListResults(page: Page): Locator {
test.describe('Queue settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Queue.MaxHistoryItems', () => {
test.describe('limit query parameter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(
createMockJobs(TOTAL_MOCK_JOBS)
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('limit query parameter on /api/jobs reflects the setting', async ({
comfyPage,
getWebSocket
getWebSocket,
jobsRoutes
}) => {
const TARGET_LIMIT = 6
await jobsRoutes.mockJobsHistory(
createMockJobs(TOTAL_MOCK_JOBS),
TARGET_LIMIT
)
await comfyPage.settings.setSetting(
MAX_HISTORY_ITEMS_SETTING,
TARGET_LIMIT
@@ -73,39 +82,14 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
test('queue panel caps history items to the configured number', async ({
comfyPage,
getWebSocket
getWebSocket,
jobsRoutes
}) => {
// Add a mock route that returns all jobs regardless of the request's `limit` param
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await comfyPage.page.route(
overflowJobsListRoutePattern,
async (route) => {
const url = new URL(route.request().url())
if (!url.searchParams.get('status')?.includes('completed')) {
await route.continue()
return
}
const response = {
jobs: overflowJobs,
pagination: {
offset: 0,
limit: overflowJobs.length,
total: overflowJobs.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
)
const VISIBLE_LIMIT = 6
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await jobsRoutes.mockJobsHistory(overflowJobs, VISIBLE_LIMIT, {
responseLimit: overflowJobs.length
})
await comfyPage.settings.setSetting(
MAX_HISTORY_ITEMS_SETTING,
VISIBLE_LIMIT

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -18,70 +19,19 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
await expect(nodeInfoButton).toBeHidden()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -90,11 +40,14 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -0,0 +1,147 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.use({
initialSettings: {
'Comfy.RightSidePanel.ShowErrorsTab': true
}
})
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -10,6 +10,9 @@ import type {
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,278 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
interface ViewFile {
body?: Buffer | string
contentType?: string
}
type ViewFilesByName = Readonly<Record<string, ViewFile>>
const transparentPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
'base64'
)
const alphaJob = createRouteMockJob({
id: 'alpha',
create_time: routeMockJobTimestamp - 1_000,
execution_start_time: routeMockJobTimestamp - 1_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'alpha.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
const betaJob = createRouteMockJob({
id: 'beta',
create_time: routeMockJobTimestamp - 2_000,
execution_start_time: routeMockJobTimestamp - 2_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'beta.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
}
})
const multiOutputJob = createRouteMockJob({
id: 'multi-output',
create_time: routeMockJobTimestamp - 3_000,
execution_start_time: routeMockJobTimestamp - 3_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'multi-output-a.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
const multiOutputJobDetail: JobDetail = {
...multiOutputJob,
outputs: {
'3': {
images: [
{
filename: 'multi-output-a.png',
subfolder: '',
type: 'output'
},
{
filename: 'multi-output-b.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
const viewFiles = {
'alpha.png': {},
'beta.png': {},
'imported.png': {},
'multi-output-a.png': {},
'multi-output-b.png': {}
}
async function mockInputFiles(page: Page, files: readonly string[]) {
await page.route('**/internal/files/input**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({ json: [...files] })
})
}
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
await page.route('**/api/view**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
json: { error: 'Missing filename' } satisfies { error: string }
})
return
}
const file = filesByName[filename]
if (!file) {
await route.fulfill({
status: 404,
json: {
error: `Unknown filename: ${filename}`
} satisfies { error: string }
})
return
}
await route.fulfill({
body: file.body ?? transparentPng,
contentType: file.contentType ?? 'image/png'
})
})
}
test.describe('FE-130 assets sidebar route mocks', () => {
test.beforeEach(async ({ jobsRoutes, page }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
})
test('renders generated and imported assets with image previews', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
await expect(tab.getAssetCardByName('beta')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'alpha.png' })
).toHaveJSProperty('naturalWidth', 1)
await tab.switchToImported()
await expect(tab.getAssetCardByName('imported')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'imported.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('opens previews for generated and imported images', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'alpha.png'
})
).toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
await tab.switchToImported()
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'imported.png'
})
).toBeVisible()
})
test('shows footer actions for single and multiple generated selections', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
await comfyPage.page.keyboard.down('Control')
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})
test('loads full generated job outputs from job detail', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await jobsRoutes.mockJobsHistory([multiOutputJob])
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
await comfyPage.setup()
await tab.open()
await tab
.getAssetCardByName('multi-output-a')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('deletes a generated output asset through explicit history refresh', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
const deleteRequests = await jobsRoutes.mockDeleteHistory()
await jobsRoutes.mockJobsHistory([betaJob])
await tab.getAssetCardByName('alpha').click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
await comfyPage.confirmDialog.delete.click()
await expect.poll(() => deleteRequests).toHaveLength(1)
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
await expect(comfyPage.toast.toastSuccesses).toContainText(
'Asset deleted successfully'
)
})
})

View File

@@ -0,0 +1,274 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const historyJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'history-completed',
status: 'completed',
create_time: routeMockJobTimestamp - 60_000,
execution_start_time: routeMockJobTimestamp - 60_000,
execution_end_time: routeMockJobTimestamp - 55_000,
preview_output: {
filename: 'completed-output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createRouteMockJob({
id: 'history-failed',
status: 'failed',
create_time: routeMockJobTimestamp - 120_000,
execution_start_time: routeMockJobTimestamp - 120_000,
execution_end_time: routeMockJobTimestamp - 118_000,
outputs_count: 0,
execution_error: {
node_id: '1',
node_type: 'SaveImage',
exception_message: 'Intentional fixture failure',
exception_type: 'Error',
traceback: [],
current_inputs: {},
current_outputs: {}
}
}),
createRouteMockJob({
id: 'history-cancelled',
status: 'cancelled',
create_time: routeMockJobTimestamp - 180_000,
execution_start_time: routeMockJobTimestamp - 180_000,
execution_end_time: routeMockJobTimestamp - 179_000,
outputs_count: 0
})
]
const activeJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'queue-running',
status: 'in_progress',
create_time: routeMockJobTimestamp - 10_000,
execution_start_time: routeMockJobTimestamp - 9_000,
execution_end_time: null,
outputs_count: 0
}),
createRouteMockJob({
id: 'queue-pending',
status: 'pending',
create_time: routeMockJobTimestamp - 5_000,
execution_start_time: null,
execution_end_time: null,
outputs_count: 0
})
]
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
initialJobsScenario: JobsScenario
mockInitialJobsScenario: void
}>({
initialJobsScenario: [
{ history: historyJobs, queue: activeJobs },
{ option: true }
],
mockInitialJobsScenario: [
async ({ jobsRoutes, initialJobsScenario }, use) => {
await jobsRoutes.mockJobsScenario(initialJobsScenario)
await use()
},
{ auto: true }
]
})
async function openJobHistorySidebar(comfyPage: ComfyPage) {
await comfyPage.page
.getByTestId(TestIds.sidebar.toolbar)
.getByRole('button', { name: 'Job History', exact: true })
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
}
function jobRow(comfyPage: ComfyPage) {
const list = comfyPage.page.getByTestId(TestIds.queue.jobAssetsList)
return (jobId: string) => list.locator(`[data-job-id="${jobId}"]`)
}
function jobHistorySidebar(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.queue.jobHistorySidebar)
}
function clearQueueButton(comfyPage: ComfyPage) {
return jobHistorySidebar(comfyPage).getByRole('button', {
name: 'Clear queue',
exact: true
})
}
async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
await jobHistorySidebar(comfyPage)
.getByLabel(/More options/i)
.click()
await comfyPage.page.getByTestId(TestIds.queue.clearHistoryAction).click()
}
test.describe('Job history sidebar', { tag: '@ui' }, () => {
test.describe('docked overlay action', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
test('opens from the queue overlay docked history action', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
})
})
test.describe('expanded history tab', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
test('shows terminal and active job states', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
test('filters completed and failed history jobs', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
test('searches by job id and output filename', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
})
await clearQueueButton(comfyPage).click()
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
})
})
test.describe('without pending queue jobs', () => {
test.use({
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
initialSettings: { 'Comfy.Queue.QPOV2': true }
})
test('disables clear queue', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await expect(clearQueueButton(comfyPage)).toBeDisabled()
})
})
})

View File

@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -607,3 +607,218 @@ test.describe(
)
}
)
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})

View File

@@ -632,3 +632,72 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
})
})
})
test(
'link interactions',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const seedSlot = ksampler.getSlot('seed')
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
await test.step('Make second INT typed connection', async () => {
const toPos = await seedIOSlot.getOpenSlotPosition()
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
await expect.poll(isConnected).toBe(true)
})
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
await stepsSlot.hover()
await stepsSlot.click({ trial: true })
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
})
//cancel link operation
await stepsSlot.hover()
await comfyPage.page.mouse.up()
})
await ksampler.title.hover()
const slotParent = stepsSlot.locator('../..')
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
'opacity',
'0'
)
await test.step('Connect I/O to node with snap', async () => {
const hasSnap = () =>
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
expect(await hasSnap()).toBe(false)
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()
//move hover off the slot
await ksampler.title.hover()
})
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
'opacity',
'0'
)
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
const test = mergeTests(comfyPageFixture, templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage }) => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -26,53 +26,37 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
await templateApi.mockThumbnails()
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -86,45 +70,38 @@ test.describe(
})
test('filtered count reflects distribution + model filter together', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -144,36 +121,29 @@ test.describe(
})
test('desktop-only templates never leak into DOM on cloud distribution', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -200,28 +170,21 @@ test.describe(
})
test('templates without includeOnDistributions are visible on cloud', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -234,39 +197,32 @@ test.describe(
})
test('clear filters button resets to correct distribution-filtered total', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()

View File

@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1133,3 +1133,108 @@ test.describe(
})
}
)
test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
test('should keep widget-input link aligned after persisted-workflow reload', async ({
comfyPage
}) => {
test.setTimeout(30000)
await comfyPage.workflow.loadWorkflow(
'vueNodes/ksampler-denoise-widget-link'
)
await comfyPage.vueNodes.waitForNodes(2)
await comfyPage.workflow.waitForDraftPersisted()
await comfyPage.workflow.reloadAndWaitForApp()
await comfyPage.vueNodes.waitForNodes(2)
const ksampler = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
if (!node) return null
const findIndex = (name: string) =>
node.inputs.findIndex(
(input) => input.name === name || input.widget?.name === name
)
return {
id: node.id,
denoiseIndex: findIndex('denoise'),
schedulerIndex: findIndex('scheduler')
}
})
if (!ksampler) {
throw new Error('KSampler should be present in fixture')
}
expect(
ksampler.denoiseIndex,
'denoise input slot not found'
).toBeGreaterThanOrEqual(0)
expect(
ksampler.schedulerIndex,
'scheduler input slot not found'
).toBeGreaterThanOrEqual(0)
const denoiseSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.denoiseIndex,
true
)
const schedulerSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.schedulerIndex,
true
)
await expectVisibleAll(denoiseSlot, schedulerSlot)
await expect
.poll(() =>
getInputLinkDetails(comfyPage.page, ksampler.id, ksampler.denoiseIndex)
)
.toMatchObject({
targetId: ksampler.id,
targetSlot: ksampler.denoiseIndex
})
// If the regression returns, getInputPos stays stale relative to the
// grown slot DOM and the endpoint drifts toward scheduler. Re-read
// positions each retry so layout settle doesn't cause flakes.
await expect(async () => {
const linkEnd = await comfyPage.page.evaluate(
([nodeId, targetSlotIndex]) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) return null
const slotPos = node.getInputPos(targetSlotIndex)
const [cx, cy] = window.app!.canvas.ds.convertOffsetToCanvas([
slotPos[0],
slotPos[1]
])
const rect = window.app!.canvas.canvas.getBoundingClientRect()
return { x: cx + rect.left, y: cy + rect.top }
},
[ksampler.id, ksampler.denoiseIndex] as const
)
expect(linkEnd, 'link endpoint should resolve').not.toBeNull()
const denoiseCenter = await getCenter(denoiseSlot)
const schedulerCenter = await getCenter(schedulerSlot)
const distToDenoise = Math.hypot(
linkEnd!.x - denoiseCenter.x,
linkEnd!.y - denoiseCenter.y
)
const rowGap = Math.hypot(
denoiseCenter.x - schedulerCenter.x,
denoiseCenter.y - schedulerCenter.y
)
// Bound at rowGap / 4 - half the inter-slot midpoint, so any drift
// toward scheduler fails well before reaching it.
expect(
distToDenoise,
`Link endpoint (${linkEnd!.x.toFixed(1)}, ${linkEnd!.y.toFixed(1)}) is ` +
`${distToDenoise.toFixed(1)}px from denoise — should be within ` +
`${(rowGap / 4).toFixed(1)}px (quarter of inter-slot gap ${rowGap.toFixed(1)}px)`
).toBeLessThan(rowGap / 4)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -75,6 +75,24 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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