Compare commits

...

26 Commits

Author SHA1 Message Date
Alexander Brown
efdbcb8afa Merge branch 'main' into glary/oxlint-func-style 2026-05-20 11:47:23 -07: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
Glary-Bot
a95b9ab3fb fix: convert two arrow expressions introduced after main merge
Two new const-arrow expressions landed via the merged subgraph IO PR
in browser_tests/tests/subgraph/subgraphSlots.spec.ts; convert them
to function declarations to satisfy func-style.
2026-05-20 17:55:13 +00:00
Alexander Brown
96ba23e2f3 Merge branch 'main' into glary/oxlint-func-style 2026-05-20 10:38:09 -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
Glary-Bot
d6291de715 fix: preserve type narrowing for nested fn in linkInteraction spec
Codemod converted const-arrow findIndex into a function declaration,
which loses the outer 'if (!node) return null' narrowing of node when
the inner function captures it. Capture into a typed local first.
2026-05-20 17:15:12 +00: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
Glary-Bot
df578b198b fix: drop unused GetStaticPaths imports from astro pages
Codemod converted typed const-arrow getStaticPaths into untyped function
declarations, leaving the GetStaticPaths import unused in 7 astro pages.
2026-05-20 05:58:13 +00:00
Glary-Bot
3e56bc925f lint: enable oxlint func-style rule and convert function expressions
Enables eslint/func-style in oxlint with declaration mode to enforce
function declarations over function expressions and arrow expressions
assigned to variables. Vendored litegraph is excluded via override.

Converts existing function expressions and variable-initialized arrow
functions to function declarations across src/, browser_tests/, apps/,
packages/, and scripts/. Adjusts a handful of let-reassignable callback
placeholders, narrowed variable patterns, and typed widget constructors
to keep type safety intact.

Pre-existing type-aware oxlint errors (no-console, no-floating-promises,
no-explicit-any) are unchanged from main.
2026-05-20 05:44:45 +00: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
773 changed files with 8275 additions and 4425 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

@@ -28,6 +28,7 @@
],
"rules": {
"no-async-promise-executor": "off",
"func-style": ["error", "declaration"],
"no-console": [
"error",
{
@@ -124,6 +125,12 @@
"no-console": "allow"
}
},
{
"files": ["src/lib/litegraph/**"],
"rules": {
"func-style": "off"
}
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],

View File

@@ -47,7 +47,7 @@ setup((app) => {
})
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
export function withTheme(Story: StoryFn, context: StoryContext) {
const theme = context.globals.theme || 'light'
// Apply theme class to document root

View File

@@ -40,7 +40,7 @@ setup((app) => {
app.use(ToastService)
})
export const withTheme = (Story: StoryFn, context: StoryContext) => {
export function withTheme(Story: StoryFn, context: StoryContext) {
const theme = context.globals.theme || 'light'
if (theme === 'dark') {
document.documentElement.classList.add('dark-theme')

View File

@@ -58,7 +58,7 @@ const tooltipText = computed(() => {
: t('serverStart.copyAllTooltip')
})
const handleCopy = async () => {
async function handleCopy() {
const existingSelection = terminal.getSelection()
const shouldSelectAll = !existingSelection
if (shouldSelectAll) terminal.selectAll()
@@ -76,7 +76,7 @@ const handleCopy = async () => {
}
}
const showContextMenu = (event: MouseEvent) => {
function showContextMenu(event: MouseEvent) {
event.preventDefault()
electronAPI()?.showContextMenu({ type: 'text' })
}

View File

@@ -44,8 +44,9 @@ const emit = defineEmits<{
const validationState = ref<ValidationState>(ValidationState.IDLE)
const cleanInput = (value: string): string =>
value ? value.replace(/\s+/g, '') : ''
function cleanInput(value: string): string {
return value ? value.replace(/\s+/g, '') : ''
}
// Add internal value state
const internalValue = ref(cleanInput(props.modelValue))
@@ -68,14 +69,14 @@ onMounted(async () => {
await validateUrl(props.modelValue)
})
const handleInput = (value: string | undefined) => {
function handleInput(value: string | undefined) {
// Update internal value without emitting
internalValue.value = cleanInput(value ?? '')
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}
const handleBlur = async () => {
async function handleBlur() {
const input = cleanInput(internalValue.value)
let normalizedUrl = input
@@ -91,7 +92,7 @@ const handleBlur = async () => {
}
// Default validation implementation
const defaultValidateUrl = async (url: string): Promise<boolean> => {
async function defaultValidateUrl(url: string): Promise<boolean> {
if (!isValidUrl(url)) return false
try {
return await checkUrlReachable(url)
@@ -100,7 +101,7 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
}
}
const validateUrl = async (value: string) => {
async function validateUrl(value: string) {
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -127,7 +127,7 @@ const showDialog = ref(false)
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
const showMetricsInfo = () => {
function showMetricsInfo() {
showDialog.value = true
}
</script>

View File

@@ -182,10 +182,12 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
function useFallbackMirror(mirror: UVMirror) {
return {
...mirror,
mirror: mirror.fallbackMirror
}
}
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
@@ -212,7 +214,7 @@ onMounted(async () => {
userIsInChina.value = await isInChina()
})
const validatePath = async (path: string | undefined) => {
async function validatePath(path: string | undefined) {
try {
pathError.value = ''
pathExists.value = false
@@ -246,7 +248,7 @@ const validatePath = async (path: string | undefined) => {
}
}
const browsePath = async () => {
async function browsePath() {
try {
const result = await electron.showDirectoryPicker()
if (result) {
@@ -258,7 +260,7 @@ const browsePath = async () => {
}
}
const onFocus = async () => {
async function onFocus() {
if (!inputTouched.value) {
inputTouched.value = true
return

View File

@@ -92,7 +92,7 @@ const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async (sourcePath: string | undefined) => {
async function validateSource(sourcePath: string | undefined) {
if (!sourcePath) {
pathError.value = ''
return
@@ -109,7 +109,7 @@ const validateSource = async (sourcePath: string | undefined) => {
}
}
const browsePath = async () => {
async function browsePath() {
try {
const result = await electron.showDirectoryPicker()
if (result) {

View File

@@ -82,7 +82,7 @@ const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
const toggle = (event: Event) => {
function toggle(event: Event) {
infoPopover.value?.toggle(event)
}
</script>

View File

@@ -67,7 +67,7 @@ defineProps<{
filter: MaintenanceFilter
}>()
const executeTask = async (task: MaintenanceTask) => {
async function executeTask(task: MaintenanceTask) {
let message: string | undefined
try {
@@ -87,7 +87,7 @@ const executeTask = async (task: MaintenanceTask) => {
}
// Commands
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
async function confirmButton(event: MouseEvent, task: MaintenanceTask) {
if (!task.requireConfirm) {
await executeTask(task)
return

View File

@@ -34,10 +34,10 @@ const buffer = useTerminalBuffer()
let xterm: Terminal | null = null
// Created and destroyed with the Drawer - contents copied from hidden buffer
const terminalCreated = (
function terminalCreated(
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
) {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
terminal.write(props.defaultMessage)
@@ -49,7 +49,7 @@ const terminalCreated = (
terminal.options.disableStdin = true
}
const terminalUnmounted = () => {
function terminalUnmounted() {
xterm = null
}

View File

@@ -55,14 +55,14 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
minRows?: number
onResize?: () => void
}) {
const ensureValidRows = (rows: number | undefined): number => {
function ensureValidRows(rows: number | undefined): number {
if (rows == null || isNaN(rows)) {
return (root.value?.clientHeight ?? 80) / 20
}
return rows
}
const ensureValidCols = (cols: number | undefined): number => {
function ensureValidCols(cols: number | undefined): number {
if (cols == null || isNaN(cols)) {
// Sometimes this is NaN if so, estimate.
return (root.value?.clientWidth ?? 80) / 8
@@ -70,7 +70,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
return cols
}
const resize = () => {
function resize() {
const dims = fitAddon.proposeDimensions()
// Sometimes propose returns NaN, so we may need to estimate.
terminal.resize(

View File

@@ -6,13 +6,17 @@ export function useTerminalBuffer() {
const serializeAddon = new SerializeAddon()
const terminal = markRaw(new Terminal({ convertEol: true }))
const copyTo = (destinationTerminal: Terminal) => {
function copyTo(destinationTerminal: Terminal) {
destinationTerminal.write(serializeAddon.serialize())
}
const write = (message: string) => terminal.write(message)
function write(message: string) {
return terminal.write(message)
}
const serialize = () => serializeAddon.serialize()
function serialize() {
return serializeAddon.serialize()
}
onMounted(() => {
terminal.loadAddon(serializeAddon)

View File

@@ -5,7 +5,7 @@ import { electronAPI } from '@/utils/envUtil'
const electron = electronAPI()
const openUrl = (url: string) => {
function openUrl(url: string) {
window.open(url, '_blank')
return true
}

View File

@@ -124,13 +124,15 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param task Task to get the matching state object for
* @returns The state object for this task
*/
const getRunner = (task: MaintenanceTask) => taskRunners.value.get(task.id)!
function getRunner(task: MaintenanceTask) {
return taskRunners.value.get(task.id)!
}
/**
* Updates the task list with the latest validation state.
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
function processUpdate(validationUpdate: InstallValidation) {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -151,19 +153,19 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
/** Clears the resolved status of tasks (when changing filters) */
const clearResolved = () => {
function clearResolved() {
for (const task of tasks.value) {
getRunner(task).resolved &&= false
}
}
/** @todo Refreshes Electron tasks only. */
const refreshDesktopTasks = async () => {
async function refreshDesktopTasks() {
isRefreshing.value = true
await electron.Validation.validateInstallation(processUpdate)
}
const execute = async (task: MaintenanceTask) => {
async function execute(task: MaintenanceTask) {
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()

View File

@@ -7,7 +7,7 @@ import { electronAPI } from './envUtil'
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export const checkMirrorReachable = async (mirror: string) => {
export async function checkMirrorReachable(mirror: string) {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
)

View File

@@ -36,7 +36,7 @@ import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
const { id, title, message, buttons } = getDialog(route.params.dialogId)
const handleButtonClick = async (button: DialogAction) => {
async function handleButtonClick(button: DialogAction) {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>

View File

@@ -52,7 +52,7 @@ const electron = electronAPI()
const terminalVisible = ref(false)
const toggleConsoleDrawer = () => {
function toggleConsoleDrawer() {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -47,11 +47,11 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const openGitDownloads = () => {
function openGitDownloads() {
window.open('https://git-scm.com/downloads/', '_blank')
}
const skipGit = async () => {
async function skipGit() {
console.warn('pushing')
const router = useRouter()
await router.push('install')

View File

@@ -8,8 +8,8 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
// Create a mock router for stories
const createMockRouter = () =>
createRouter({
function createMockRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
@@ -23,6 +23,7 @@ const createMockRouter = () =>
}
]
})
}
const meta: Meta<typeof InstallView> = {
title: 'Desktop/Views/InstallView',

View File

@@ -90,7 +90,7 @@ const currentStep = ref('1')
/** Forces each install step to be visited at least once. */
const highestStep = ref(0)
const handleStepChange = (value: string | number) => {
function handleStepChange(value: string | number) {
setHighestStep(value)
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -98,7 +98,7 @@ const handleStepChange = (value: string | number) => {
})
}
const setHighestStep = (value: string | number) => {
function setHighestStep(value: string | number) {
const int = typeof value === 'number' ? value : parseInt(value, 10)
if (!isNaN(int) && int > highestStep.value) highestStep.value = int
}
@@ -123,7 +123,7 @@ const canProceed = computed(() => {
})
// Navigation methods
const goToNextStep = () => {
function goToNextStep() {
const nextStep = (parseInt(currentStep.value) + 1).toString()
currentStep.value = nextStep
setHighestStep(nextStep)
@@ -132,7 +132,7 @@ const goToNextStep = () => {
})
}
const goToPreviousStep = () => {
function goToPreviousStep() {
const prevStep = (parseInt(currentStep.value) - 1).toString()
currentStep.value = prevStep
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -142,7 +142,7 @@ const goToPreviousStep = () => {
const electron = electronAPI()
const router = useRouter()
const install = async () => {
async function install() {
if (!device.value) return
const options: InstallOptions = {

View File

@@ -35,12 +35,14 @@ const validationState: ValidationState = {
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
function createMockElectronAPI() {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
function getValidationUpdate() {
return {
...validationState
}
}
return {
getPlatform: () => 'darwin',
@@ -76,7 +78,7 @@ const createMockElectronAPI = () => {
}
}
const ensureElectronAPI = () => {
function ensureElectronAPI() {
const globalWindow = window as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()

View File

@@ -183,7 +183,7 @@ const unsafeReasonText = computed(() => {
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
async function completeValidation() {
const isValid = await electron.Validation.complete()
if (!isValid) {
toast.add({
@@ -194,7 +194,7 @@ const completeValidation = async () => {
}
}
const toggleConsoleDrawer = () => {
function toggleConsoleDrawer() {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -67,7 +67,9 @@ const electron = electronAPI()
const basePath = ref<string | null>(null)
const sep = ref<'\\' | '/'>('/')
const restartApp = (message?: string) => electron.restartApp(message)
function restartApp(message?: string) {
return electron.restartApp(message)
}
onMounted(async () => {
basePath.value = await electron.getBasePath()

View File

@@ -64,7 +64,7 @@ const allowMetrics = ref(true)
const router = useRouter()
const isUpdating = ref(false)
const updateConsent = async () => {
async function updateConsent() {
isUpdating.value = true
try {
await electronAPI().setMetricsConsent(allowMetrics.value)

View File

@@ -61,19 +61,19 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const openDocs = () => {
function openDocs() {
window.open(
'https://github.com/Comfy-Org/desktop#currently-supported-platforms',
'_blank'
)
}
const reportIssue = () => {
function reportIssue() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const router = useRouter()
const continueToInstall = async () => {
async function continueToInstall() {
await router.push('/install')
}
</script>

View File

@@ -118,7 +118,7 @@ let xterm: Terminal | undefined
/**
* Handles installation stage updates from the desktop
*/
const updateInstallStage = (stageInfo: InstallStageInfo) => {
function updateInstallStage(stageInfo: InstallStageInfo) {
console.warn('[InstallStage.onUpdate] Received:', {
stage: stageInfo.stage,
progress: stageInfo.progress,
@@ -183,17 +183,17 @@ const displayStatusText = computed(() => {
return currentStatusLabel.value
})
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
function updateProgress({ status: newStatus }: { status: ProgressStatus }) {
status.value = newStatus
// Make critical error screen more obvious.
if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false
}
const terminalCreated = (
function terminalCreated(
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
) {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
@@ -206,11 +206,15 @@ const terminalCreated = (
terminal.options.cursorInactiveStyle = 'block'
}
const troubleshoot = () => electron.startTroubleshooting()
const reportIssue = () => {
function troubleshoot() {
return electron.startTroubleshooting()
}
function reportIssue() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const openLogs = () => electron.openLogsFolder()
function openLogs() {
return electron.openLogsFolder()
}
let cleanupInstallStageListener: (() => void) | undefined

View File

@@ -33,7 +33,7 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const router = useRouter()
const navigateTo = async (path: string) => {
async function navigateTo(path: string) {
await router.push(path)
}
</script>

View File

@@ -3,8 +3,9 @@ import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {

View File

@@ -111,7 +111,7 @@ async function measureMarqueeLoopGeometry(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
function setAllTimes(time: number) {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
@@ -119,7 +119,9 @@ async function measureMarqueeLoopGeometry(
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
function readX() {
return tracks.map((track) => track.getBoundingClientRect().x)
}
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(

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

@@ -36,7 +36,9 @@ let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function deptElementId(key: string) {
return `careers-dept-${key}`
}
function pickActiveSection() {
pendingFrame = 0

View File

@@ -58,7 +58,7 @@ onMounted(() => {
})
raw.sort((a, b) => {
const norm = (v: number) => {
function norm(v: number) {
const r = v + Math.PI / 2
return r < 0 ? r + 2 * Math.PI : r
}
@@ -117,7 +117,7 @@ onMounted(() => {
applyToPanel(panels[1], elapsed2)
applyToPanel(panels[2], elapsed3)
const wOf = (elapsed: number) => {
function wOf(elapsed: number) {
const progress = elapsed < PANEL_DURATION ? elapsed / PANEL_DURATION : 1
return lerp(S.w, E.w, ease(progress))
}

View File

@@ -35,7 +35,7 @@ export function useParallax(
const triggerEl = options.trigger?.value
const createAnimations = () => {
function createAnimations() {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)

View File

@@ -29,7 +29,7 @@ function interpolateY(
contentH: number,
vpH: number
) {
const clampedTarget = (i: number) => {
function clampedTarget(i: number) {
const center = buttonCenters[i] ?? 0
return Math.max(-(contentH - vpH), Math.min(0, vpH / 2 - center))
}

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

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../data/cloudNodes'
@@ -9,7 +8,7 @@ import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
export async function getStaticPaths() {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
import ContentSection from '../../components/common/ContentSection.vue'
@@ -7,7 +6,7 @@ import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
@@ -8,7 +7,7 @@ import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

@@ -1,11 +1,10 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return models.map((model) => ({
params: { slug: model.slug }
}))

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../../data/cloudNodes'
@@ -9,7 +8,7 @@ import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
export async function getStaticPaths() {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
import ContentSection from '../../../components/common/ContentSection.vue'
@@ -7,7 +6,7 @@ import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
@@ -8,7 +7,7 @@ import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

@@ -35,8 +35,9 @@ const TICK_MS = 200
function readColors() {
const style = getComputedStyle(document.documentElement)
const get = (name: string, fallback: string): string =>
style.getPropertyValue(name).trim() || fallback
function get(name: string, fallback: string): string {
return style.getPropertyValue(name).trim() || fallback
}
return {
bg: get('--color-primary-comfy-ink', '#211927'),
@@ -59,9 +60,12 @@ function requireElement<T extends Element>(
return el
}
const isSVGSVG = (el: Element): el is SVGSVGElement =>
el instanceof SVGSVGElement
const isSVGG = (el: Element): el is SVGGElement => el instanceof SVGGElement
function isSVGSVG(el: Element): el is SVGSVGElement {
return el instanceof SVGSVGElement
}
function isSVGG(el: Element): el is SVGGElement {
return el instanceof SVGGElement
}
function isSVGText(el: Element): el is SVGTextElement {
return el instanceof SVGTextElement
}
@@ -127,8 +131,9 @@ function depth(cell: Cell): number {
function roundedPath(pts: [number, number][], radius: number): string {
const n = pts.length
const fmt = (p: readonly [number, number]) =>
`${p[0].toFixed(2)},${p[1].toFixed(2)}`
function fmt(p: readonly [number, number]) {
return `${p[0].toFixed(2)},${p[1].toFixed(2)}`
}
let d = ''
for (let i = 0; i < n; i++) {
const prev = pts[(i - 1 + n) % n]
@@ -206,7 +211,7 @@ function triggerExplosion() {
const cx = ((COLS - ROWS) * STEP_X) / 2
const cy = ((COLS + ROWS - 2) * STEP_Y) / 2
const launchParticle = (i: number, j: number, fill: string): Particle => {
function launchParticle(i: number, j: number, fill: string): Particle {
const [sx, sy] = iso(i, j)
const baseAngle = Math.atan2(sy - cy, sx - cx)
const angle = baseAngle + (Math.random() - 0.5) * 1.2
@@ -239,7 +244,9 @@ function triggerExplosion() {
const DROP_DURATION_MS = 450
const DROP_HEIGHT = 600
let foodDropStart = 0
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3)
}
function foodDropOffset(now = performance.now()): number {
if (!foodDropStart) return 0
@@ -252,7 +259,7 @@ function foodDropOffset(now = performance.now()): number {
const REBIRTH_STAGGER_MS = 90
const REBIRTH_GROW_MS = 260
let rebirthStart = 0
const easeOutBack = (t: number) => {
function easeOutBack(t: number) {
const c1 = 1.70158
const c3 = c1 + 1
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
@@ -271,7 +278,9 @@ function rebirthScaleFor(idx: number, now = performance.now()): number {
const CHOMP_DURATION_MS = 220
const CHOMP_PEAK_SCALE = 1.15
let chompStart = 0
const easeOut = (t: number) => 1 - (1 - t) * (1 - t)
function easeOut(t: number) {
return 1 - (1 - t) * (1 - t)
}
function chompScale(now = performance.now()): number {
if (!chompStart) return 1
@@ -299,7 +308,7 @@ function isAnimating(): boolean {
function ensureAnimationLoop() {
if (animationHandle !== null) return
const tick = () => {
function tick() {
if (
explodeStart &&
performance.now() - explodeStart >= EXPLODE_DURATION_MS
@@ -411,8 +420,12 @@ function updateScoreDisplay() {
scoreBestEl.textContent = String(best)
}
const cellsEqual = (a: Cell, b: Cell) => a.i === b.i && a.j === b.j
const inBounds = (c: Cell) => c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
function cellsEqual(a: Cell, b: Cell) {
return a.i === b.i && a.j === b.j
}
function inBounds(c: Cell) {
return c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
}
function reset() {
const j0 = Math.floor(ROWS / 2)

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

@@ -158,7 +158,7 @@ export class AssetHelper {
statusCode: number,
error: string = 'Internal Server Error'
): Promise<void> {
const handler = async (route: Route) => {
async function handler(route: Route) {
return route.fulfill({
status: statusCode,
json: { error }

View File

@@ -325,7 +325,7 @@ export class AssetsHelper {
await this.page.unroute(pattern, existingHandler)
}
const handler = async (route: Route) => {
async function handler(route: Route) {
await route.fulfill({
status: 200,
contentType: 'application/json',

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

@@ -42,7 +42,7 @@ export async function setupNodeReplacement(
options?: AddEventListenerOptions | boolean
) {
if (type === 'message' && typeof listener === 'function') {
const wrapped = function (this: WebSocket, event: Event) {
function wrapped(this: WebSocket, event: Event) {
const msgEvent = event as MessageEvent
if (typeof msgEvent.data === 'string') {
try {

View File

@@ -11,6 +11,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
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'
@@ -241,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.
@@ -606,7 +618,7 @@ export class SubgraphHelper {
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
const handler = (msg: ConsoleMessage) => {
function handler(msg: ConsoleMessage) {
const text = msg.text()
if (patterns.some((p) => text.includes(p))) {
warnings.push(text)

View File

@@ -150,7 +150,7 @@ export class TemplateHelper {
}
async mockThumbnails(): Promise<void> {
const thumbnailHandler = async (route: Route) => {
async function thumbnailHandler(route: Route) {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',

View File

@@ -1,6 +1,11 @@
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 {
JobStatus,
@@ -9,6 +14,8 @@ import type {
} 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',
@@ -22,7 +29,8 @@ const activeJobStatuses = [
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
@@ -32,7 +40,7 @@ interface JobsListRoute {
responseLimit?: number
}
interface JobsScenario {
export interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
@@ -65,11 +73,9 @@ function hasJobsListPageParams(
)
}
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
return (
url.pathname.endsWith('/api/jobs') &&
hasExactStatuses(url, route.statuses) &&
hasJobsListPageParams(url, route)
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
)
}
@@ -99,9 +105,9 @@ export function createRouteMockJob({
return {
id,
status: 'completed',
create_time: defaultRouteMockJobTimestamp,
execution_start_time: defaultRouteMockJobTimestamp,
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
create_time: routeMockJobTimestamp,
execution_start_time: routeMockJobTimestamp,
execution_end_time: routeMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
@@ -150,7 +156,8 @@ export class JobsRouteMocker {
const response = createJobsListResponse(route)
await this.page.route(
(url) => isJobsListRequest(url, route),
(url) =>
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
@@ -161,6 +168,44 @@ export class JobsRouteMocker {
}
)
}
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, {})
}
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<{
@@ -168,6 +213,5 @@ export const jobsRouteFixture = base.extend<{
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
await page.unrouteAll({ behavior: 'wait' })
}
})

View File

@@ -76,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'
@@ -226,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,252 @@
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> {
function noopResolveResponse() {}
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void =
noopResolveResponse
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

@@ -7,7 +7,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
export function getMiddlePoint(pos1: Position, pos2: Position) {
return {
x: (pos1.x + pos2.x) / 2,
y: (pos1.y + pos2.y) / 2

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

@@ -45,7 +45,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
// Find and set the width on the latent node
const triggerChange = async (value: number) => {
async function triggerChange(value: number) {
return await comfyPage.page.evaluate((value) => {
const node = window.app!.graph!._nodes.find(
(n) => n.type === 'EmptyLatentImage'
@@ -59,7 +59,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Trigger a status websocket message
const triggerStatus = (queueSize: number) => {
function triggerStatus(queueSize: number) {
ws.send(
JSON.stringify({
type: 'status',
@@ -75,7 +75,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Extract the width from the queue response
const getQueuedWidth = async (resp: Promise<Response>) => {
async function getQueuedWidth(resp: Promise<Response>) {
const obj = await (await resp).json()
return obj['__request']['prompt']['5']['inputs']['width']
}

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,16 +5,18 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
function expectedGroupSize(
nodeBounds: Size,
padding: number,
titleHeight: number
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
): Size {
return {
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
}
}
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
@@ -24,7 +26,7 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
async function createNode(comfyPage: ComfyPage) {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
@@ -79,10 +81,10 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
async function groupAroundAllNodesWithPadding(
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
): Promise<Size> {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
@@ -126,15 +128,16 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
function litegraphContextMenu(comfyPage: ComfyPage) {
return comfyPage.page.locator('.litecontextmenu')
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
async function openComboMenu(comfyPage: ComfyPage) {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]

View File

@@ -3,12 +3,14 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const getLocators = (page: Page) => ({
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
})
function getLocators(page: Page) {
return {
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
}
}
const MODES = [
{

View File

@@ -7,7 +7,7 @@ import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
async function getClipNodesDragBox(comfyPage: ComfyPage) {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
@@ -242,11 +242,11 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
const holdDragAt = async (
async function holdDragAt(
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) => {
) {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
@@ -383,8 +383,9 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
function getFrameGap(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
}
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)

View File

@@ -219,7 +219,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const bypassAndPin = async () => {
async function bypassAndPin() {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
@@ -228,14 +228,14 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await afterChange(comfyPage)
}
const collapse = async () => {
async function collapse() {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
const multipleChanges = async () => {
async function multipleChanges() {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')

View File

@@ -133,10 +133,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await node.click('title')
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
function getMode() {
return comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
}
await expect.poll(() => getMode()).toBe(0)

View File

@@ -290,7 +290,7 @@ test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
const confirmDialog = comfyPage.confirmDialog.root
const { incrementButton } = comfyPage.vueNodes.getInputNumberControls(steps)
const dirtyGraphAndSave = async () => {
async function dirtyGraphAndSave() {
await incrementButton.click()
await comfyPage.page.keyboard.press('Control+s')
}

View File

@@ -21,13 +21,14 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const getGroupPositions = () =>
comfyPage.page.evaluate(() =>
function getGroupPositions() {
return comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
}
await expect.poll(getGroupPositions).toHaveLength(2)

View File

@@ -137,7 +137,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name: string, type1: string, type2: string) => {
async function makeGroup(name: string, type1: string, type2: string) {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
@@ -204,7 +204,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
async function expectSingleNode(type: string) {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
@@ -255,13 +255,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
async function isRegisteredLitegraph(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window.LiteGraph!.registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
async function isRegisteredNodeDefStore(comfyPage: ComfyPage) {
await comfyPage.menu.nodeLibraryTab.open()
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
@@ -269,10 +269,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
return groupNodesFolderCt === 1
}
const verifyNodeLoaded = async (
async function verifyNodeLoaded(
comfyPage: ComfyPage,
expectedCount: number
) => {
) {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
@@ -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)
})

View File

@@ -510,7 +510,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const brokenAfter = 'http://127.0.0.1:1/broken2.png'
const pageErrors: Error[] = []
const onPageError = (err: Error) => {
function onPageError(err: Error) {
pageErrors.push(err)
}
comfyPage.page.on('pageerror', onPageError)

View File

@@ -82,10 +82,10 @@ test.describe('Node Interaction', () => {
}
)
const dragSelectNodes = async (
async function dragSelectNodes(
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
) {
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -117,15 +117,16 @@ test.describe('Node Interaction', () => {
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
function getPositions() {
return Promise.all(clipNodes.map((node) => node.getPosition()))
}
async function testDirection({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) => {
}) {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.command.executeCommand(
@@ -671,7 +672,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
})
test('Cursor style changes when panning', async ({ comfyPage }) => {
const getCursorStyle = async () => {
async function getCursorStyle() {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
@@ -703,7 +704,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Properly resets dragging state after pan mode sequence', async ({
comfyPage
}) => {
const getCursorStyle = async () => {
async function getCursorStyle() {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
@@ -878,8 +879,9 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
function generateUniqueFilename(extension = '') {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
}
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
@@ -1077,7 +1079,7 @@ test.describe('Viewport settings', () => {
comfyPage,
comfyMouse
}) => {
const changeTab = async (tab: Locator) => {
async function changeTab(tab: Locator) {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.emptySpace)
@@ -1406,7 +1408,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Cursor changes appropriately in different modes', async ({
comfyPage
}) => {
const getCursorStyle = async () => {
async function getCursorStyle() {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'

View File

@@ -3,14 +3,15 @@ import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
const getGizmoConfig = (page: Page) =>
page.evaluate(() => {
function getGizmoConfig(page: Page) {
return page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const modelConfig = n?.properties?.['Model Config'] as
| { gizmo?: { enabled: boolean; mode: string } }
| undefined
return modelConfig?.gizmo
})
}
test.describe('Load3D Gizmo Controls', () => {
test(

View File

@@ -155,7 +155,7 @@ test.describe('Load3D', () => {
async ({ comfyPage, load3d }) => {
await expect(load3d.uploadBackgroundImageButton).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById(1)
const readBackgroundImage = async () => {
async function readBackgroundImage() {
const properties =
await node.getProperty<Record<string, { backgroundImage?: string }>>(
'properties'
@@ -222,7 +222,7 @@ test.describe('Load3D', () => {
await expect(load3d.gridToggleButton).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById(1)
const readShowGrid = async () => {
async function readShowGrid() {
const properties =
await node.getProperty<Record<string, { showGrid?: boolean }>>(
'properties'

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

@@ -55,7 +55,7 @@ async function setLocaleAndWaitForWorkflowReload(
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
function tick() {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}

View File

@@ -166,10 +166,10 @@ test.describe('Node search box', { tag: '@node' }, () => {
})
test.describe('Filtering', () => {
const expectFilterChips = async (
async function expectFilterChips(
comfyPage: ComfyPage,
expectedTexts: string[]
) => {
) {
const chips = comfyPage.searchBox.filterChips
// Check that the number of chips matches the expected count

View File

@@ -243,15 +243,18 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const switchToDesktop = () =>
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
const switchToMobile = () =>
comfyPage.page.setViewportSize({ width: 360, height: 800 })
const expectExpanded = (value: 'true' | 'false') =>
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
function switchToDesktop() {
return comfyPage.page.setViewportSize({ width: 1280, height: 800 })
}
function switchToMobile() {
return comfyPage.page.setViewportSize({ width: 360, height: 800 })
}
function expectExpanded(value: 'true' | 'false') {
return expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
value
)
}
await switchToDesktop()
await searchBoxV2.open()

View File

@@ -312,7 +312,9 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
function getCount() {
return searchBoxV2.results.count()
}
await searchBoxV2.open()

View File

@@ -758,8 +758,8 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
await comfyPage.nextFrame()
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
function hasContentAtRow(yFraction: number) {
return canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
@@ -769,6 +769,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
}
return false
}, yFraction)
}
await expect
.poll(() => hasContentAtRow(0.25), {

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

@@ -77,7 +77,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}
cloudUploadAssetStateByPage.set(page, state)
const assetsRouteHandler = async (route: Route) => {
async function assetsRouteHandler(route: Route) {
const allAssets = [
cloudDefaultGraphInputAsset,
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
@@ -149,7 +149,7 @@ async function delayNextUpload(comfyPage: ComfyPage) {
releaseUpload = resolve
})
const uploadRouteHandler = async (route: Route) => {
async function uploadRouteHandler(route: Route) {
resolveUploadStarted()
await release
await route.continue()

View File

@@ -15,7 +15,9 @@ const REQUEST_ID_SECONDARY = 2
const REQUEST_ID_MISMATCH = 999
let nextRequestId = 1000
const newRequestId = () => nextRequestId++
function newRequestId() {
return nextRequestId++
}
function bannerLocator(page: Page) {
return page.getByTestId(TestIds.queue.notificationBanner)

View File

@@ -6,11 +6,11 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const mockOptions = ['d', 'c', 'b', 'a']
const addRemoteWidgetNode = async (
async function addRemoteWidgetNode(
comfyPage: ComfyPage,
nodeName: string,
count: number = 1
) => {
) {
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
await tab.getFolder('DevTools').click()
@@ -21,24 +21,24 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
}
}
const getWidgetOptions = async (
async function getWidgetOptions(
comfyPage: ComfyPage,
nodeName: string
): Promise<string[] | undefined> => {
): Promise<string[] | undefined> {
return await comfyPage.page.evaluate((name) => {
const node = window.app!.graph!.nodes.find((node) => node.title === name)
return node!.widgets![0].options.values as string[] | undefined
}, nodeName)
}
const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => {
async function getWidgetValue(comfyPage: ComfyPage, nodeName: string) {
return await comfyPage.page.evaluate((name) => {
const node = window.app!.graph!.nodes.find((node) => node.title === name)
return node!.widgets![0].value
}, nodeName)
}
const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => {
function clickRefreshButton(comfyPage: ComfyPage, nodeName: string) {
return comfyPage.page.evaluate((name) => {
const node = window.app!.graph!.nodes.find((node) => node.title === name)
const buttonWidget = node!.widgets!.find((w) => w.name === 'refresh')

View File

@@ -13,16 +13,21 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
function getColorPickerButton(comfyPage: { page: Page }) {
return comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
}
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
function getColorPickerCurrentColor(comfyPage: { page: Page }) {
return comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerCurrentColor
)
}
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
function getColorPickerGroup(comfyPage: { page: Page }) {
return comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
}
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

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,60 +19,8 @@ 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')
}
// 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()
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')
function openMoreOptions(comfyPage: ComfyPage) {
return openMoreOptionsMenu(comfyPage, 'KSampler')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
@@ -92,11 +41,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

@@ -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

@@ -114,11 +114,12 @@ test.describe('Sidebar splitter width independence', () => {
await dragGutter(comfyPage, 80)
// Check that saved sizes sum to ~100%
const getSidebarSizes = () =>
comfyPage.page.evaluate(() => {
function getSidebarSizes() {
return comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('unified-sidebar')
return raw ? (JSON.parse(raw) as number[]) : null
})
}
await expect
.poll(async () => {

View File

@@ -25,7 +25,7 @@ const MISSING_NODES_SUBGRAPH_NODE_ID = '2'
* the root graph, then the inner subgraph node that appears inside. Matches
* how a user navigates via the canvas.
*/
const enterNestedSubgraphs = async (comfyPage: ComfyPage) => {
async function enterNestedSubgraphs(comfyPage: ComfyPage) {
const outerNode = await comfyPage.nodeOps.getNodeRefById(
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
)

View File

@@ -689,7 +689,9 @@ test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
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)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -735,7 +737,9 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
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)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -812,7 +816,9 @@ test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
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)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()

View File

@@ -368,15 +368,16 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
]
const SENTINEL_IDS = new Set([-1, -10, -20])
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
function isSentinelNodeId(id: number | string): id is number {
return typeof id === 'number' && SENTINEL_IDS.has(id)
}
const checkEndpoint = (
function checkEndpoint(
label: string,
kind: 'origin_id' | 'target_id',
id: number | string,
g: typeof graph
): string | null => {
): string | null {
if (isSentinelNodeId(id)) return null
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
return `${label}: ${kind} ${id} invalid or not found`

View File

@@ -632,3 +632,75 @@ 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 })
function isConnected() {
return 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 () => {
function hasSnap() {
return 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

@@ -14,7 +14,7 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
const assertInSubgraph = async (inSubgraph: boolean) => {
async function assertInSubgraph(inSubgraph: boolean) {
await expect
.poll(() => comfyPage.subgraph.isInSubgraph())
.toBe(inSubgraph)

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()

View File

@@ -7,9 +7,9 @@ test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
const createMockSystemStatsRes = (
function createMockSystemStatsRes(
requiredFrontendVersion: string
): SystemStats => {
): SystemStats {
return {
system: {
os: 'posix',

View File

@@ -79,7 +79,7 @@ async function getNodeGroupCenteringErrors(
const nodeRect = nodeElement.getBoundingClientRect()
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
function getCenteringError(group: GraphGroup): NodeGroupCenteringError {
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
group.pos[0],
group.pos[1]

View File

@@ -1151,12 +1151,14 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
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(
const ksamplerNode = node
function findIndex(name: string) {
return ksamplerNode.inputs.findIndex(
(input) => input.name === name || input.widget?.name === name
)
}
return {
id: node.id,
id: ksamplerNode.id,
denoiseIndex: findIndex('denoise'),
schedulerIndex: findIndex('scheduler')
}

View File

@@ -8,10 +8,10 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
async function getHeaderPos(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> => {
): Promise<{ x: number; y: number; width: number; height: number }> {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
@@ -21,27 +21,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
return box
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
getHeaderPos(comfyPage, 'Load Checkpoint')
async function getLoadCheckpointHeaderPos(comfyPage: ComfyPage) {
return getHeaderPos(comfyPage, 'Load Checkpoint')
}
const expectPosChanged = async (pos1: Position, pos2: Position) => {
async function expectPosChanged(pos1: Position, pos2: Position) {
const diffX = Math.abs(pos2.x - pos1.x)
const diffY = Math.abs(pos2.y - pos1.y)
expect(diffX).toBeGreaterThan(0)
expect(diffY).toBeGreaterThan(0)
}
const deltaBetween = (before: Position, after: Position) => ({
x: after.x - before.x,
y: after.y - before.y
})
function deltaBetween(before: Position, after: Position) {
return {
x: after.x - before.x,
y: after.y - before.y
}
}
const expectSameDelta = (a: Position, b: Position, tol = 2) => {
function expectSameDelta(a: Position, b: Position, tol = 2) {
expect(Math.abs(a.x - b.x)).toBeLessThanOrEqual(tol)
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
async function dragFromTabButton(comfyPage: ComfyPage, button: Locator) {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
@@ -172,7 +175,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const dx = 120
const dy = 80
const clickNodeTitleWithMeta = async (title: string) => {
async function clickNodeTitleWithMeta(title: string) {
await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')

View File

@@ -15,10 +15,11 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = () =>
comfyPage.page.evaluate(
function getWidth() {
return comfyPage.page.evaluate(
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
)
}
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')

View File

@@ -5,11 +5,15 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
function getFirstClipNode(comfyPage: ComfyPage) {
return comfyPage.vueNodes
.getNodeByTitle('CLIP Text Encode (Prompt)')
.first()
}
const getFirstMultilineStringWidget = (comfyPage: ComfyPage) =>
getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
function getFirstMultilineStringWidget(comfyPage: ComfyPage) {
return getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
}
test('should allow entering text', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)

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