Compare commits

..

35 Commits

Author SHA1 Message Date
Connor Byrne
20dcc369dc test(newUserService): drop unneeded global version write
The Vite `define` for `__COMFYUI_FRONTEND_VERSION__` is applied at
transform time in tests too, so the ad-hoc global assignment and
companion @ts-expect-error were unnecessary. All 29 tests still pass.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11728#pullrequestreview-3635064775
2026-05-04 16:06:32 -07:00
Connor Byrne
e0c1c4e994 fix(newUserService): require non-empty V2 draft index for existing-user signal
migrateV1toV2() writes an empty index (`createEmptyIndex()`) at startup
when there is no V1 data, so genuine new users would have the V2 index
key present before initializeIfNewUser() ever runs. Treating key
existence as evidence of prior usage misclassified them as existing.

Now parse the index and require a non-empty `order` or `entries` to
count as draft history. Malformed JSON is treated as no history.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11728#pullrequestreview-3642597247
2026-05-04 16:05:12 -07:00
bymyself
ac7a825fd3 fix: detect V1/V2 draft storage keys in new-user check
`checkIsNewUser()` only checked legacy pre-V1 localStorage keys
(`workflow`, `Comfy.PreviousWorkflow`). A returning user who had only
ever used the V1 or V2 draft persistence system would have neither of
those keys, causing the getting-started tab to appear on every dialog
open after a settings reset.

Now also checks V1 keys (`Comfy.Workflow.Drafts`,
`Comfy.Workflow.DraftOrder`) and the V2 draft index key
(`Comfy.Workflow.DraftIndex.v2:personal`).
2026-05-01 21:30:28 -07:00
Christian Byrne
e831daae59 feat(website): point robots.txt at /sitemap-index.xml + AI crawler rules (#11823)
## Summary

Once
[comfy-router#22](https://github.com/Comfy-Org/comfy-router/pull/22)
ships, `comfy.org/sitemap-index.xml` will return a unified index
aggregating both the website (38 URLs) and workflow-templates sitemaps.
This PR:

1. Reverts `Sitemap:` back to `/sitemap-index.xml` (was `/sitemap-0.xml`
in #11802 as a workaround for the 404).
2. Adds explicit allow records for 21 search and AI/LLM crawlers
(GPTBot, ChatGPT-User, OAI-SearchBot, Google-Extended, ClaudeBot,
Claude-Web, anthropic-ai, PerplexityBot, Perplexity-User,
Applebot-Extended, Bytespider, Amazonbot, CCBot, Meta-ExternalAgent,
Meta-ExternalFetcher, Diffbot, etc.).
3. Adds `Disallow:` for `/_astro/`, `/_website/`, `/_vercel/` — Vercel
build artifacts that aren't useful to crawl.

## Why granular UAs

Stacked `User-agent:` records (per [RFC 9309
§2.2](https://datatracker.ietf.org/doc/html/rfc9309#section-2.2)) share
one rule block. Listing each bot explicitly:

- Signals intent to AI bots that look for their UA in robots.txt before
crawling more aggressively.
- Surfaces our crawl policy clearly to anyone inspecting the file.
- Lets us add per-bot Disallows in future without restructuring.

## Merge order

⚠️ **Do NOT merge until comfy-router#22 is deployed to production.**
Until then, `/sitemap-index.xml` returns 404 and this PR would re-break
the issue PR #11802 patched. Verification:

```bash
curl -sI https://comfy.org/sitemap-index.xml
# expect: HTTP/2 200, x-served-by: worker-sitemap-index
```

Once that returns 200, this is safe to merge.

## Verification (after merge + deploy)

```bash
# robots.txt is served and points at the unified index
curl -s https://comfy.org/robots.txt | grep '^Sitemap:'
# → Sitemap: https://comfy.org/sitemap-index.xml

# Each AI crawler can fetch it
for ua in 'GPTBot/1.0' 'ClaudeBot/1.0' 'PerplexityBot/1.0' 'Google-Extended' 'Applebot-Extended'; do
  curl -s -o /dev/null -w "$ua → %{http_code}\n" -A "$ua" https://comfy.org/robots.txt
done

# Sitemap is reachable from robots.txt
SITEMAP=$(curl -s https://comfy.org/robots.txt | awk -F': ' '/^Sitemap:/ {print $2}')
curl -s "$SITEMAP" | xmllint --noout - && echo "valid XML"
```

## Linear / closes

- Closes FE-437 (AI crawler rules)
- Updates FE-432 — the robots.txt change in #11802 was a workaround
that's no longer needed once #22 ships

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11823-feat-website-point-robots-txt-at-sitemap-index-xml-AI-crawler-rules-3546d73d3650811dbceedd06c00db444)
by [Unito](https://www.unito.io)
2026-05-01 21:04:45 -07:00
Robin Huang
96575fcec9 feat: redesign cloud onboarding survey for ICP and persona signal (#11628)
## Summary

Replaces the 4-step Cloud onboarding survey with a 7-step flow that
captures both ICP attributes and user persona dimensions. The survey
questions are now populated dynamically from remoteConfig.

## Changes

- **What**: New survey questions — Usage, Familiarity, Role, Team size,
Industry, Making, Source. Role / Team size / Industry are gated to
"Work" usage; Education users see a Student / Educator short list for
Role. Most option lists are randomized per visit (familiarity and team
size stay ordered as ordinals). \`SurveyResponses\` extended with
optional \`usage\`, \`role\`, \`teamSize\`, \`source\` fields.
- **Breaking**: None — \`useCase\` and \`workflowRelationship\` remain
optional in the type and existing telemetry normalization keeps working
unchanged.

## Review Focus

- The \`role\` step has a function-form \`options\` so the list can swap
based on \`usage\`. \`steps\` is a computed that filters by
\`showWhen()\` and resolves the option function — verify reactivity when
\`usage\` changes.
- Changing \`usage\` clears the previously-picked \`role\` via a watcher
to prevent a stale value from carrying over between Work / Education
modes.
- Per-visit shuffle is stable: option lists are passed through
\`randomize()\` once at module load, not on every render.

## Screenshots


https://github.com/user-attachments/assets/3602a388-50dc-401e-ada9-ea9016c5052d


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11628-feat-redesign-cloud-onboarding-survey-for-ICP-and-persona-signal-34d6d73d365081f4a792cfe76a987ffb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-05-01 23:25:17 +00:00
Kelly Yang
e7e1ae25a6 fix(load3d): suppress error toast on 404 when loading output model file (#11807)
## Summary

- Adds `silentOnNotFound` option to `LoadModelOptions` interface,
threaded through `Load3d.loadModel` → `LoaderManager.loadModel`
- 404 errors (detected via message text or `response.status`) are
silently swallowed when `silentOnNotFound: true`; all other errors still
surface a toast
- Sets `silentOnNotFound: true` for output-folder loads in `load3d.ts`
and `saveMesh.ts` — covers shared workflows opened on a machine that
never ran them

## Test plan

- [x] `LoaderManager.test.ts` — 40 unit tests covering 404 suppression,
non-404 still toasts, stale load handling
- [x] `Load3DConfiguration.test.ts` — 4 unit tests verifying
`silentOnNotFound` propagates correctly through `configureForSaveMesh`
and `configure`
- [x] `load3d.spec.ts` — 2 E2E tests: 404 → no toast, 500 → toast
appears

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes error-handling behavior in the 3D model loading pipeline and
extends method signatures/options; risk is mainly missed call sites or
incorrectly classifying non-404 errors as 404 and hiding real failures.
> 
> **Overview**
> Prevents noisy user-facing toasts when an *output* 3D model referenced
by `Preview3D`/`SaveGLB` is missing locally by adding a
`silentOnNotFound` flag and suppressing the "Error loading model" toast
specifically for HTTP 404 failures.
> 
> Threads the new `LoadModelOptions` through `Load3d.loadModel` →
`LoaderManager.loadModel` and updates `Load3DConfiguration`/callers to
opt in for output-folder loads, with new unit + Playwright coverage (404
stays silent, non-404 still toasts).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
049f75ef60. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11807-fix-load3d-suppress-error-toast-on-404-when-loading-output-model-file-3536d73d36508129ac0de1d5b081dcf0)
by [Unito](https://www.unito.io)
2026-05-01 18:49:31 -04:00
Yourz
4ed00cec08 update: robots.txt to point to /sitemap-0.xml (#11802)
## Summary

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

robots.txt at [comfy.org](https://comfy.org/) references
/sitemap-index.xml which returns 404. The actual working sitemap is at
/sitemap-0.xml (200, 38 URLs). This blocks search engines from
discovering the sitemap.

## Changes

- **What**: <!-- Core functionality added/modified -->
  - Update robots.txt to point to /sitemap-0.xml, OR

## Review Focus

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

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

## Screenshots (if applicable)

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

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11802-update-robots-txt-to-point-to-sitemap-0-xml-3536d73d365081bb9545eb96dd1e8025)
by [Unito](https://www.unito.io)
2026-05-01 19:36:22 +00:00
Alexander Brown
f566abdd6e ci: extract changes-filter composite action; fix docs-only PR stall (#11785)
## Summary

Extract a `.github/actions/changes-filter` composite action and adopt it
across path-gated CI workflows, fixing the docs-only PR stall and
removing duplicated `paths:` / `paths-ignore:` filtering across 8
workflows.

## Background

Docs-only PRs stalled on required status checks because workflows using
`paths-ignore: ['**/*.md']` never created a check run, while branch
protection still required it. Observed on #11776 (the `test` check from
`ci-tests-unit.yaml` never appeared).

The fix pattern: keep the workflow triggered, gate downstream jobs on a
`changes` job whose outputs are computed from a path filter. Skipped
jobs count as passing under branch protection.

## What the action emits

| Output | Meaning |
|---|---|
| `should-run` | Any file outside `apps/`, `docs/`, `.storybook/`,
`**/*.md` changed. |
| `app-website-changes` | Shared deps or `apps/website/**` changed. |
| `app-desktop-changes` | Shared deps or `apps/desktop-ui/**` changed. |
| `app-frontend-changes` | Shared deps or `src/**` changed. |
| `packages-changes` | Shared deps or `packages/**` changed. |
| `storybook-changes` | Shared deps or `.storybook/**` changed. |
| `docs-changes` | `docs/**` or any `**/*.md` changed (deps NOT folded
in). |
| `dependency-changes` | Root `package.json`, `pnpm-lock.yaml`, or
`pnpm-workspace.yaml` changed. |

Shared deps are folded into every `app-*`, `packages-changes`, and
`storybook-changes` output so a lockfile bump correctly invalidates each
granular gate. Outputs default to `'true'` for non-`pull_request` events
to avoid the silent-skip footgun on push / merge_group.

## Workflows migrated

| Workflow | Gate | Notes |
|---|---|---|
| `ci-tests-unit.yaml` | `should-run` | Required check (`test`). Fixes
the original stall. |
| `ci-tests-e2e.yaml` | `should-run` | Required check (`e2e-status`).
Replaces inline filter. |
| `ci-perf-report.yaml` | `should-run` | Removes `paths-ignore`. |
| `ci-website-build.yaml` | `app-website-changes \|\| packages-changes`
| Refactor — not a required check, but moves to job-level gating. Filter
scope broadens from `packages/{design-system}` to all `packages/**`
(strictly safer). |
| `ci-website-e2e.yaml` | `app-website-changes \|\| packages-changes` |
Same restructure; `post-starting-comment` also gated to avoid spurious
"tests are running" when E2E is skipped. |
| `ci-dist-telemetry-scan.yaml` | `should-run` | New gate; was
previously running on every PR including docs-only. |
| `ci-oss-assets-validation.yaml` | `should-run` | Same. |
| `ci-size-data.yaml` | `should-run` | Preserves existing repository
guard on the new `changes` job. |
| `ci-tests-storybook.yaml` | `storybook-changes \|\|
app-frontend-changes \|\| packages-changes` | Gates 4 of 6 jobs.
`deploy-production` (push to main) left ungated;
`update-comment-with-chromatic` cascades naturally. |

## Branch protection (verified)

Required status checks on `main` and `core/**`/`cloud/**`: `test`,
`lint-and-format`, `e2e-status`. Only `test` and `e2e-status` use the
composite — `lint-and-format` correctly stays unfiltered (must run on
docs/apps too). The other 6 migrations are refactor wins (less wasted CI
on docs/apps-only PRs), not stall fixes.

## Changes

- **What**: New `.github/actions/changes-filter` composite + 8 workflow
migrations to consume it.
- **Breaking**: None.
- **Dependencies**: New pin on `dorny/paths-filter@de90cc6` — already
covered by `ci-validate-action-pins`.

## Review Focus

- The `should-run` filter excludes `.storybook/**` (granular
`storybook-changes` covers it instead). Storybook's gate combines all
three: `storybook-changes || app-frontend-changes || packages-changes`.
- Two `dorny/paths-filter` steps inside the composite —
`predicate-quantifier=every` is required for the negated globs in
`should-run` but breaks the multi-pattern OR filters.
- The website filter scope intentionally broadens from
`packages/{design-system,tailwind-utils}/**` to all `packages/**` for
consistency and safety.

Fixes #11776

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11785-ci-extract-changes-filter-composite-action-fix-docs-only-PR-stall-3526d73d36508172a1d7fe8c30fa6453)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 18:06:29 +00:00
pythongosssss
3c5695fd42 test: add Vue node error/validation ring e2e coverage (#11727)
## Summary

Add additional test coverage for vue node errors

## Changes

- **What**: 
- add tests for showing error on missing node, execution error,
validation failure & resolved on fix
- move ErrorsTabHelper to fixtures dir & update refs
- add SLOW_MO env var for headed local tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11727-test-add-Vue-node-error-validation-ring-e2e-coverage-3506d73d365081069ff8f70f7970dd55)
by [Unito](https://www.unito.io)
2026-05-01 09:35:29 +00:00
Dante
4fff0c4b49 fix: report total file count, not job count, in ZIP export toast (#11737)
## Summary

ZIP export toast now reports the total number of files instead of the
number of selected jobs when any selected job has multiple outputs.

## Changes

- **What**: In `downloadMultipleAssetsAsZip`
(`src/platform/assets/composables/useMediaAssetActions.ts`), compute
`fileCount` by summing each asset's `outputCount` metadata (fallback 1)
and pass it to `mediaAsset.selection.exportStarted` instead of
`assets.length`. The existing i18n string already handles `file`/`files`
plural.
- **Tests**: 3 new unit tests in `useMediaAssetActions.test.ts` covering
multi-output, single-output fallback, and mixed selections. The
`useToast` and `useI18n` mocks were lifted to hoisted refs so toast call
args are assertable.

## Review Focus

- Reduce uses `count > 1 ? count : 1`, mirroring the
`hasMultiOutputJobs` gate above so a known `outputCount === 1` is still
counted as 1 file (no double-counting).
- Only `downloadMultipleAssetsAsZip` is touched; OSS individual-download
path and direct-download path are unchanged.

Fixes #11736

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11737-fix-report-total-file-count-not-job-count-in-ZIP-export-toast-3516d73d3650811ba78dfdb0a0ae8ea1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 09:28:01 +00:00
pythongosssss
69dca2d600 test: add test coverage for workflow save settings (#11763)
## Summary

Add tests for autosave/delay/node sort on save

## Changes

- **What**: 
- add tests

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11763-test-add-test-coverage-for-workflow-save-settings-3516d73d365081d1b57bc1cf4b2e2ece)
by [Unito](https://www.unito.io)
2026-05-01 09:09:31 +00:00
Kelly Yang
004530b23a fix: search bar layout and autocomplete clipping on Desktop at small sizes (#11713)
## Summary

Fixes two visual bugs in the Desktop app at small window sizes: the
search bar getting pushed/clipped in modal headers, and autocomplete
suggestion dropdowns being cut off by `overflow-hidden` ancestors.

## Changes

- **`SearchAutocomplete.vue`**: Wrap `ComboboxContent` in
`ComboboxPortal` so the suggestions dropdown teleports to `<body>`,
escaping `overflow-hidden` ancestors (fixes z-index clipping in Manager
dialog and other modals using `BaseModalLayout`)
- **`BaseModalLayout.vue`**: Replace `shrink-0` with `min-w-0` on the
header content container so the search bar can shrink at narrow window
sizes instead of overflowing and being clipped by the modal root's
`overflow-hidden`
- **`GraphCanvas.vue`**: Fix dead code where the native drag
(`app-drag`) div was nested inside a `v-if="workflowTabsPosition ===
'Topbar'"` block with its own mutually exclusive condition — move it
before the block and add `pointer-events-auto` so Desktop window
dragging works when tabs are in Sidebar position

## Why no E2E tests

- **`SearchAutocomplete` portal**: The fix is structural (teleport to
`<body>`). A meaningful regression test would require opening the
Manager dialog with a real or mocked extension list — that is a
substantial standalone effort tracked in #11714.
- **`BaseModalLayout` header shrink**: A viewport-resize assertion would
test CSS layout behaviour, not application logic; it would be fragile
and low-value.
- **`GraphCanvas` app-drag**: Desktop/Electron-only.
`-webkit-app-region: drag` cannot be exercised in headless Chromium.

Unit tests for `SearchAutocomplete` cover the new code paths (portal
rendering, suggestion display, item selection).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk UI-only changes: adjusts layout CSS and combobox rendering
via `ComboboxPortal`, plus adds unit tests; no business logic or data
flow changes.
> 
> **Overview**
> Fixes small-window Desktop UI issues where modal-header search inputs
could be clipped and autocomplete dropdowns could be cut off by
`overflow-hidden` ancestors.
> 
> `SearchAutocomplete` now renders its suggestions list inside a
`ComboboxPortal` (teleporting the popper content outside clipping
containers) and adds a focused unit test suite covering empty/non-empty
suggestions, selection behavior, and `optionLabel` handling.
> 
> `BaseModalLayout` tweaks header flexbox constraints (`min-w-0` on the
header content container) to allow the search bar to shrink instead of
overflowing.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd32d960f9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-04-30 23:22:04 -07:00
Alexander Brown
73d4e24ffa revert: roll back #10849 + #11697 (per-instance promoted widget values) (#11790)
## Summary

Reverts #10849 (per-instance promoted widget value storage) and its
companion test-pinning PR #11697. The fix in #10849 caused regressions
in promoted-widget serialization (notably the Z-Image-Turbo template,
see #10146 follow-up). A replacement fix is being developed on
`fix/subgraph-promoted-widget-inline-state` and will land separately.

## Changes

- **Revert #11697** — drops the `it.fails`-marked tests that pin the
#10849 corruption symptom. With #10849 reverted, those markers would
falsely flip to passing.
- **Revert #10849** — removes per-instance `_instanceWidgetValues` map,
`_pendingWidgetsValues` configure-time hydration, the `widgets_values`
write path in `SubgraphNode.serialize()`, the `sourceSerialize` field on
`PromotedWidgetView`, the multi-instance Vitest suite, and the
multi-instance E2E test + asset.
- **Conflict resolution** in
`browser_tests/tests/subgraph/subgraphSerialization.spec.ts`: kept the
restored test coverage from #11579 (which is post-#10849 on main) and
removed only the now-unreachable multi-instance test, its helper, and
its workflow constant. Auto-merge with #11698 (`incrementVersion`) and
#11699 (ID type aliases) was clean.

## Review Focus

- Confirm no other on-main code path has come to depend on
`PromotedWidgetView.sourceSerialize` or
`SubgraphNode._instanceWidgetValues` since #10849 (grep is clean
locally).
- Confirm we want to land this revert before the replacement fix on
`fix/subgraph-promoted-widget-inline-state` is ready — this leaves the
original #10146 (multi-instance widget value collision) unfixed in the
meantime.
- The retained #11579 test coverage now exercises pre-#10849 behavior;
some of those assertions were written expecting the #10849 code path. CI
will surface any that need adjustment.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11790-revert-roll-back-10849-11697-per-instance-promoted-widget-values-3536d73d3650814094abd58b6b712d8d)
by [Unito](https://www.unito.io)
2026-05-01 03:37:20 +00:00
Kelly Yang
09790bd7f3 test: add unit tests for useLayoutMutations (#11313)
## Summary
A follow-up PR of
https://github.com/Comfy-Org/ComfyUI_frontend/issues/11106.
This PR only focus on `layoutMutations.ts`.
`layoutMutations.ts` is the central API for all node layout mutations in
the Vue renderer. It previously had zero test coverage despite
containing non-trivial logic such as guard clauses, ID normalization,
and z-index scanning. This PR addresses issue #11106 to prevent silent
regressions in node positioning and lifecycle.

## What was tested and how
All tests use the real `layoutStore` singleton (no mocks).
`initializeFromLiteGraph` resets node state before each test, and
results are verified via `getNodeLayoutRef().value`.

| Method | Tests | Logic covered |
| :--- | :--- | :--- |
| `moveNode` | 3 | **Guard** (missing node -> no-op); position written
to store; numeric ID coerced to string |
| `resizeNode` | 2 | **Guard**; size written to store |
| `setNodeZIndex` | 2 | **Guard**; zIndex written to store |
| `createNode` | 1 | Node becomes readable with the provided position
and size |
| `deleteNode` | 2 | **Guard**; node removed from store |
| `batchMoveNodes` | 4 | Empty array -> no-op; multiple nodes updated
**atomically**; existing size preserved; missing nodes skipped while
valid ones still update |
| `bringNodeToFront` | 1 | Target node ends up with a **higher zIndex**
than all other nodes |

## What was not tested and why

| Method | Reason skipped |
| :--- | :--- |
| `createLink` / `deleteLink` | `layoutStore` exposes no public API to
query link existence by ID; methods contain no logic beyond a straight
`applyOperation` call. |
| `createReroute` / `deleteReroute` / `moveReroute` | Same reason as
above. |
| `setSource` / `setActor` | Single-line delegation to `layoutStore`; no
logic to test. |
| Default value behavior in `createNode` | Avoids "change-detector"
tests; asserting hardcoded defaults adds no regression value and would
block valid product changes. |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only changes; no production logic is modified. Main risk is
potential flakiness due to reliance on singleton store state across
tests.
> 
> **Overview**
> Adds a new `layoutMutations.test.ts` Vitest suite that exercises
`useLayoutMutations` against the real `layoutStore` singleton
initialized from LiteGraph data.
> 
> Tests cover no-op guard clauses for missing nodes, node ID
normalization, position/size/z-index updates, node create/delete
behavior, `batchMoveNodes` semantics (empty input, skipping missing
nodes, preserving size), and `bringNodeToFront` z-index promotion
relative to other nodes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
bb92de0b5b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11313-test-add-unit-tests-for-useLayoutMutations-3446d73d36508134a289d6147244e710)
by [Unito](https://www.unito.io)
2026-05-01 03:06:29 +00:00
Kelly Yang
dafb944c3b refactor: replace PrimeVue InputText/Textarea with project UI components (#11324)
## Summary
Fix part of the
#https://github.com/Comfy-Org/ComfyUI_frontend/issues/11092
A total of 4 `// @ts-expect-error` directives were removed across 3
files — all caused by PrimeVue's legacy `$el` access pattern (`const
inputElement = inputRef.value.$el`) — by replacing PrimeVue with
Reka-based UI components. 1 corresponding unit test file was added.

## Changes
### `src/components/ui/input/Input.vue`
- **Exposed APIs**: Extended `defineExpose` to include `blur()` and
`setSelectionRange()`. This allows parent components to programmatically
control input behavior without direct DOM manipulation.

### `src/components/ui/textarea/Textarea.vue`
- **Exposed APIs**: Added `focus()` via `defineExpose`.
- **Cleanup**: Removed redundant attribute spreading (`...restAttrs`) to
lean on Vue’s default `$attrs` inheritance, making the component more
predictable.

---

## Refactored Feature Components

### `WidgetMarkdown.vue` (Note/Markdown Widgets)
- **Dependency Swap**: Replaced `primevue/textarea` with local
`Textarea.vue`.
- **Logic Simplification**: Simplified focus logic from
`textareaRef.value?.$el?.focus()` to a typed
`textareaRef.value?.focus()`.
- **Code Style**: Converted arrow functions to function declarations and
removed redundant section comments.

### `PromptDialogContent.vue` (Generic Prompt Dialogs)
- **Component Update**: Replaced PrimeVue `FloatLabel` and `InputText`
with a native `<label>` and local `Input.vue`.
- **Vue 3.5 Adoption**: Implemented **Reactive Destructuring** for
props.
- **Conflict Resolution**: Renamed internal `onConfirm` handler to
`handleConfirm` to prevent collision with destructured props.

### `EditableText.vue` (Node Titles & Sidebar Items)
- **Style Modernization**: Removed `<style scoped>` block in favor of
**Tailwind CSS** utility classes (e.g., `inline`, `w-full`).
- **Clean Implementation**: Replaced PrimeVue PassThrough (`:pt`) logic
with standard `@blur` and `v-bind` attributes.

---

## Testing & Quality Assurance

### Updated Tests
- **Redundancy Removal**: Cleaned up `EditableText.test.ts` and
`WidgetMarkdown.test.ts` by removing unused PrimeVue global
registrations. All 34 existing behavioral tests remain passing.

### New Coverage
- **`PromptDialogContent.test.ts`**: Added 3 new tests to verify:
    1. Correct initialization with `defaultValue`.
    2. Value persistence when clicking the Confirm button.
    3. Form submission via the `Enter` key.

---

## Manual Test Screenshot
All functions have passed testing.
 
<img width="594" height="530" alt="test5"
src="https://github.com/user-attachments/assets/46a6b3b2-1855-414e-ac78-65668052ce50"
/>
<img width="1190" height="1074" alt="test4"
src="https://github.com/user-attachments/assets/89aa61ab-9401-44c2-9eae-9ca8761df675"
/>
<img width="1154" height="1028" alt="test3"
src="https://github.com/user-attachments/assets/3f63cfdf-8fbd-4dd3-9e42-dbebe4d8d421"
/>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Moderate risk because it swaps underlying input/textarea components
and ref handling (focus/blur/selection) in interactive UI paths
(editable labels, prompt dialogs, markdown editor), which could subtly
change keyboard/blur behavior.
> 
> **Overview**
> Refactors several Vue components to stop using PrimeVue
`InputText`/`Textarea` (and `$el` access) in favor of the project’s
`Input`/`Textarea` components, updating bindings/events and Tailwind
classes accordingly.
> 
> Extends the shared `Input` to expose `blur`, `setSelectionRange`, and
`selectAll`, and updates `Textarea` to expose `focus`, enabling callers
to manage focus/selection without DOM internals.
> 
> Adds a new unit test suite for `PromptDialogContent` and simplifies
existing tests by removing PrimeVue plugin/component setup; the groups
e2e test replaces a screenshot assertion with a functional visibility
check for the new title input.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9c97314d59. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11324-refactor-replace-PrimeVue-InputText-Textarea-with-project-UI-components-3456d73d36508109a18bc97a7d0487a7)
by [Unito](https://www.unito.io)
2026-05-01 03:06:19 +00:00
Christian Byrne
d429d481e8 test: use real vue-i18n plugin in useReconnectingNotification tests (#11386)
## Summary

Replace `vi.mock('vue-i18n')` stub with a real `createI18n` plugin
instance in `useReconnectingNotification` tests.

## Changes

- **What**: Add `setupComposable()` helper that renders a wrapper
component via `@testing-library/vue` with a real `createI18n` plugin.
Assertions now check translated values
(`'Reconnecting'`/`'Reconnected'`) instead of raw i18n keys. Removes the
brittle `vi.mock('vue-i18n')` stub.

## Review Focus

Straightforward test-only change — the composable requires a component
setup context for `useI18n()`, so we render a thin wrapper via
`@testing-library/vue` with the i18n plugin installed.

Fixes #11153

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11386-test-use-real-vue-i18n-plugin-in-useReconnectingNotification-tests-3476d73d3650814ba70eea6df91c8bbe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 01:55:20 +00:00
Comfy Org PR Bot
a9aae6af4a 1.44.15 (#11787)
Patch version increment to 1.44.15

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11787-1-44-15-3536d73d3650814da3a6dfe300ff559c)
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-01 01:32:05 +00:00
jaeone94
46ba65e25c fix: hide advanced footer button on collapsed Vue nodes (#11778)
Before 


https://github.com/user-attachments/assets/82e323d7-0c9b-4303-81eb-87cb7b62f1c1


After 


https://github.com/user-attachments/assets/56f1db45-1b5d-47a9-9960-9a73deb68e78

Fixes the Vue node footer so the Advanced button only appears while a
node is expanded.

Root cause: `showAdvancedInputsButton` only checked for advanced widgets
and the global setting, so collapsed nodes with advanced widgets could
still render the Advanced tab, including the combined error + advanced
footer state.

Changes:
- Return `false` for advanced footer visibility when the Vue node is
collapsed.
- Add regression coverage for collapsed advanced nodes and collapsed
error + advanced nodes.

Validation:
- `pnpm test:unit --
src/renderer/extensions/vueNodes/components/LGraphNode.test.ts`
- Commit hook: format, stylelint, oxlint, eslint, `pnpm typecheck`
- Push hook: `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11778-codex-Hide-advanced-footer-on-collapsed-Vue-nodes-3526d73d365081d79399ddda40f2a7f7)
by [Unito](https://www.unito.io)
2026-05-01 01:00:14 +00:00
jaeone94
11432f7d0e refactor: extract missing model refresh pipeline (#11751)
## Summary

Extracts the missing-model pipeline orchestration out of `ComfyApp` and
into an app-independent platform module, while tightening the
workflow-flattening type boundary that refresh needs when rescanning the
live LiteGraph graph.

This PR is intentionally refactor-heavy. It is the follow-up to the
earlier missing-model refresh work: instead of keeping refresh-specific
candidate recheck logic beside the UI, this change makes the refresh
path reuse the existing missing-model pipeline and removes the direct
dependency on private `ComfyApp` pipeline methods.

Linear: FE-499

Issues covered by this PR:

- Fixes #11678
- Fixes #11680
- Partially addresses #11679 by removing the missing-model refresh
path's unsafe `graph.serialize() as unknown as ComfyWorkflowJSON` cast
and replacing it with the narrower flattenable workflow contract.
Broader workflow serialization/type-boundary cleanup outside this
missing-model refresh path remains deferred.

## Changes

- **What**:
- Added `src/platform/missingModel/missingModelPipeline.ts` as the
orchestration module for missing-model detection/verification.
- `runMissingModelPipeline(...)` now owns the pipeline previously
embedded in `ComfyApp`:
      - candidate scan and enrichment
      - active ancestor filtering for muted/bypassed subgraph containers
      - pending warning cache updates
      - OSS folder path and file-size follow-up work
      - cloud asset verification follow-up work
- surfaced missing-model errors via the existing execution error store
- `refreshMissingModelPipeline(...)` handles the refresh-specific flow:
      - calls the injected `reloadNodeDefs()` first
      - serializes the current live graph
- preserves model metadata by preferring active workflow `models`, then
falling back to current missing-model candidate metadata
      - delegates back into the same pipeline used during workflow load
- Kept `ComfyApp` as the compatibility caller instead of the owner of
the pipeline.
- `loadGraphData(...)` now calls `runMissingModelPipeline(...)` with
`graph`, `graphData`, `missingNodeTypes`, and `silent` options.
- `refreshMissingModels(...)` is now a thin wrapper around
`refreshMissingModelPipeline(...)` and keeps the existing default
`silent: true` refresh behavior.
- The new pipeline module does not import `@/scripts/app`; app-owned
data/actions are passed in as inputs.
- Moved the workflow node-flattening helpers out of `workflowSchema.ts`
and into `src/platform/workflow/core/utils/workflowFlattening.ts`.
- This includes `flattenWorkflowNodes`, `buildSubgraphExecutionPaths`,
and `isSubgraphDefinition`.
- The move is intentional: these helpers are not zod schema definitions
or workflow validation logic. They are core workflow traversal utilities
used to flatten root workflow nodes plus nested subgraph definition
nodes into the execution-shaped node list needed by missing-model
scanning.
- The refresh path receives data from `LGraph.serialize()`, whose return
type is serialized LiteGraph data rather than validated
`ComfyWorkflowJSON`. Previously this forced unsafe typing like
`graph.serialize() as unknown as ComfyWorkflowJSON`.
- The new `FlattenableWorkflowGraph` / `FlattenableWorkflowNode`
structural contract describes only what flattening actually needs:
`nodes`, `definitions.subgraphs`, node `id`, `type`, `mode`,
`widgets_values`, and `properties`.
- This lets both normal workflow-load data (`ComfyWorkflowJSON`) and
refresh-time live graph serialization (`LGraph.serialize()`) flow into
the same scan/enrichment path without pretending serialized LiteGraph
output is a fully validated workflow schema document.
- Updated `missingModelScan.ts` to consume that minimal flattenable
workflow shape via `MissingModelWorkflowData`.
- `MissingModelWorkflowData` extends the flattenable workflow contract
with optional workflow-level `models` metadata.
- Removed now-unnecessary casts around execution IDs, flattened nodes,
and `widgets_values` object access.
- Updated `getSelectedModelsMetadata(...)` to accept readonly widget
value arrays so flattened workflow data can stay read-only.
- Reduced the exported surface of the new pipeline module after `knip`
flagged unused exported internal option/store interfaces.
- Kept `workflowSchema.ts` focused on validation schemas. The flattening
helpers are not re-exported from the schema module because they are
internal workflow core utilities, not public schema API.

- **Breaking**: None intended.
  - Internal imports were updated to the new core utility path.
- This repo is not exposing these flattening helpers as a public package
API, so the old schema-local helper location is treated as an internal
implementation detail.

- **Dependencies**: None.

## Review Focus

- **Pipeline extraction / dependency direction**:
- Please verify that `missingModelPipeline.ts` stays independent from
`@/scripts/app`.
- `ComfyApp` should remain the caller/adapter, not the owner of
missing-model pipeline orchestration.

- **Workflow flattening type boundary**:
- The main type-cleanup goal is removing the refresh-time
`graph.serialize() as unknown as ComfyWorkflowJSON` lie.
- `LGraph.serialize()` and validated workflow JSON are not the same
contract. The new flattenable workflow contract is deliberately smaller
and structural because the missing-model enrichment path only needs
enough data to flatten nodes and read embedded model metadata.
- This is why the flattening helpers moved from `workflowSchema.ts` to
`workflow/core/utils`: the logic is reusable workflow traversal, not
validation schema.

- **Behavior preservation**:
- The PR is intended to preserve existing user-facing missing-model
behavior while moving ownership out of `app.ts`.
- Existing async follow-up behavior remains intentionally
fire-and-forget:
- cloud asset verification still surfaces after verification completes
- OSS folder paths still update asynchronously before surfacing
confirmed missing models
    - file-size metadata fetching remains asynchronous
- More invasive behavior changes, such as adding non-cloud post-fetch
`isMissingCandidateActive(...)` re-verification or redesigning the
fire-and-forget result contract, are intentionally left for follow-up
work because they are not pure extraction.

- **Downloadable model metadata**:
- `missingModels` returned for download metadata now requires both `url`
and `directory`.
- Candidates without a directory still remain in `confirmedCandidates`,
but they are not exposed as downloadable model metadata. This keeps the
returned downloadable list aligned with what the download flow can
actually use.

- **Test ownership**:
- Complex missing-model pipeline behavior tests moved out of
`src/scripts/app.test.ts` and into
`src/platform/missingModel/missingModelPipeline.test.ts`.
- `app.test.ts` now only covers thin delegation for
`app.refreshMissingModels(...)`.
- Workflow flattening tests moved with the helper from schema tests into
`src/platform/workflow/core/utils/workflowFlattening.test.ts`.

- **Deferred follow-ups**:
  - Broader function decomposition for cognitive complexity.
- Wider dependency-injection/port cleanup for stores and services beyond
the app boundary.
- Cloud-specific pipeline unit tests, which need a separate `isCloud`
mocking strategy.
- Additional E2E coverage expansion beyond the existing OSS refresh
path.
- More general workflow serialization/type-boundary cleanup outside the
missing-model refresh path.

## Validation

- `pnpm format`
- `pnpm lint`
- Passed. Existing lint output included a pre-existing
`no-misused-spread` warning and icon-name logs, but the command exited
successfully.
- `pnpm typecheck`
- `pnpm test:unit`
  - `714 passed`, `9514 passed | 8 skipped`
- Pre-push `pnpm knip`
- Passed after reducing the exported surface of the new pipeline module.

## Screenshots (if applicable)

Not applicable. This PR is a pipeline/type-boundary refactor with no UI
changes.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11751-refactor-extract-missing-model-refresh-pipeline-3516d73d3650816d9245d4b1324b71c9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 00:50:51 +00:00
pythongosssss
9384beaec6 test: Add tests for bounding box widget (#11343)
## Summary

Adds coverage for the bounding box widget

## Changes

- **What**: 
- Validates user interactions and functionality on the widget

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11343-test-Add-tests-for-bounding-box-widget-3456d73d365081eb8d03f2220a837816)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-05-01 00:23:22 +00:00
pythongosssss
4a05d89fdb fix: detach DOM widget event listeners on widget removal (#11724)
## Summary

Fixes leaked event listeners

## Changes

- **What**: 
- update all listeners to use AbortController to signal removal on
widget remove

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11724-fix-detach-DOM-widget-event-listeners-on-widget-removal-3506d73d3650811dae81c034c1098759)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 00:17:18 +00:00
Alexander Brown
ef98ba0e8f feat: add plum/ink color primitives and standardize design tokens (#11139)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds new `plum` and `ink` color scales for Comfy Hub branding and
standardizes existing tokens to align with current Figma design system.

### Changes

**Phase 1 — New primitives** (`_palette.css`)
- Added `plum-300/400/500/600` and `ink-100` through `ink-900`

**Phase 2 — Token cleanup** (`style.css`)
- Removed deprecated `slate-100/200/300` primitives (cool blue-grey,
removed from Figma)
- Removed duplicate `graphite-400` (identical hex to slate-100)
- Dark mode: migrated 6 slate/graphite references to muted-foreground,
smoke-700, smoke-800, charcoal-200
- Light mode: replaced 3 `ash-500` references with `smoke-800` per
designer alignment

### Token migration detail

| Dark mode token | Old value | New value | Rationale |
|---|---|---|---|
| `--node-component-header-icon` | slate-300 (#5b5e7d) |
muted-foreground (smoke-800) | Figma `node/foreground-secondary` |
| `--node-component-slot-text` | slate-200 (#9fa2bd) | smoke-700
(#a0a0a0) | Lighter neutral for text contrast |
| `--node-component-surface-highlight` | slate-100 (#9c9eab) | smoke-800
(#8a8a8a) | Neutral grey highlight |
| `--node-component-tooltip-border` | slate-300 (#5b5e7d) | charcoal-200
(#494a50) | Consistent with dark border tokens |
| `--text-secondary` | slate-100 (#9c9eab) | smoke-700 (#a0a0a0) |
Adequate contrast on dark surfaces |
| `--widget-background-highlighted` | graphite-400 (#9c9eab) | smoke-800
(#8a8a8a) | Removed duplicate, neutral replacement |

### Visual note

These changes shift some dark mode colors from cool blue-grey to neutral
grey. This is intentional per the design team. The
`--node-component-surface-highlight` and
`--node-component-tooltip-border` tokens should be QA'd as the designer
noted.

### Not included (Phase 3)

Hub Dark overlay theme will ship separately once the Hub UI work is
ready to validate against.

## Screenshots

![Dark theme with templates dialog - text contrast
verified](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6425a0f072864b0f279edd70da09bf96b24eaf7d483eb695e2718e864735bea9/pr-images/1775860135830-05ba14b3-f385-4fc8-9d25-3094d8c09b74.png)

![Dark theme canvas view - sidebar and toolbar rendering
correctly](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6425a0f072864b0f279edd70da09bf96b24eaf7d483eb695e2718e864735bea9/pr-images/1775860136167-5887ce56-5eae-482e-90e5-06e24cb23d2c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11139-feat-add-plum-ink-color-primitives-and-standardize-design-tokens-33e6d73d365081418e13e0efe6161fb5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 16:15:53 -07:00
Christian Byrne
019c1787a5 [chore] Update Comfy Registry API types from comfy-api@e993818 (#11783)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: e993818
- Generated on: 2026-04-30T17:57:05Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11783-chore-Update-Comfy-Registry-API-types-from-comfy-api-e993818-3526d73d3650810e9e14f55da44714b2)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-30 21:37:01 +00:00
Alexander Brown
87fab87d84 docs: add reviewing-unit-tests skill (#11777)
## Summary

Adds `.claude/skills/reviewing-unit-tests/SKILL.md`, a project-scoped
agent skill that gives reviewing agents a concrete rubric for evaluating
Vitest unit-test diffs.

## Changes

- **What**: New skill at `.claude/skills/reviewing-unit-tests/SKILL.md`.
Mirrors the structural template of the peer skill
`.claude/skills/writing-playwright-tests/SKILL.md` (frontmatter shape,
table-driven sections, terse prescriptive prose). Description triggers
on PR review of `*.test.ts` files. Codifies: alias-by-renaming
detection, `vi.mocked()` scope rule, unnecessary-cast detection,
divergence checks against `docs/testing/unit-testing.md` /
`store-testing.md` / `component-testing.md` / `vitest-patterns.md`,
redundant `mockClear()` flag, `vue-i18n`-must-not-be-mocked rule (with
`testI18n` reference), bugfix regression-validity check, leading-close
framing flag, testing-library requirement for new component tests.

## Review Focus

- **Retrospective validation against PR #11737** (head SHA
`4573d62450fd12ac6f06e5e491f8af84ccbd27de`, file
`src/platform/assets/composables/useMediaAssetActions.test.ts`).
Mentally running the skill's checklist against that diff flags:
1.  `getToastAddMock` / `getI18nTMock` module-level helpers — caught by
the "Renaming ≠ Restructuring" section + Mocking Smells row
"Module-level helper functions wrapping mocked composable returns".
2.  `as ReturnType<typeof vi.fn>` casts in those helpers — caught by
the "`vi.mocked()` Scope" section + Mocking Smells row on stray casts.
3.  `vi.mock('vue-i18n', ...)` — caught by Mocking Smells row directing
to mount real `createI18n` per `docs/testing/vitest-patterns.md` and the
shared `testI18n` in
`src/components/searchbox/v2/__test__/testUtils.ts`.
4.  `vi.mock('primevue/usetoast', ...)` — explicitly **not** flagged.
The "Distinguish" paragraph after the Mocking Smells table marks
trivially-shaped third-party hooks as acceptable to mock; "Don't Mock
What You Don't Own" applies to behavior-rich APIs, not single-method
composables.
- **Reference style**: links to `docs/testing/*.md` and
`docs/guidance/typescript.md` instead of restating their content, per
the FE-511 acceptance criterion.
- **Open question**: scope. The repo-local skill is the FE-511
deliverable; equivalent prescriptive rules already live in the user's
global `~/.config/amp/AGENTS.md` "Code Review Rigor" section as a
separate artifact. No duplication intended — the skill is the
project-scoped surface a reviewing agent loads when entering this repo.

Closes FE-511

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11777-docs-add-reviewing-unit-tests-skill-3526d73d365081848759e6a8fab942f0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 14:29:47 -07:00
Alexander Brown
f2a99adaa3 docs: prefer real createI18n over mocking vue-i18n in tests (#11776)
## Summary

Document that `vue-i18n` should not be mocked in tests — mount with a
real `createI18n` plugin instance instead.

## Changes

- **What**: Expanded `docs/testing/vitest-patterns.md` "Don't Mock
`vue-i18n`" section with a concrete code example (covering both
component and composable tests), guidance for asserting on translation
keys with empty messages, and a real-example link to
`src/components/searchbox/v2/__test__/testUtils.ts`. Added a callout at
the top of `docs/testing/unit-testing.md` "Mocking Composables with
Reactive State" cross-linking the new section, since that section
applies to *owned* composables.

## Review Focus

- The previous `vitest-patterns.md` paragraph pointed at a non-existent
`SearchBox.test.ts`; the new link points to the actual shared `testI18n`
helper.
- The "Mocking Composables with Reactive State" pattern should not be
applied to third-party composables like `useI18n` — the callout makes
that explicit.

Surfaced during review of #11737, where the test file mocked `vue-i18n`
and then accumulated structural workarounds (hoisted aliases, helper
functions, type casts) to interact with the mocked `t`. A real
`createI18n` instance avoids the entire ceremony.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11776-docs-prefer-real-createI18n-over-mocking-vue-i18n-in-tests-3526d73d365081d4bc39fbf3c2502e49)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 14:29:11 -07:00
Robin Huang
a934056246 feat(website): add PostHog analytics (#11735)
## Summary

Adds PostHog page analytics to the marketing website (`apps/website/`).

## Changes

- **What**: New `posthog.ts` script with
`initPostHog`/`capturePageview`. Wired into `BaseLayout.astro` behind
`import.meta.env.PROD` (mirroring the GTM gate). Pageviews are captured
on every `astro:page-load` so ClientRouter view-transition navigations
are tracked, not just hard reloads.
- **Dependencies**: `posthog-js` (already in the workspace catalog at
`^1.358.1`; previously used by the workbench telemetry provider).

## Review Focus

- API host is set to `https://t.comfy.org` to match
`src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts` —
confirm that proxy is OK to share with the website surface.
- Project token is hard-coded inline. It is a public `phc_…` frontend
token (designed to ship to clients); this matches the pattern used for
`gtmId` in the same file. Happy to switch to a `PUBLIC_POSTHOG_KEY` env
var if preferred.
- `person_profiles: 'identified_only'` to avoid creating profiles for
every anonymous visitor — flag if you want full anonymous tracking
instead.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11735-feat-website-add-PostHog-analytics-3516d73d3650811189c6d64c3af4ded9)
by [Unito](https://www.unito.io)
2026-04-30 20:08:01 +00:00
pythongosssss
b8dfbfc0bb fix: ensure escape key/graph navigation cancels ghost node placement (#11779)
## Summary

When inside a subgraph, the parent key handler intercepts the escape key
before `processKey` is called, causing a stale ghost node to be added to
the inner subgraph when the graph is changed to the parent.

## Changes

- **What**: 
- move escape key handler to document, prevent propagating the event
- add saftey net cancel in setGraph
- tests

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11779-fix-ensure-escape-key-graph-navigation-cancels-ghost-node-placement-3526d73d3650812292e4ca10d384f783)
by [Unito](https://www.unito.io)
2026-04-30 17:30:04 +00:00
Comfy Org PR Bot
036c79259b 1.44.14 (#11769)
Patch version increment to 1.44.14

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11769-1-44-14-3526d73d3650816ba3a9cd87afeef035)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-30 06:09:17 +00:00
Benjamin Lu
17c18b0707 fix: embed HubSpot contact form (#11723)
## Summary

Embed the HubSpot-hosted contact sales form on `/contact` and
`/zh-CN/contact` so HubSpot owns submission handling, validation,
anti-spam/security updates, tracking context, and form configuration.

## Changes

- **What**: Replaces the custom local contact form stub with a HubSpot
embed component using the HubSpot-hosted developer/raw HTML script and
`hs-form-html` container.
- **Localization**: Uses the existing English form ID by default and
switches to the zh-CN form ID for `/zh-CN/contact`.
- **Styling**: Applies HubSpot form CSS custom properties to match the
Comfy contact page colors and `PP Formula` font more closely.
- **Docs**: Updates the website README with the developer embed snippet
and the zh-CN form ID.
- **Dependencies**: None.

## Why Embedded Form

- HubSpot docs say forms should be loaded with the HubSpot-hosted
JavaScript file, and that security, anti-spam, accessibility, and
performance improvements will not propagate if the embed runtime is
copied or self-hosted:
https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/forms
- The direct form submission endpoint is documented under `v3 legacy`:
https://developers.hubspot.com/docs/api-reference/legacy/marketing/forms/v3-legacy/submit-data-unauthenticated
- HubSpot’s legacy API overview says numeric-versioned APIs are legacy
and will be deprecated in a future release:
https://developers.hubspot.com/docs/api-reference/legacy/overview

## Review Focus

- Confirm the portal ID and form IDs are correct:
  - `en`: `94e05eab-1373-47f7-ab5e-d84f9e6aa262`
  - `zh-CN`: `6885750c-02ef-4aa2-ba0d-213be9cccf93`
- Check visual fit on `/contact` and `/zh-CN/contact`, especially font,
background, inputs, radio controls, and submit button.
- Confirm the developer/raw HTML embed remains the preferred integration
over a custom Forms API POST.

## Local Checks

- `pnpm exec oxfmt --check
apps/website/src/components/contact/HubspotFormEmbed.vue
apps/website/README.md`
- `pnpm exec eslint
apps/website/src/components/contact/HubspotFormEmbed.vue`
- `pnpm --filter @comfyorg/website typecheck`
- `pnpm --filter @comfyorg/website test:unit`
- `pnpm --filter @comfyorg/website build`
- Commit hooks: stylelint, oxfmt, oxlint, eslint, `pnpm typecheck`,
`pnpm typecheck:website`
- Push hook: `pnpm knip --cache`

Build completed with existing non-fatal environment warnings: Node
`v25.8.1` vs requested `24.x`, unresolved website font paths deferred to
runtime, `transformWithEsbuild` deprecation, and missing Ashby env
falling back to the committed snapshot.

Incredibly, during the taking of these screenshots, I discovered a bug
in macos, where despite the snapshot/record bar not existing, from me
esc'ing out, some of the tooltips persist. Closing and reopening the lid
did not fix this. I didn't see the process in activity monitor.

<img width="1512" height="862" alt="Screenshot 2026-04-29 at 7 09 55 PM"
src="https://github.com/user-attachments/assets/92518795-6845-4b34-8da3-54048b440eb1"
/>

<img width="1512" height="862" alt="Screenshot 2026-04-29 at 7 13 18 PM"
src="https://github.com/user-attachments/assets/f7609e58-898d-413c-975c-b02b70b84e73"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11723-fix-embed-HubSpot-contact-form-3506d73d365081528bfbe4b024c2a21f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-29 22:40:55 -07:00
Alexander Brown
ca8407218b chore(husky): skip pre-push knip hook in CI (#11772)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes a false-positive knip failure in the `i18n: Update Core` workflow
that's been blocking version-bump PRs (e.g. #11769, run
[25141091787](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25141091787/job/73690747401)).

## Root cause

1. The `update-locales` job uses `setup-comfyui-server`, which copies
`tools/devtools/*` → `ComfyUI/custom_nodes/ComfyUI_devtools/*` at the
workspace root.
2. The job's final `git push` triggers the husky `.husky/pre-push` hook,
which runs `pnpm knip`.
3. Knip's `project` glob (`**/*.{js,ts,vue}` in `knip.config.ts`) sweeps
the runtime `ComfyUI/` directory; the existing `tools/devtools/web/**`
ignore doesn't cover the copied path, so
`ComfyUI/custom_nodes/ComfyUI_devtools/web/legacyWidget.js` (added in
#11574) is reported as unused → exit 1 → push aborted.

## Why fix the hook (not knip config or workflows)

- **`knip.config.ts` ignore** (`'ComfyUI/**'`): tried first;
`treatConfigHintsAsErrors: true` makes the unused-pattern hint fatal in
normal CI runs where `ComfyUI/` doesn't exist, breaking `ci-lint-format`
everywhere.
- **`HUSKY=0` per workflow step**: works, but bots can't push workflow
file changes without `workflows` scope, and it would need to be repeated
on every CI workflow that pushes.
- **Skip the hook in CI**: the canonical `pnpm knip` check runs in
`.github/actions/lint-format-verify/action.yml` on every PR, so the
pre-push duplicate is pure dev-side guardrail. Gating it on `$CI` (set
to `true` by all GitHub Actions runners) cleanly removes it from every
CI push without affecting local developer workflow.

## Verification

Reproduced the failure locally by mirroring the CI sequence (`cp -r
tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/` then `pnpm
knip:no-cache`):

- Without fix: `[log] Unused files (1)
ComfyUI/custom_nodes/ComfyUI_devtools/web/legacyWidget.js` → exit 1
(matches CI log)
- With `CI=true`, the hook short-circuits at `exit 0` before invoking
knip
- `pnpm knip` on a clean checkout (no `ComfyUI/`) still passes

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11772-chore-husky-skip-pre-push-knip-hook-in-CI-3526d73d3650819f9a01d549d122b69c)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-30 05:16:51 +00:00
Benjamin Lu
26ac1eece1 Allow website screenshot workflow to remove its label (#11725)
## Summary

Allows the website screenshot update workflow to remove its own trigger
label when a label-triggered run completes.

## Changes

- **What**: Grants the screenshot update job `issues: write`, which is
required for `issues.removeLabel`, and keeps the cleanup scoped to
`Update Website Screenshots`.
- **Dependencies**: None.

## Review Focus

Confirm the workflow permission scope is appropriate and that unexpected
label cleanup failures should fail the workflow instead of being
silently swallowed.

## Validation

- `/Users/ben/go/bin/actionlint
.github/workflows/pr-update-website-screenshots.yaml`
- `/Users/ben/Library/Python/3.9/bin/yamllint --config-file .yamllint
.github/workflows/pr-update-website-screenshots.yaml`
- `git diff --check HEAD~1 HEAD`

No browser e2e regression was added because this change only adjusts
GitHub Actions token permissions and label cleanup behavior; it does not
change shipped app/runtime behavior.

Fixes FE-487
2026-04-30 04:57:00 +00:00
Benjamin Lu
810381ab63 Stabilize website GitHub stars in visual snapshots (#11771)
## Summary

Stabilize the website nav GitHub star count in visual-test builds so
snapshot comparisons do not drift as the live GitHub count changes.

## Changes

- **What**: Added `WEBSITE_GITHUB_STARS_OVERRIDE` for build-time
star-count overrides and set it to `111000` in the website E2E and
screenshot-update workflows.
- **Dependencies**: None.

## Review Focus

Confirm the deterministic build-time override is preferable to
screenshot masking, since Playwright masks draw a colored rectangle
whose geometry can also drift when masked content changes size.

## Screenshots (if applicable)

Not included; this keeps visual-test input data stable rather than
changing the UI.
2026-04-29 21:57:30 -07:00
Christian Byrne
cc1a737291 test: add unit tests for useImagePreviewWidget (#11394)
## Summary
Add 23 unit tests for `useImagePreviewWidget` composable, improving
coverage from ~52% to significantly higher.

## Test Coverage
- Widget construction (factory return, name, type, serialization,
options)
- `computeLayoutSize` returns correct dimensions
- `createCopyForNode` creates properly bound copy
- Upload spinner rendering (animation, stroke color)
- Single image rendering (draws image, handles missing node.size)
- Image size text overlay (enabled/disabled via setting)
- Multi-image thumbnail grid (compact vs non-compact mode, cell borders)
- Pointer interaction (pointerDown cleanup, overIndex reset)
- `previewImages` override from DrawWidgetOptions
- `onPointerDown` drag handler setup
- `onClick` no-op behavior

## Testing
```bash
pnpm test:unit -- src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.test.ts
```
All 23 tests pass. No source changes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11394-test-add-unit-tests-for-useImagePreviewWidget-3476d73d365081a69c78e87329e75d9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 02:15:48 +00:00
Kelly Yang
c74e08e244 refactor: extract useBrushAdjustment from useBrushDrawing (#11544)
## Summary

Part of the `useBrushDrawing` decomposition plan (PR C). Extracts brush
size/hardness adjustment logic (Alt+drag interaction) into a dedicated
`useBrushAdjustment` composable. No runtime behavior is changed — pure
structural refactor.

## Changes

- **New** `src/composables/maskeditor/useBrushAdjustment.ts` —
encapsulates `startBrushAdjustment` and `handleBrushAdjustment`,
including dead zone filtering, dominant axis suppression, and
size/hardness clamping
- **New** `src/composables/maskeditor/useBrushAdjustment.test.ts` — unit
tests covering: no-op before start, dead zone suppression, size increase
on drag, size/hardness clamping, dominant axis lock
- **Updated** `src/composables/maskeditor/useBrushDrawing.ts` — removes
inlined adjustment state and functions, delegates to
`useBrushAdjustment(initialSettings)`

## Test Functionality
Open ComfyUI and enter the MaskEditor of any image node. On the canvas,
Alt + Right-click Drag:

- Drag Right → Increase brush size - pass
- Drag Left → Decrease brush size - pass
- Drag Up → Increase hardness - pass
- Drag Down → Decrease hardness - pass


https://github.com/user-attachments/assets/273e8383-dab5-4c82-ac7b-0a1534dfd770





<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches core pointer-interaction logic for brush tuning and changes
adjustment behavior (removes delta saturation and uses initial values),
which could subtly affect UX even though scope is localized to the mask
editor.
> 
> **Overview**
> Extracts the Alt-drag brush size/hardness adjustment logic out of
`useBrushDrawing` into a new `useBrushAdjustment` composable, and wires
`useBrushDrawing` to delegate to it.
> 
> The extracted logic now bases adjustments off the captured initial
brush size/hardness and removes prior delta capping (no ±100px
saturation), which changes how large/continuous drags affect the final
values. Adds a Vitest suite covering dead-zone behavior, dominant-axis
suppression, clamping, and the no-op-before-start contract.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b2f0ce22d3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11544-refactor-extract-useBrushAdjustment-from-useBrushDrawing-34a6d73d365081a48897dd77b04ef56b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-29 20:25:36 -04:00
Robin Huang
8f9f452c86 fix: enable Chrome password autofill on signup form (#11636)
## Summary

Add `name` attributes to the signup form's email, password, and
confirm-password inputs so Chrome's password manager recognizes the form
and offers autofill/save.

## Changes

- **What**: Pass `name` through to the underlying `<input>` on the email
field (via `pt:root:name`) and on both password fields (via
`pt:pc-input-text:root:name`). Without `name`, Chrome can't pair the
email with the password and won't surface the save-password /
suggest-strong-password prompts.

## Review Focus

- The PrimeVue passthrough syntax (`pt:root:*` for `InputText`,
`pt:pc-input-text:root:*` for `Password`) lands the attribute on the
actual `<input>` element — verified in DevTools.
- `confirm-password` is not a standard `autocomplete` token; we keep
`autocomplete="new-password"` on both password fields and only
differentiate via `name`.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/e1e25ab5-8496-4c84-b5f1-630f31956c80

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11636-fix-enable-Chrome-password-autofill-on-signup-form-34e6d73d36508180882cc9ebafb58417)
by [Unito](https://www.unito.io)
2026-04-30 00:11:20 +00:00
161 changed files with 7347 additions and 2837 deletions

View File

@@ -0,0 +1,156 @@
---
name: reviewing-unit-tests
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
---
# Reviewing Unit Tests for ComfyUI_frontend
## Overview
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
## Review Workflow
1. Identify the test type: component, store, composable, util, or bugfix regression.
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
3. Open the authoritative doc section before judging structure.
4. Scan the red flags below.
5. State the verdict first. Name the failure mode. Cite the doc or rule.
## Source of Truth / Precedence
When docs and examples conflict, use this order:
1. Explicit repo rules, lint rules, and note blocks.
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
4. Example snippets
5. Prior diffs
Apply these repo-specific clarifications:
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
- Motion != fix.
## 30-Second Red Flags
| If you see... | Failure mode | Default action |
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
## Rationalization Table
| Excuse | Reality |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
| "It is only a nit" | Explicit repo-rule violations are never nits. |
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
## Mocking Rules
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
### Alias-by-Renaming
```ts
// Before
const mockAdd = vi.hoisted(() => vi.fn())
// After: same indirection, new name
function getToastAddMock() {
return useToast().add
}
```
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
### `vi.mocked()` Scope
| Use case | `vi.mocked()` required? |
| --------------------------------------------------------------- | ----------------------- |
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
| `.mock.calls`, `.mock.results` | Yes |
| `expect(fn).toHaveBeenCalled()` | No |
| `expect(fn).toHaveBeenCalledWith(...)` | No |
- Flag casts whenever `vi.mocked()` would narrow correctly.
- Do not add `vi.mocked()` around assertion-only references just for style.
### Reset Hygiene
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
### Third-Party Seams
- Distinguish trivial hooks from behavior-rich APIs.
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
- That exception does not justify mocking behavior-rich third-party modules.
### `vue-i18n`
- Never mock `vue-i18n` in component tests.
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
## Test-Body Rules
| Smell | Review bar |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Change-detector test | Reject. Default values alone prove nothing. |
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
## Bugfix Regression Proof
For `fix:` PRs or bugfix diffs:
1. Identify the production change that fixes the bug.
2. Verify the new test fails on pre-fix code, or ask the author to show it.
3. If the test passes on broken code, request changes.
A regression test that never proves red does not pin the bug.
## Review Output Rules
- State verdict before procedural questions.
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
- Link the authoritative doc section in the review comment.
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
## Quick Reference
| When you see... | Read this |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
## Key Files to Read
| Purpose | Path |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |

View File

@@ -0,0 +1,87 @@
# Outputs default to 'true' for non-pull_request events (push, merge_group):
# granular path filtering is a PR-only optimization. This avoids the silent
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
# would never run on push.
#
# Shared dependency files (root package.json, pnpm-lock.yaml,
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
# output so a lockfile bump correctly invalidates each granular gate. They
# are NOT folded into docs-changes.
#
# Two paths-filter steps are needed because predicate-quantifier=every is
# required for the negated globs in `should-run` but breaks multi-pattern
# OR filters like `docs:` and `deps:`.
#
# Requires the caller to have checked out the repository.
name: 'Detect Path Changes'
description: >
Computes typed *-changes outputs and a back-compat should-run for
path-gated CI jobs.
outputs:
should-run:
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
app-website-changes:
description: 'Shared deps or `apps/website/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
app-desktop-changes:
description: 'Shared deps or `apps/desktop-ui/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
app-frontend-changes:
description: 'Shared deps or `src/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
packages-changes:
description: 'Shared deps or `packages/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
storybook-changes:
description: 'Shared deps or `.storybook/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
docs-changes:
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
dependency-changes:
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
runs:
using: composite
steps:
- name: Filter typed changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
app_website:
- 'apps/website/**'
app_desktop:
- 'apps/desktop-ui/**'
app_frontend:
- 'src/**'
packages:
- 'packages/**'
storybook:
- '.storybook/**'
docs:
- 'docs/**'
- '**/*.md'
deps:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Filter relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: relevant
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
relevant:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'

View File

@@ -12,17 +12,30 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
scan:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -14,16 +14,29 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
validate-fonts:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -68,15 +81,17 @@ jobs:
echo '✅ No proprietary fonts found in dist'
validate-licenses:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -3,10 +3,8 @@ name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
@@ -16,8 +14,20 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
runs-on: ubuntu-latest
timeout-minutes: 30
container:

View File

@@ -16,9 +16,22 @@ permissions:
contents: read
jobs:
collect:
changes:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
collect:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

View File

@@ -4,7 +4,6 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
merge_group:
@@ -15,36 +14,20 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect whether e2e-relevant files changed. Required checks see "skipped"
# (which counts as passing) when only docs/apps/storybook files are touched,
# avoiding the stall that paths-ignore would cause.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- name: Checkout repository
if: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@v6
- name: Check for e2e-relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
e2e:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
setup:
needs: changes
if: ${{ needs.changes.outputs.should_run == 'true' }}
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -194,7 +177,7 @@ jobs:
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -233,7 +216,7 @@ jobs:
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
@@ -251,7 +234,7 @@ jobs:
runs-on: ubuntu-latest
if: >-
${{
needs.changes.outputs.should_run == 'true' &&
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
@@ -278,7 +261,7 @@ jobs:
if: >-
${{
always() &&
needs.changes.outputs.should_run == 'true' &&
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}

View File

@@ -8,10 +8,29 @@ on:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
permissions:
pull-requests: write
steps:
@@ -30,8 +49,13 @@ jobs:
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
if: |
github.event_name == 'pull_request'
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -67,8 +91,15 @@ jobs:
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -107,9 +138,15 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [storybook-build]
needs: [changes, storybook-build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
if: |
always()
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
permissions:
pull-requests: write
contents: read

View File

@@ -4,10 +4,8 @@ name: 'CI: Tests Unit'
on:
push:
branches: [main, master, dev*, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
concurrency:
@@ -15,7 +13,20 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
test:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:

View File

@@ -4,23 +4,29 @@ name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
build:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
steps:

View File

@@ -3,25 +3,29 @@ name: 'CI: Website E2E'
on:
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
website-e2e:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
@@ -45,6 +49,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
@@ -161,7 +167,11 @@ jobs:
post-starting-comment:
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
needs: changes
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
runs-on: ubuntu-latest
permissions:
pull-requests: write

View File

@@ -18,6 +18,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
@@ -86,6 +87,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
@@ -137,7 +140,10 @@ jobs:
name: 'Update Website Screenshots'
})
} catch (e) {
// Label may already be removed
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
}
post-starting-comment:

View File

@@ -1,5 +1,13 @@
#!/usr/bin/env bash
# Skip in CI: the canonical knip check runs in ci-lint-format on every
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
# setup-comfyui-server, which contaminates knip's project glob with the
# devtools copy under custom_nodes and produces false-positive failures.
if [ -n "${CI-}" ]; then
exit 0
fi
# Run Knip with cache via package script
pnpm knip 1>&2

View File

@@ -113,6 +113,31 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
```html
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
```
The localized `/zh-CN/contact` page uses the same portal and script with form
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
renders the documented embed container.
## Scripts
- `pnpm dev` — Astro dev server

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -26,6 +26,7 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

@@ -1,4 +1,33 @@
# robots.txt for comfy.org
# Open to all crawlers — including AI/LLM bots — for maximum visibility
# in AI-powered search, chat-based answer engines, and traditional search.
# Granular UAs are listed explicitly to signal intent; rules are shared
# via stacked user-agent records (RFC 9309 §2.2).
User-agent: *
User-agent: Googlebot
User-agent: Bingbot
User-agent: DuckDuckBot
User-agent: GPTBot
User-agent: ChatGPT-User
User-agent: OAI-SearchBot
User-agent: Google-Extended
User-agent: ClaudeBot
User-agent: Claude-Web
User-agent: anthropic-ai
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: Applebot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: Amazonbot
User-agent: CCBot
User-agent: Meta-ExternalAgent
User-agent: Meta-ExternalFetcher
User-agent: Diffbot
Allow: /
Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import SectionLabel from '../common/SectionLabel.vue'
import HubspotFormEmbed from './HubspotFormEmbed.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
@@ -17,30 +16,6 @@ function tk(suffix: string): TranslationKey {
return `contact.form.${suffix}` as TranslationKey
}
const firstName = ref('')
const lastName = ref('')
const company = ref('')
const phone = ref('')
const selectedPackage = ref('')
const comfyUsage = ref('')
const lookingFor = ref('')
const packageOptions = [
'packageIndividual',
'packageTeams',
'packageEnterprise'
] as const
const usageOptions = [
'usingYesProduction',
'usingYesTesting',
'usingNotYet',
'usingOtherTools'
] as const
const inputClass =
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
const sectionRef = ref<HTMLElement>()
const badgeRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
@@ -55,10 +30,6 @@ useHeroAnimation({
video: formRef,
parallax: false
})
function handleSubmit() {
// TODO: implement form submission
}
</script>
<template>
@@ -105,160 +76,7 @@ function handleSubmit() {
<!-- Right column: form -->
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- First Name + Last Name -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('firstName'), locale) }}*
</label>
<input
v-model="firstName"
type="text"
required
:placeholder="t(tk('firstNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lastName'), locale) }}*
</label>
<input
v-model="lastName"
type="text"
required
:placeholder="t(tk('lastNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
</div>
<!-- Company + Phone -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('company'), locale) }}*
</label>
<input
v-model="company"
type="text"
required
:placeholder="t(tk('companyPlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('phone'), locale) }}
</label>
<input v-model="phone" type="tel" :class="inputClass" />
</div>
</div>
<!-- Package selection -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('packageQuestion'), locale) }}
</p>
<div class="mt-3 flex flex-wrap gap-3">
<label
v-for="opt in packageOptions"
:key="opt"
:class="
cn(
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
selectedPackage === opt
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
)
"
>
<input
v-model="selectedPackage"
type="radio"
name="package"
:value="opt"
class="sr-only"
/>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
selectedPackage === opt
? 'border-primary-comfy-yellow'
: 'border-primary-warm-gray/40'
)
"
>
<span
v-if="selectedPackage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
{{ t(tk(opt), locale) }}
</label>
</div>
</div>
<!-- Comfy usage -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('usingComfy'), locale) }}
</p>
<div class="mt-3 space-y-3">
<label
v-for="opt in usageOptions"
:key="opt"
class="flex cursor-pointer items-center gap-3"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
comfyUsage === opt
? 'border-primary-comfy-yellow'
: 'border-(--site-border-subtle)'
)
"
>
<span
v-if="comfyUsage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
<input
v-model="comfyUsage"
type="radio"
:value="opt"
class="sr-only"
/>
<span class="text-primary-comfy-canvas text-sm">
{{ t(tk(opt), locale) }}
</span>
</label>
</div>
</div>
<!-- Looking for -->
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lookingFor'), locale) }}
</label>
<textarea
v-model="lookingFor"
:placeholder="t(tk('lookingForPlaceholder'), locale)"
:class="cn(inputClass, 'min-h-24 resize-y')"
/>
</div>
<!-- Submit -->
<div>
<BrandButton type="submit" variant="outline" size="sm">
{{ t(tk('submit'), locale) }}
</BrandButton>
</div>
</form>
<HubspotFormEmbed :locale />
</div>
</section>
</template>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
const HUBSPOT_CONTACT_PORTAL_ID = '244637579'
const HUBSPOT_CONTACT_REGION = 'na2'
const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed'
const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js`
const hubspotContactFormIds: Record<Locale, string> = {
en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262',
'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93'
}
const hasEmbedLoadError = ref(false)
const hubspotContactFormId = computed(() => hubspotContactFormIds[locale])
const hubspotFormStyles: Record<`--${string}`, string> = {
'--hsf-global__font-family': "'PP Formula', sans-serif",
'--hsf-global__color': '#c2bfb9',
'--hsf-background__background-color': '#211927',
'--hsf-background__border-width': '0',
'--hsf-background__padding': '0',
'--hsf-button__font-family': "'PP Formula', sans-serif",
'--hsf-button__font-size': '14px',
'--hsf-button__color': '#211927',
'--hsf-button__background-color': '#f2ff59',
'--hsf-button__border-radius': '16px',
'--hsf-button__padding': '10px 24px',
'--hsf-richtext__font-family': "'PP Formula', sans-serif",
'--hsf-richtext__color': '#c2bfb9',
'--hsf-heading__font-family': "'PP Formula', sans-serif",
'--hsf-heading__color': '#c2bfb9',
'--hsf-field-label__font-family': "'PP Formula', sans-serif",
'--hsf-field-label__font-size': '12px',
'--hsf-field-label__color': '#c2bfb9',
'--hsf-field-description__font-family': "'PP Formula', sans-serif",
'--hsf-field-description__color': '#c2bfb9',
'--hsf-field-footer__font-family': "'PP Formula', sans-serif",
'--hsf-field-footer__color': '#c2bfb9',
'--hsf-field-input__font-family': "'PP Formula', sans-serif",
'--hsf-field-input__color': '#c2bfb9',
'--hsf-field-input__background-color': '#2a2230',
'--hsf-field-input__placeholder-color': '#585159',
'--hsf-field-input__border-color': '#3b3539',
'--hsf-field-input__border-width': '1px',
'--hsf-field-input__border-style': 'solid',
'--hsf-field-input__border-radius': '16px',
'--hsf-field-input__padding': '16px',
'--hsf-field-textarea__font-family': "'PP Formula', sans-serif",
'--hsf-field-textarea__color': '#c2bfb9',
'--hsf-field-textarea__background-color': '#2a2230',
'--hsf-field-textarea__placeholder-color': '#585159',
'--hsf-field-textarea__border-color': '#3b3539',
'--hsf-field-textarea__border-width': '1px',
'--hsf-field-textarea__border-style': 'solid',
'--hsf-field-textarea__border-radius': '16px',
'--hsf-field-textarea__padding': '16px',
'--hsf-field-checkbox__color': '#c2bfb9',
'--hsf-field-checkbox__background-color': '#2a2230',
'--hsf-field-checkbox__border-color': '#464147',
'--hsf-field-checkbox__border-width': '1px',
'--hsf-field-checkbox__border-style': 'solid',
'--hsf-field-radio__color': '#c2bfb9',
'--hsf-field-radio__background-color': '#2a2230',
'--hsf-field-radio__border-color': '#464147',
'--hsf-field-radio__border-width': '1px',
'--hsf-field-radio__border-style': 'solid',
'--hsf-erroralert__font-family': "'PP Formula', sans-serif",
'--hsf-infoalert__font-family': "'PP Formula', sans-serif"
}
onMounted(() => {
if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return
const script = document.createElement('script')
script.id = HUBSPOT_CONTACT_SCRIPT_ID
script.src = HUBSPOT_CONTACT_SCRIPT_SRC
script.defer = true
script.addEventListener(
'error',
() => {
hasEmbedLoadError.value = true
script.remove()
},
{ once: true }
)
document.head.append(script)
})
</script>
<template>
<div class="min-h-[640px] w-full">
<p
v-if="hasEmbedLoadError"
class="text-primary-comfy-canvas text-sm/6"
role="status"
>
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
<a
class="text-primary-comfy-yellow underline"
href="mailto:hello@comfy.org"
>
hello@comfy.org
</a>
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
</p>
<div
v-else
:key="hubspotContactFormId"
class="hs-form-html"
:style="hubspotFormStyles"
:data-region="HUBSPOT_CONTACT_REGION"
:data-form-id="hubspotContactFormId"
:data-portal-id="HUBSPOT_CONTACT_PORTAL_ID"
/>
</div>
</template>

View File

@@ -3298,82 +3298,13 @@ const translations = {
en: 'Find your answer here',
'zh-CN': '在这里找到答案'
},
'contact.form.firstName': {
en: 'First name',
'zh-CN': ''
'contact.form.embedLoadErrorPrefix': {
en: 'Unable to load the contact form. Email us at',
'zh-CN': '联系表单无法加载。请发送邮件至'
},
'contact.form.lastName': {
en: 'Last Name',
'zh-CN': ''
},
'contact.form.company': {
en: 'Company',
'zh-CN': '公司'
},
'contact.form.phone': {
en: 'Phone Number (optional)',
'zh-CN': '电话号码(可选)'
},
'contact.form.packageQuestion': {
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
'zh-CN':
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K、个人套餐或团队套餐'
},
'contact.form.packageIndividual': {
en: 'INDIVIDUAL',
'zh-CN': '个人'
},
'contact.form.packageTeams': {
en: 'TEAMS',
'zh-CN': '团队'
},
'contact.form.packageEnterprise': {
en: 'ENTERPRISE',
'zh-CN': '企业'
},
'contact.form.usingComfy': {
en: 'Are you /your team currently using Comfy?',
'zh-CN': '您/您的团队目前是否在使用 Comfy'
},
'contact.form.usingYesProduction': {
en: 'Yes, in production',
'zh-CN': '是,在生产环境中'
},
'contact.form.usingYesTesting': {
en: 'Yes, testing / experimenting',
'zh-CN': '是,测试/实验中'
},
'contact.form.usingNotYet': {
en: 'Not yet, evaluating',
'zh-CN': '尚未使用,评估中'
},
'contact.form.usingOtherTools': {
en: 'Not using Comfy yet, but using other GenAI tools',
'zh-CN': '尚未使用 Comfy但在使用其他 GenAI 工具'
},
'contact.form.lookingFor': {
en: 'What are you looking for?',
'zh-CN': '您在寻找什么?'
},
'contact.form.lookingForPlaceholder': {
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
},
'contact.form.submit': {
en: 'SUBMIT',
'zh-CN': '提交'
},
'contact.form.firstNamePlaceholder': {
en: 'Jane',
'zh-CN': 'Jane'
},
'contact.form.lastNamePlaceholder': {
en: 'Smith',
'zh-CN': 'Smith'
},
'contact.form.companyPlaceholder': {
en: 'jane@acme.org',
'zh-CN': 'jane@acme.org'
'contact.form.embedLoadErrorSuffix': {
en: "and we'll route your request.",
'zh-CN': '我们会为您处理请求。'
},
'customers.story.whatsNext': {

View File

@@ -133,9 +133,15 @@ const websiteJsonLd = {
<script>
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
import { ScrollTrigger } from '../scripts/gsapSetup'
import { initPostHog, capturePageview } from '../scripts/posthog'
initSmoothScroll()
if (import.meta.env.PROD) {
initPostHog()
document.addEventListener('astro:page-load', capturePageview)
}
document.addEventListener('astro:page-load', () => {
ScrollTrigger.refresh()
})

View File

@@ -0,0 +1,36 @@
import posthog from 'posthog-js'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
const POSTHOG_API_HOST =
import.meta.env.PUBLIC_POSTHOG_API_HOST ?? 'https://t.comfy.org'
const POSTHOG_UI_HOST =
import.meta.env.PUBLIC_POSTHOG_UI_HOST ?? 'https://us.posthog.com'
let initialized = false
export function initPostHog() {
if (initialized || typeof window === 'undefined' || !POSTHOG_KEY) return
try {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_API_HOST,
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
})
initialized = true
} catch (error) {
console.error('PostHog init failed', error)
}
}
export function capturePageview() {
if (!initialized) return
try {
posthog.capture('$pageview')
} catch (error) {
console.error('PostHog pageview capture failed', error)
}
}

View File

@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
else process.env.WEBSITE_GITHUB_STARS_OVERRIDE = savedOverride
})
it('uses the build-time override without calling GitHub', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
})
describe('formatStarCount', () => {
it('formats the visual-test override to match committed snapshots', () => {
expect(formatStarCount(110000)).toBe('110K')
})
})

View File

@@ -2,6 +2,9 @@ export async function fetchGitHubStars(
owner: string,
repo: string
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
@@ -25,3 +28,17 @@ export function formatStarCount(count: number): string {
}
return count.toString()
}
function readGitHubStarsOverride(): number | undefined {
const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
if (rawCount === undefined || rawCount === '') return undefined
const count = Number(rawCount)
if (!Number.isSafeInteger(count) || count < 0) {
throw new Error(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
}
return count
}

View File

@@ -96,6 +96,17 @@ pnpm test:browser:local # Run all tests
pnpm test:browser:local widget.spec.ts # Run specific test file
```
### Slowing the browser down for debugging
When running with `--headed` (or `--ui`), set `SLOW_MO` to a millisecond delay
to slow every Playwright action down so you can watch what is happening. The
delay only applies when `PLAYWRIGHT_LOCAL` is set (the default for the
`pnpm test:browser:local` script).
```bash
SLOW_MO=250 pnpm test:browser:local --headed widget.spec.ts
```
## Test Structure
Browser tests in this project follow a specific organization pattern:

View File

@@ -0,0 +1,27 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Preview3D",
"pos": [50, 50],
"size": [450, 600],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "Preview3D",
"Last Time Model File": "nonexistent_model.glb"
},
"widgets_values": ["nonexistent_model.glb"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "offset": [0, 0], "scale": 1 } },
"version": 0.4
}

View File

@@ -1,284 +0,0 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [210, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [210, 168],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [210, 168],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -30,6 +30,13 @@ export class VueNodeHelpers {
return this.page.locator(`[data-node-id="${nodeId}"]`)
}
/**
* Get the inner wrapper element of a Vue node.
*/
getNodeInnerWrapper(nodeId: string): Locator {
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
}
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.
@@ -119,10 +126,9 @@ export class VueNodeHelpers {
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
* Resolve the data-node-id of the first rendered node matching the title.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
async getNodeIdByTitle(title: string): Promise<string> {
const node = this.getNodeByTitle(title).first()
await node.waitFor({ state: 'visible' })
@@ -133,6 +139,15 @@ export class VueNodeHelpers {
)
}
return nodeId
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
const nodeId = await this.getNodeIdByTitle(title)
return new VueNodeFixture(this.getNodeLocator(nodeId))
}

View File

@@ -0,0 +1,54 @@
import type { Locator } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
class BoundingBoxCoordinate {
public readonly root: Locator
public readonly input: Locator
public readonly incrementButton: Locator
public readonly decrementButton: Locator
constructor(root: Locator) {
this.root = root
this.input = root.locator('input')
this.incrementButton = root.getByTestId(TestIds.widgets.increment)
this.decrementButton = root.getByTestId(TestIds.widgets.decrement)
}
async type(value: string | number): Promise<void> {
await this.input.fill(String(value))
await this.input.press('Enter')
}
async focus(): Promise<void> {
await this.input.focus()
}
async increment(): Promise<void> {
await this.incrementButton.click()
}
async decrement(): Promise<void> {
await this.decrementButton.click()
}
}
export class WidgetBoundingBoxFixture {
public readonly root: Locator
public readonly x: BoundingBoxCoordinate
public readonly y: BoundingBoxCoordinate
public readonly width: BoundingBoxCoordinate
public readonly height: BoundingBoxCoordinate
constructor(parent: Locator) {
this.root = parent.getByTestId('bounding-box')
this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x'))
this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y'))
this.width = new BoundingBoxCoordinate(
this.root.getByTestId('bounding-box-width')
)
this.height = new BoundingBoxCoordinate(
this.root.getByTestId('bounding-box-height')
)
}
}

View File

@@ -7,19 +7,17 @@ import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'
type DragAndDropOptions = {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
}
export class DragDropHelper {
constructor(private readonly page: Page) {}
async dragAndDropExternalResource(
options: DragAndDropOptions = {}
options: {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
@@ -145,14 +143,17 @@ export class DragDropHelper {
async dragAndDropFile(
fileName: string,
options: DragAndDropOptions = {}
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropURL(
url: string,
options: DragAndDropOptions = {}
options: {
dropPosition?: Position
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}

View File

@@ -4,6 +4,21 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
export async function enableErrorsOverlay(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
}
/** Dismiss the error overlay (the floating dialog with the dismiss button). */
export async function dismissErrorOverlay(comfyPage: ComfyPage): Promise<void> {
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).toBeHidden()
}
export async function loadWorkflowAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: string

View File

@@ -1,9 +1,35 @@
import type { WebSocketRoute } from '@playwright/test'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
*/
export function buildKSamplerError(
type: NodeError['errors'][number]['type'],
inputName: string,
message: string
): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type,
message,
details: '',
extra_info: { input_name: inputName }
}
]
}
}
/**
* Helper for simulating prompt execution in e2e tests.
*/
@@ -16,13 +42,23 @@ export class ExecutionHelper {
constructor(
comfyPage: ComfyPage,
private readonly ws: WebSocketRoute
private readonly ws?: WebSocketRoute
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
}
private requireWs(): WebSocketRoute {
if (!this.ws) {
throw new Error(
'ExecutionHelper was constructed without a WebSocketRoute; ' +
'pass `ws` to use methods that send WS frames.'
)
}
return this.ws
}
/**
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
* the synthetic job ID.
@@ -39,7 +75,7 @@ export class ExecutionHelper {
})
await this.page.route(
'**/api/prompt',
PROMPT_ROUTE_PATTERN,
async (route) => {
await route.fulfill({
status: 200,
@@ -60,6 +96,31 @@ export class ExecutionHelper {
return jobId
}
async mockValidationFailure(
nodeErrors: Record<string, NodeError>
): Promise<void> {
const response: PromptResponse = {
node_errors: nodeErrors,
error: {
type: 'prompt_outputs_failed_validation',
message: 'Prompt outputs failed validation',
details: ''
}
}
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify(response)
})
},
{ times: 1 }
)
}
/**
* Send a binary `b_preview_with_metadata` WS message (type 4).
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
@@ -89,12 +150,12 @@ export class ExecutionHelper {
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.ws.send(Buffer.from(buf))
this.requireWs().send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
@@ -104,7 +165,7 @@ export class ExecutionHelper {
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
@@ -118,7 +179,7 @@ export class ExecutionHelper {
nodeId: string,
output: Record<string, unknown>
): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'executed',
data: {
@@ -133,7 +194,7 @@ export class ExecutionHelper {
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
@@ -143,7 +204,7 @@ export class ExecutionHelper {
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'execution_error',
data: {
@@ -161,7 +222,7 @@ export class ExecutionHelper {
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
@@ -201,7 +262,7 @@ export class ExecutionHelper {
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }

View File

@@ -5,7 +5,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { cleanupFakeModel } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -282,6 +282,57 @@ test.describe('Load3D', () => {
})
})
test.describe('Load3D silent 404 on missing output model', () => {
test('Does not show an error toast when the output model file is missing (404)', async ({
comfyPage
}) => {
// Intercept model fetch and return 404 to simulate a missing output file
// (e.g. shared workflow opened on a machine that never ran it)
await comfyPage.page.route('**/view?**', (route) =>
route.fulfill({ status: 404, body: 'Not Found' })
)
// This workflow has a Preview3D node with Last Time Model File set,
// triggering the loadFolder: 'output' + silentOnNotFound: true path.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
// Wait for the 404 response before asserting — gives the load attempt time
// to complete without using waitForTimeout
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
await responsePromise
await expect(
comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' })
).toHaveCount(0)
})
test('Shows an error toast when a non-404 error occurs loading the output model', async ({
comfyPage
}) => {
// Intercept with a 500 to simulate a real server error (not 404) — toast must appear
await comfyPage.page.route('**/view?**', (route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
await responsePromise
await expect
.poll(
() =>
comfyPage.toast.visibleToasts
.filter({ hasText: 'Error loading model' })
.count(),
{ timeout: 10000 }
)
.toBeGreaterThan(0)
})
})
test.describe('Load3D initialization failure', () => {
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
comfyPage

View File

@@ -267,5 +267,65 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(afterPlace!.ghost).toBe(false)
}
)
test(
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
true
)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
if (mode === 'vue') {
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
} else {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
}
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const subgraphId = await comfyPage.subgraph.getActiveGraphId()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.app!.canvas.state.ghostNodeId != null
)
)
.toBe(true)
await comfyPage.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
})
.toBe(true)
await expect
.poll(() => comfyPage.subgraph.getActiveGraphId())
.toBe(subgraphId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
)
.toBeNull()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount)
}
)
})
}

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(

View File

@@ -9,7 +9,7 @@ import {
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -6,7 +6,7 @@ import {
cleanupFakeModel,
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -42,8 +42,11 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
// Selection toolbox should be visible with multiple nodes selected
await expect(comfyPage.selectionToolbox).toBeVisible()
// Border is now drawn on canvas, check via screenshot
// Allow small anti-aliasing variance on the canvas-drawn selection border
// (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35)
await expect(comfyPage.canvas).toHaveScreenshot(
'selection-toolbox-multiple-nodes-border.png'
'selection-toolbox-multiple-nodes-border.png',
{ maxDiffPixels: 100 }
)
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -364,34 +364,6 @@ test.describe('Workflows sidebar', () => {
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
})
test('Can upload workflow to library by drag and drop', async ({
comfyPage
}) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
const { workflowsTab } = comfyPage.menu
expect(await workflowsTab.getTopLevelSavedWorkflowNames()).not.toContain(
'default'
)
const sidebarBox = (await comfyPage.page
.locator('.workflows-sidebar-tab')
.boundingBox())!
const dropPosition = {
x: sidebarBox.x + sidebarBox.width / 2,
y: sidebarBox.y + sidebarBox.height / 2
}
await comfyPage.dragDrop.dragAndDropFile('default.json', {
dropPosition,
preserveNativePropagation: true
})
await expect
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
.toContain('default')
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({
'workflow1.json': 'default.json'

View File

@@ -14,8 +14,6 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -42,31 +40,6 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
expect(results).toEqual(widgets.map(() => true))
}
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
@@ -525,29 +498,4 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -121,10 +121,7 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
})
test('should allow fitting group to contents', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -1,9 +1,25 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
enableErrorsOverlay
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -11,24 +27,202 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Expect error state on missing unknown node
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
await expect(unknownNode).toHaveClass(ERROR_CLASS)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID)
).toHaveClass(ERROR_CLASS)
})
test('should display error state when node causes execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
const raiseErrorId =
await comfyPage.vueNodes.getNodeIdByTitle('Raise Error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(raiseErrorId)
).toHaveClass(ERROR_CLASS)
})
test.describe('validation errors', () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
test('shows error ring when a validation error is returned for a node', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
})
test('clears error ring when user edits an out-of-range number widget back into range', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
const exec = new ExecutionHelper(comfyPage)
await test.step('queue with out-of-range steps to surface the error', async () => {
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('edit steps widget so the new value is within range', async () => {
const stepsWidget = comfyPage.vueNodes.getWidgetByName(
'KSampler',
'steps'
)
const controls = comfyPage.vueNodes.getInputNumberControls(stepsWidget)
// ScrubableNumberInput commits on blur — explicit blur avoids a race
// with the keyup-Enter handler in case Enter is consumed elsewhere.
await controls.input.fill('25')
await controls.input.blur()
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user picks a different combo option', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
const exec = new ExecutionHelper(comfyPage)
await test.step('queue with invalid sampler to surface the error', async () => {
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_not_in_list',
'sampler_name',
'sampler_name: bogus_sampler is not in list'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('select a different sampler option', async () => {
await comfyPage.vueNodes.selectComboOption(
'KSampler',
'sampler_name',
'dpmpp_2m'
)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await cleanupFakeModel(comfyPage)
})
test('parent subgraph node shows error ring when an interior node is missing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
'Subgraph with Missing Node'
)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when an interior node has a missing model', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
'Subgraph with Missing Model'
)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when an interior node fails execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
await expect(
innerWrapper,
'subgraph parent must mount before injecting WS execution_error'
).toBeVisible()
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
exec.executionError(
'mocked-prompt',
INNER_EXECUTION_ID,
'boom inside the subgraph'
)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when interior node has a validation error', async ({
comfyPage
}) => {
// Validation errors are keyed by execution id, so an interior error
// ("2:1") must propagate the ring up to the root-level subgraph
// container ("2") via errorAncestorExecutionIds.
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
await expect(innerWrapper).toBeVisible()
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,6 +2,10 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,15 +24,11 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
function getNode(comfyPage: ComfyPage) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
function getWidgets(comfyPage: ComfyPage) {
return getNode(comfyPage).locator('.lg-node-widget')
}
@@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
@@ -58,20 +58,41 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await expect(widgets).toHaveCount(2)
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
const vueNode =
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
await expect(showAdvancedButton).toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeHidden()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeVisible()
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
@@ -92,6 +113,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).toBeHidden()
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
})
})

View File

@@ -0,0 +1,185 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { WidgetBoundingBoxFixture } from '@e2e/fixtures/components/WidgetBoundingBox'
const NODE_ID = '1'
test.describe('Widget Bounding Box', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
})
test(
'Renders all four coordinate inputs with workflow values',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await expect(boundingBox.root).toBeVisible()
await expect(boundingBox.x.input).toHaveValue('0')
await expect(boundingBox.y.input).toHaveValue('0')
await expect(boundingBox.width.input).toHaveValue('512')
await expect(boundingBox.height.input).toHaveValue('512')
}
)
test('Typing into each coordinate updates only that coordinate', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('type X', async () => {
await boundingBox.x.type(25)
await expect(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('0')
await expect.soft(boundingBox.width.input).toHaveValue('512')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Y', async () => {
await boundingBox.y.type(40)
await expect(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.width.input).toHaveValue('512')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Width', async () => {
await boundingBox.width.type(200)
await expect(boundingBox.width.input).toHaveValue('200')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Height', async () => {
await boundingBox.height.type(300)
await expect(boundingBox.height.input).toHaveValue('300')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.width.input).toHaveValue('200')
})
})
test('Negative X/Y values are clamped to min=0', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.x.type(50)
await expect(boundingBox.x.input).toHaveValue('50')
await boundingBox.x.type('-10')
await expect(boundingBox.x.input).toHaveValue('0')
await boundingBox.y.type(75)
await expect(boundingBox.y.input).toHaveValue('75')
await boundingBox.y.type('-50')
await expect(boundingBox.y.input).toHaveValue('0')
})
test('Width/Height values below 1 are clamped to min=1', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.width.type(0)
await expect(boundingBox.width.input).toHaveValue('1')
await boundingBox.height.type('-5')
await expect(boundingBox.height.input).toHaveValue('1')
})
test('Increment and decrement buttons change coordinate by step', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('increment X from 0 to 2', async () => {
await boundingBox.x.increment()
await boundingBox.x.increment()
await expect(boundingBox.x.input).toHaveValue('2')
})
await test.step('decrement X from 2 to 1', async () => {
await boundingBox.x.decrement()
await expect(boundingBox.x.input).toHaveValue('1')
})
await test.step('decrement Width from 512 to 510', async () => {
await boundingBox.width.decrement()
await boundingBox.width.decrement()
await expect(boundingBox.width.input).toHaveValue('510')
})
await test.step('increment Height from 512 to 513', async () => {
await boundingBox.height.increment()
await expect(boundingBox.height.input).toHaveValue('513')
})
})
test('Arrow keys step the focused input; PageUp/PageDown step by 10', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.width.focus()
await boundingBox.width.input.press('ArrowUp')
await expect(boundingBox.width.input).toHaveValue('513')
await boundingBox.width.input.press('ArrowDown')
await boundingBox.width.input.press('ArrowDown')
await expect(boundingBox.width.input).toHaveValue('511')
await boundingBox.width.input.press('PageUp')
await expect(boundingBox.width.input).toHaveValue('521')
await boundingBox.width.input.press('PageDown')
await expect(boundingBox.width.input).toHaveValue('511')
})
test('Decrement button is disabled when value equals min', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('X at 0 disables decrement', async () => {
await expect(boundingBox.x.input).toHaveValue('0')
await expect(boundingBox.x.decrementButton).toBeDisabled()
await expect(boundingBox.x.incrementButton).toBeEnabled()
})
await test.step('Width at 1 disables decrement', async () => {
await boundingBox.width.type(1)
await expect(boundingBox.width.input).toHaveValue('1')
await expect(boundingBox.width.decrementButton).toBeDisabled()
await expect(boundingBox.width.incrementButton).toBeEnabled()
})
await test.step('Incrementing X re-enables decrement', async () => {
await boundingBox.x.increment()
await expect(boundingBox.x.decrementButton).toBeEnabled()
})
})
test('Non-numeric input reverts to previous value on blur', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.x.type(42)
await expect(boundingBox.x.input).toHaveValue('42')
await boundingBox.x.input.fill('not a number')
await boundingBox.x.input.blur()
await expect(boundingBox.x.input).toHaveValue('42')
})
})

View File

@@ -0,0 +1,205 @@
import type { Page, Request } from '@playwright/test'
import type {
ComfyApiWorkflow,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
function isUserdataWorkflowSave(request: Request): boolean {
return (
request.method() === 'POST' &&
/\/api\/userdata\/workflows%2F[^?]+\.json/.test(request.url())
)
}
function collectSaves(page: Page): Disposable & { readonly saves: string[] } {
const saves: string[] = []
function onRequest(request: Request) {
if (isUserdataWorkflowSave(request)) saves.push(request.url())
}
page.on('request', onRequest)
return {
saves,
[Symbol.dispose]() {
page.off('request', onRequest)
}
}
}
async function waitForSave(page: Page, timeout: number): Promise<boolean> {
return page
.waitForRequest(isUserdataWorkflowSave, { timeout })
.then(() => true)
.catch(() => false)
}
/**
* Drag the first node so the change tracker dispatches `graphChanged`.
*/
async function triggerGraphChange(comfyPage: ComfyPage): Promise<void> {
const node = await comfyPage.nodeOps.getFirstNodeRef()
if (!node) throw new Error('Default workflow expected to have a first node')
const titlePos = await node.getTitlePosition()
const absFrom = await comfyPage.canvasOps.toAbsolute(titlePos)
const absTo = { x: absFrom.x + 120, y: absFrom.y + 120 }
await comfyPage.canvasOps.dragAndDrop(absFrom, absTo)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
}
async function setupAutoSaveAfterDelay(
comfyPage: ComfyPage,
delayMs: number
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow('autosave')
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', delayMs)
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'after delay')
}
test.describe('Workflow settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Workflow.AutoSave', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
})
test("'off' does not save modified workflow after delay", async ({
comfyPage
}) => {
await comfyPage.menu.topbar.saveWorkflow('autosave')
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', 50)
await triggerGraphChange(comfyPage)
// Within a window an order of magnitude longer than AutoSaveDelay, the
// off watcher must not write back.
const sawSave = await waitForSave(comfyPage.page, 500)
expect(
sawSave,
'AutoSave=off must not write back after a graph change'
).toBe(false)
})
test("'after delay' saves the workflow after a graph change", async ({
comfyPage
}) => {
await setupAutoSaveAfterDelay(comfyPage, 100)
const savePromise = comfyPage.page.waitForRequest(
isUserdataWorkflowSave,
{ timeout: 4000 }
)
await triggerGraphChange(comfyPage)
await savePromise
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
})
})
test.describe('Comfy.Workflow.AutoSaveDelay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
})
test('long delay defers save until at least the configured duration has elapsed', async ({
comfyPage
}) => {
const LONG_DELAY_MS = 1000
const EARLY_WINDOW_MS = 500
await setupAutoSaveAfterDelay(comfyPage, LONG_DELAY_MS)
using tracker = collectSaves(comfyPage.page)
await triggerGraphChange(comfyPage)
// No save fires within a window comfortably shorter than the delay.
const sawEarlySave = await waitForSave(comfyPage.page, EARLY_WINDOW_MS)
expect(
sawEarlySave,
`No save should fire within ${EARLY_WINDOW_MS}ms when the configured delay is ${LONG_DELAY_MS}ms`
).toBe(false)
// Eventually the save does fire.
await comfyPage.page.waitForRequest(isUserdataWorkflowSave, {
timeout: 3000
})
expect(tracker.saves).toHaveLength(1)
})
})
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
async function getSerializedNodeIds(
comfyPage: ComfyPage
): Promise<NodeId[]> {
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
(n) => n.id
)
}
function ascendingById(ids: NodeId[]): NodeId[] {
return [...ids].sort((a, b) => Number(a) - Number(b))
}
test('false preserves the graph insertion order', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
false
)
const ids = await getSerializedNodeIds(comfyPage)
expect(ids, 'default workflow nodes already sorted').not.toEqual(
ascendingById(ids)
)
})
test('true sorts nodes by id ascending', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
true
)
const ids = await getSerializedNodeIds(comfyPage)
expect(ids).toEqual(ascendingById(ids))
})
test('toggling sort preserves node set in both workflow JSON and API prompt', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
false
)
const expectedIds = ascendingById(await getSerializedNodeIds(comfyPage))
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
true
)
// Workflow JSON nodes (the surface controlled by SortNodeIdOnSave) must
// still contain the same set of ids — sort changes order, not membership.
expect(ascendingById(await getSerializedNodeIds(comfyPage))).toEqual(
expectedIds
)
// The API prompt is independently derived from execution order, but it
// must enumerate the same node set regardless of the sort flag.
const apiPrompt: ComfyApiWorkflow =
await comfyPage.workflow.getExportedWorkflow({ api: true })
expect(ascendingById(Object.keys(apiPrompt).map(Number))).toEqual(
expectedIds
)
})
})
})

View File

@@ -257,6 +257,8 @@ it('should validate node definition', () => {
## Mocking Composables with Reactive State
> **Don't mock `vue-i18n`.** Mount with a real `createI18n` plugin instance instead — see [Don't Mock `vue-i18n` in `vitest-patterns.md`](./vitest-patterns.md#dont-mock-vue-i18n--use-a-real-plugin). This section applies to composables you own.
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules

View File

@@ -30,9 +30,42 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
## Don't Mock `vue-i18n` — Use a Real Plugin
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
Mount with a real `createI18n` instance instead of mocking `vue-i18n`. The plugin is cheap, owned by a third party (don't mock what you don't own), and a real instance exercises the same translation key resolution and pluralization logic that production uses.
This applies to **all tests** that touch a component or composable calling `useI18n()` — not just component tests.
```typescript
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} } // empty — assertions key off the translation key, not the rendered string
})
// Component tests: pass via global plugins
mount(MyComponent, { global: { plugins: [i18n] } })
// Composable tests: provide via a host component (see useMediaAssetActions.test.ts pattern)
const app = createApp(HostComponent)
app.use(i18n)
```
Real example: [`src/components/searchbox/v2/__test__/testUtils.ts`](../../src/components/searchbox/v2/__test__/testUtils.ts) exports a shared `testI18n` instance.
### Asserting on translation keys
With empty messages, `t('foo.bar')` returns `'foo.bar'` (the key). Assert against the key directly — no need to mock `t`:
```typescript
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'mediaAsset.selection.exportStarted' })
)
```
For pluralization / interpolation arguments, spy on the consumer (e.g. the toast `add` fn) and inspect the captured payload, rather than spying on `t` itself.
## Mock Patterns

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.13",
"version": "1.44.15",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -83,6 +83,7 @@
"@tiptap/extension-table-row": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
@@ -113,6 +114,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",

View File

@@ -27,6 +27,23 @@
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
/* Plum */
--color-plum-300: #afa3db;
--color-plum-400: #8d7fc5;
--color-plum-500: #6b5ca8;
--color-plum-600: #49378b;
/* Ink */
--color-ink-100: #5c5362;
--color-ink-200: #4f4754;
--color-ink-300: #413b45;
--color-ink-400: #353139;
--color-ink-500: #312c34;
--color-ink-600: #29252c;
--color-ink-700: #232025;
--color-ink-800: #19161a;
--color-ink-900: #151317;
--color-white: #ffffff;
--color-black: #000000;

View File

@@ -41,10 +41,6 @@
--color-sand-300: #888682;
--color-sand-400: #eed7ac;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-azure-300: #78bae9;
--color-azure-400: #31b9f4;
--color-azure-600: #0b8ce9;
@@ -53,7 +49,6 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9c9eab;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -208,7 +203,7 @@
--node-component-slot-dot-outline-opacity: 5%;
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-ash-800);
--node-component-surface-highlight: var(--color-ash-500);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-surface-hovered: var(--color-smoke-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
@@ -227,7 +222,7 @@
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
--text-secondary: var(--color-ash-500);
--text-secondary: var(--color-smoke-800);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
@@ -264,7 +259,7 @@
--secondary-background-selected
);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
@@ -344,19 +339,19 @@
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-slate-300);
--node-component-header-icon: var(--color-smoke-800);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-slot-text: var(--color-smoke-700);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-600);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-border: var(--color-charcoal-200);
--node-component-tooltip-surface: var(--color-charcoal-800);
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
@@ -374,7 +369,7 @@
);
--color-interface-panel-job-progress-border: var(--base-foreground);
--text-secondary: var(--color-slate-100);
--text-secondary: var(--color-smoke-700);
--text-primary: var(--color-white);
--input-surface: rgb(130 130 130 / 0.1);
@@ -414,7 +409,7 @@
--component-node-widget-background-disabled: var(
--color-alpha-charcoal-600-30
);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);

View File

@@ -4062,6 +4062,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/seedance/virtual-library/assets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["seedanceVirtualLibraryCreateAsset"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/seedance/complete": {
parameters: {
query?: never;
@@ -14490,6 +14506,16 @@ export interface components {
code: string;
message: string;
};
SeedanceVirtualLibraryCreateAssetRequest: {
/** @description Publicly accessible URL of the image asset to upload to the caller's virtual portrait library. */
url: string;
/** @description Client-supplied content hash used as the per-customer dedup key. Re-submitting the same hash returns the existing asset id without re-uploading to BytePlus. */
hash: string;
};
SeedanceVirtualLibraryCreateAssetResponse: {
/** @description BytePlus-issued asset id. Clients poll seedanceGetAsset with this until status == Active. */
asset_id: string;
};
WanVideoGenerationRequest: {
/**
* @description The ID of the model to call
@@ -30338,10 +30364,7 @@ export interface operations {
};
seedanceGetAsset: {
parameters: {
query?: {
/** @description BytePlus project name. Defaults to "default" if omitted. Must match the ProjectName used at create time. */
project_name?: string;
};
query?: never;
header?: never;
path: {
/** @description BytePlus-issued asset id returned by seedanceCreateAsset */
@@ -30371,6 +30394,39 @@ export interface operations {
};
};
};
seedanceVirtualLibraryCreateAsset: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetRequest"];
};
};
responses: {
/** @description Asset creation accepted (asynchronous — poll seedanceGetAsset) */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetResponse"];
};
};
/** @description Error 4xx/5xx */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
seedanceVisualValidateCallback: {
parameters: {
query: {

View File

@@ -8,7 +8,10 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
workers: 1,
use: {
trace: 'on',
video: 'on'
video: 'on',
launchOptions: {
slowMo: Number(process.env.SLOW_MO) || 0
}
}
}
: {

41
pnpm-lock.yaml generated
View File

@@ -162,6 +162,9 @@ catalogs:
'@types/three':
specifier: ^0.169.0
version: 0.169.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1
@@ -360,6 +363,9 @@ catalogs:
unplugin-vue-components:
specifier: ^30.0.0
version: 30.0.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4
@@ -497,6 +503,9 @@ importers:
'@tiptap/starter-kit':
specifier: 'catalog:'
version: 2.27.2
'@vee-validate/zod':
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
'@vueuse/core':
specifier: 'catalog:'
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
@@ -587,6 +596,9 @@ importers:
typegpu:
specifier: 'catalog:'
version: 0.8.2
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -949,6 +961,9 @@ importers:
lenis:
specifier: 'catalog:'
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
posthog-js:
specifier: 'catalog:'
version: 1.358.1
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4721,6 +4736,11 @@ packages:
peerDependencies:
valibot: ^1.2.0
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vercel/analytics@2.0.1':
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
peerDependencies:
@@ -9593,6 +9613,11 @@ packages:
typescript:
optional: true
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.4.26
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -14038,6 +14063,14 @@ snapshots:
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
zod: 3.25.76
transitivePeerDependencies:
- vue
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
optionalDependencies:
react: 19.2.4
@@ -14156,7 +14189,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20051,6 +20084,12 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
type-fest: 4.41.0
vue: 3.5.13(typescript@5.9.3)
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3

View File

@@ -55,6 +55,7 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
@@ -121,6 +122,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vee-validate: ^4.15.1
vite: ^8.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2

View File

@@ -1,5 +1,8 @@
<template>
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<div
class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1"
data-testid="bounding-box"
>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>

View File

@@ -1,7 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { describe, expect, it, vi } from 'vitest'
import EditableText from './EditableText.vue'
@@ -17,10 +15,6 @@ describe('EditableText', () => {
const user = userEvent.setup()
render(EditableText, {
global: {
plugins: [PrimeVue],
components: { InputText }
},
props: {
...props,
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),

View File

@@ -1,24 +1,18 @@
<template>
<div class="editable-text">
<div class="editable-text inline">
<component :is="labelType" v-if="!isEditing" :class="labelClass">
{{ modelValue }}
</component>
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<InputText
<Input
v-else
ref="inputRef"
v-model:model-value="inputValue"
v-model="inputValue"
v-focus
type="text"
size="small"
fluid
:pt="{
root: {
onBlur: finishEditing,
...inputAttrs
}
}"
@keydown.enter.capture.stop="blurInputElement"
class="h-full rounded-none p-0 focus-visible:ring-0"
v-bind="inputAttrs"
@blur="finishEditing"
@keydown.enter.capture.stop="inputRef?.blur()"
@keydown.escape.capture.stop="cancelEditing"
@click.stop
@contextmenu.stop
@@ -29,9 +23,10 @@
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
import Input from '@/components/ui/input/Input.vue'
const {
modelValue,
isEditing = false,
@@ -48,30 +43,23 @@ const {
const emit = defineEmits(['edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const inputRef = ref<InstanceType<typeof Input>>()
const isCanceling = ref(false)
const blurInputElement = () => {
// @ts-expect-error - $el is an internal property of the InputText component
inputRef.value?.$el.blur()
}
const finishEditing = () => {
// Don't save if we're canceling
function finishEditing() {
if (!isCanceling.value) {
emit('edit', inputValue.value)
}
isCanceling.value = false
}
const cancelEditing = () => {
// Set canceling flag to prevent blur from saving
function cancelEditing() {
isCanceling.value = true
// Reset to original value
inputValue.value = modelValue
// Emit cancel event
emit('cancel')
// Blur the input to exit edit mode
blurInputElement()
inputRef.value?.blur()
}
watch(
() => isEditing,
async (newVal) => {
@@ -82,27 +70,14 @@ watch(
const fileName = inputValue.value.includes('.')
? inputValue.value.split('.').slice(0, -1).join('.')
: inputValue.value
const start = 0
const end = fileName.length
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange?.(start, end)
inputRef.value.setSelectionRange(0, fileName.length)
})
}
},
{ immediate: true }
)
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
}
</script>
<style scoped>
.editable-text {
display: inline;
}
.editable-text input {
width: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import { useDialogStore } from '@/stores/dialogStore'
import PromptDialogContent from './PromptDialogContent.vue'
type Props = ComponentProps<typeof PromptDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('PromptDialogContent', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function renderComponent(props: Partial<Props> = {}) {
const user = userEvent.setup()
render(PromptDialogContent, {
global: { plugins: [i18n] },
props: {
message: 'Enter a name',
defaultValue: '',
onConfirm: vi.fn(),
...props
} as Props
})
return { user }
}
it('pre-fills the input with defaultValue', () => {
renderComponent({ defaultValue: 'my workflow' })
expect(screen.getByRole('textbox')).toHaveValue('my workflow')
})
it('calls onConfirm and closes dialog when Confirm is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'renamed')
await user.click(screen.getByRole('button', { name: /confirm/i }))
expect(onConfirm).toHaveBeenCalledWith('renamed')
expect(closeSpy).toHaveBeenCalledOnce()
})
it('calls onConfirm when Enter is pressed inside the input', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'via enter')
await user.keyboard('{Enter}')
expect(onConfirm).toHaveBeenCalledWith('via enter')
})
it('closes dialog when Confirm button is clicked', async () => {
const { user } = renderComponent({ defaultValue: '' })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await user.click(screen.getByRole('button', { name: /confirm/i }))
expect(closeSpy).toHaveBeenCalledOnce()
})
it('selects all text when the input is focused', async () => {
renderComponent({ defaultValue: 'pre-filled text', onConfirm: vi.fn() })
const input = screen.getByRole('textbox') as HTMLInputElement
const spy = vi.spyOn(input, 'setSelectionRange')
await fireEvent.focus(input)
expect(spy).toHaveBeenCalledWith(0, 'pre-filled text'.length)
})
})

View File

@@ -1,49 +1,43 @@
<template>
<div class="prompt-dialog-content flex flex-col gap-2 pt-8">
<FloatLabel>
<InputText
<label class="flex flex-col gap-1 text-sm text-muted-foreground">
{{ message }}
<Input
ref="inputRef"
v-model="inputValue"
type="text"
:placeholder
autofocus
@keyup.enter="onConfirm"
@focus="selectAllText"
@keyup.enter="handleConfirm"
@focus="inputRef?.selectAll()"
/>
<label>{{ message }}</label>
</FloatLabel>
<Button @click="onConfirm">
</label>
<Button @click="handleConfirm">
{{ $t('g.confirm') }}
</Button>
</div>
</template>
<script setup lang="ts">
import FloatLabel from 'primevue/floatlabel'
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
const { message, defaultValue, onConfirm, placeholder } = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(props.defaultValue)
const inputValue = ref<string>(defaultValue)
const onConfirm = () => {
props.onConfirm(inputValue.value)
function handleConfirm() {
onConfirm(inputValue.value)
useDialogStore().closeDialog()
}
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const selectAllText = () => {
if (!inputRef.value) return
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(0, inputElement.value.length)
}
const inputRef = ref<InstanceType<typeof Input>>()
</script>

View File

@@ -0,0 +1,110 @@
import { Form, FormField } from '@primevue/forms'
import { render, screen } from '@testing-library/vue'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import SignUpForm from './SignUpForm.vue'
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
const mockLoadingRef = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoadingRef.value
}
}))
}))
describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(SignUpForm, {
global: {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
}
})
}
describe('Password manager autofill attributes', () => {
it('renders email input with attributes Chrome needs to recognize the field', () => {
renderComponent()
const emailInput = screen.getByPlaceholderText(
enMessages.auth.signup.emailPlaceholder
)
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email')
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveAttribute('type', 'email')
})
it('renders password input with new-password autofill attributes', () => {
renderComponent()
const passwordInput = screen.getByPlaceholderText(
enMessages.auth.signup.passwordPlaceholder
)
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password')
expect(passwordInput).toHaveAttribute('name', 'password')
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
})
it('renders confirm-password input with distinct name and new-password autocomplete', () => {
renderComponent()
const confirmPasswordInput = screen.getByPlaceholderText(
enMessages.auth.login.confirmPasswordPlaceholder
)
expect(confirmPasswordInput).toHaveAttribute(
'id',
'comfy-org-sign-up-confirm-password'
)
expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword')
expect(confirmPasswordInput).toHaveAttribute(
'autocomplete',
'new-password'
)
})
})
})

View File

@@ -15,9 +15,10 @@
</label>
<InputText
pt:root:id="comfy-org-sign-up-email"
pt:root:name="email"
pt:root:autocomplete="email"
class="h-10"
type="text"
type="email"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="$field.invalid"
/>

View File

@@ -8,11 +8,6 @@
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
>
<!-- Native drag area for Electron -->
<div
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
/>
<div
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
>
@@ -189,7 +184,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'

View File

@@ -140,11 +140,11 @@ const iconClass = computed(() => {
const iconColorClass = computed(() => {
if (notification.type === 'queuedPending') {
return 'animate-spin text-slate-100'
return 'animate-spin text-text-secondary'
}
if (notification.type === 'failed') {
return 'text-danger-200'
}
return 'text-slate-100'
return 'text-text-secondary'
})
</script>

View File

@@ -1,15 +1,9 @@
<template>
<SidebarTabTemplate
ref="sidebarTabRef"
:title="title"
v-bind="$attrs"
:data-testid="dataTestid"
:class="
cn(
'workflows-sidebar-tab',
isOverDropZone && 'bg-primary-500/10 ring-4 ring-primary-500 ring-inset'
)
"
class="workflows-sidebar-tab"
>
<template #alt-title>
<slot name="alt-title" />
@@ -146,10 +140,8 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { unrefElement, useDropZone } from '@vueuse/core'
import ConfirmDialog from 'primevue/confirmdialog'
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -170,8 +162,6 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getDataFromJSON } from '@/scripts/metadata/json'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import {
@@ -199,7 +189,6 @@ const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const sidebarTabRef = useTemplateRef('sidebarTabRef')
const searchBoxRef = ref()
@@ -360,34 +349,4 @@ onMounted(async () => {
searchBoxRef.value?.focus()
await workflowBookmarkStore.loadBookmarks()
})
const sidebarTabGetter = () => {
const el = unrefElement(sidebarTabRef)
return el instanceof HTMLElement ? el : undefined
}
const { isOverDropZone } = useDropZone(sidebarTabGetter, {
onDrop: async (files) => {
if (!files?.length) return
await Promise.allSettled(
files.map(async (file) => {
const { workflow } = (await getDataFromJSON(file)) ?? {}
if (!workflow) return
const workflowJSON = await validateComfyWorkflow(workflow)
if (!workflowJSON) return
const comfyWorkflow = workflowStore.createNewTemporary(
file.name,
workflowJSON
)
await workflowStore.closeWorkflow(comfyWorkflow)
await comfyWorkflow.save()
})
)
await workflowStore.syncWorkflows()
},
dataTypes: ['application/json'],
multiple: true,
preventDefaultForUnhandled: false
})
</script>

View File

@@ -209,7 +209,7 @@ const dotClasses = computed(() => {
return 'bg-gold-600'
case 'info':
default:
return 'bg-slate-100'
return 'bg-text-secondary'
}
})

View File

@@ -14,7 +14,12 @@ const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select()
select: () => inputRef.value?.select(),
blur: () => inputRef.value?.blur(),
setSelectionRange: (start: number, end: number) =>
inputRef.value?.setSelectionRange(start, end),
selectAll: () =>
inputRef.value?.setSelectionRange(0, inputRef.value.value.length)
})
</script>

View File

@@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchAutocomplete from './SearchAutocomplete.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
})
describe('SearchAutocomplete', () => {
function renderComponent(props: Record<string, unknown> = {}) {
return render(SearchAutocomplete, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: { template: '<div><slot /></div>' },
ComboboxAnchor: { template: '<div><slot /></div>' },
ComboboxInput: {
template:
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue'],
emits: ['update:modelValue']
},
ComboboxPortal: { template: '<div><slot /></div>' },
ComboboxContent: { template: '<div><slot /></div>' },
ComboboxItem: {
template:
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
emits: ['select']
}
}
},
props: { modelValue: '', ...props }
})
}
describe('suggestions dropdown', () => {
it('does not render items when suggestions list is empty', () => {
renderComponent({ suggestions: [] })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders a button for each suggestion', () => {
renderComponent({ suggestions: ['foo', 'bar'] })
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
})
it('emits select with the suggestion when an item is clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
await user.click(screen.getByText('foo'))
expect(onSelect).toHaveBeenCalledWith('foo')
})
it('updates modelValue to the suggestion label on selection', async () => {
const onUpdateModelValue = vi.fn()
const user = userEvent.setup()
renderComponent({
suggestions: ['foo', 'bar'],
'onUpdate:modelValue': onUpdateModelValue
})
await user.click(screen.getByText('foo'))
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
})
})
describe('with optionLabel', () => {
it('displays the optionLabel property as the suggestion text', () => {
const suggestions = [{ id: 1, query: 'my-extension' }]
renderComponent({ suggestions, optionLabel: 'query' })
expect(screen.getByText('my-extension')).toBeInTheDocument()
})
it('emits the full item object on selection when optionLabel is set', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
const suggestions = [{ id: 1, query: 'my-extension' }]
renderComponent({ suggestions, optionLabel: 'query', onSelect })
await user.click(screen.getByText('my-extension'))
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
})
})
})

View File

@@ -65,34 +65,36 @@
/>
</ComboboxAnchor>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
<ComboboxPortal>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>
@@ -105,6 +107,7 @@ import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot
} from 'reka-ui'
import { computed, ref, watch } from 'vue'

View File

@@ -1,18 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restAttrs } = defineProps<{
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
defineExpose({
focus: () => textareaEl.value?.focus()
})
</script>
<template>
<textarea
v-bind="restAttrs"
ref="textareaEl"
v-model="modelValue"
:class="
cn(

View File

@@ -35,7 +35,7 @@
v-if="$slots.header"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
>
<div class="flex flex-1 shrink-0 gap-2">
<div class="flex min-w-0 flex-1 gap-2">
<Button
v-if="!notMobile && !showLeftPanel"
size="lg"

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
useCoordinateTransform: () => ({
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
})
}))
vi.mock('@/scripts/app', () => ({
app: { registerExtension: vi.fn() }
}))
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useBrushAdjustment } from './useBrushAdjustment'
function makePointerEvent(offsetX: number, offsetY: number): PointerEvent {
return {
offsetX,
offsetY,
preventDefault: vi.fn()
} as unknown as PointerEvent
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
})
describe('startBrushAdjustment', () => {
it('sets brushPreviewGradientVisible to true', async () => {
const store = useMaskEditorStore()
store.brushPreviewGradientVisible = false
const { startBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
expect(store.brushPreviewGradientVisible).toBe(true)
})
})
describe('handleBrushAdjustment', () => {
it('does nothing when startBrushAdjustment has not been called', async () => {
const store = useMaskEditorStore()
const sizeBefore = store.brushSettings.size
const hardnessBefore = store.brushSettings.hardness
const { handleBrushAdjustment } = useBrushAdjustment()
await handleBrushAdjustment(makePointerEvent(200, 100))
expect(store.brushSettings.size).toBe(sizeBefore)
expect(store.brushSettings.hardness).toBe(hardnessBefore)
})
it('does not change size when deltaX is within the dead zone', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
const sizeBefore = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(103, 100))
expect(store.brushSettings.size).toBe(sizeBefore)
})
it('increases size when dragging right past the dead zone', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
const sizeBefore = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(150, 100))
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
})
it('does not compound size when pointer stays at the same position', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
await handleBrushAdjustment(makePointerEvent(150, 100))
const sizeAfterFirstMove = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(150, 100))
expect(store.brushSettings.size).toBe(sizeAfterFirstMove)
})
it('continues increasing size beyond 100px drag (no delta saturation)', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(0, 0))
await handleBrushAdjustment(makePointerEvent(100, 0))
const sizeAt100px = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(300, 0))
const sizeAt300px = store.brushSettings.size
expect(sizeAt300px).toBeGreaterThan(sizeAt100px)
})
it('clamps size to minimum 1 when dragging far left', async () => {
const store = useMaskEditorStore()
store.brushSettings.size = 2
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(500, 100))
await handleBrushAdjustment(makePointerEvent(0, 100))
expect(store.brushSettings.size).toBe(1)
})
it('clamps hardness to maximum 1 when dragging far up', async () => {
const store = useMaskEditorStore()
store.brushSettings.hardness = 0.9
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
brushAdjustmentSpeed: 10
})
await startBrushAdjustment(makePointerEvent(100, 500))
await handleBrushAdjustment(makePointerEvent(100, 0))
expect(store.brushSettings.hardness).toBe(1)
})
it('suppresses hardness change when X delta dominates (useDominantAxis=true)', async () => {
const store = useMaskEditorStore()
store.brushSettings.hardness = 0.5
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
useDominantAxis: true
})
await startBrushAdjustment(makePointerEvent(0, 0))
const sizeBefore = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(100, 10))
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
expect(store.brushSettings.hardness).toBe(0.5)
})
it('suppresses size change when Y delta dominates (useDominantAxis=true)', async () => {
const store = useMaskEditorStore()
store.brushSettings.hardness = 0.5
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
useDominantAxis: true
})
await startBrushAdjustment(makePointerEvent(0, 0))
const sizeBefore = store.brushSettings.size
const hardnessBefore = store.brushSettings.hardness
await handleBrushAdjustment(makePointerEvent(10, 100))
expect(store.brushSettings.size).toBe(sizeBefore)
expect(store.brushSettings.hardness).toBeLessThan(hardnessBefore)
})
})

View File

@@ -0,0 +1,83 @@
import { ref } from 'vue'
import type { Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useCoordinateTransform } from './useCoordinateTransform'
export function useBrushAdjustment(initialSettings?: {
useDominantAxis?: boolean
brushAdjustmentSpeed?: number
}) {
const store = useMaskEditorStore()
const coordinateTransform = useCoordinateTransform()
const initialPoint = ref<Point | null>(null)
const initialBrushSize = ref(0)
const initialBrushHardness = ref(0)
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
store.brushPreviewGradientVisible = true
initialPoint.value = coords_canvas
initialBrushSize.value = store.brushSettings.size
initialBrushHardness.value = store.brushSettings.hardness
}
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
if (!initialPoint.value) {
return
}
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const delta_x = coords_canvas.x - initialPoint.value.x
const delta_y = coords_canvas.y - initialPoint.value.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
if (useDominantAxis.value) {
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0
if (ratio > threshold) {
finalDeltaY = 0
} else if (ratio < 1 / threshold) {
finalDeltaX = 0
}
}
const newSize = Math.max(
1,
Math.min(
500,
initialBrushSize.value + (finalDeltaX / 35) * brushAdjustmentSpeed.value
)
)
const newHardness = Math.max(
0,
Math.min(
1,
initialBrushHardness.value -
(finalDeltaY / 4000) * brushAdjustmentSpeed.value
)
)
store.setBrushSize(newSize)
store.setBrushHardness(newHardness)
}
return { startBrushAdjustment, handleBrushAdjustment }
}

View File

@@ -14,6 +14,7 @@ import { tgpu } from 'typegpu'
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
import { StrokeProcessor } from './StrokeProcessor'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
import { useBrushAdjustment } from './useBrushAdjustment'
import {
resetDirtyRect,
updateDirtyRect,
@@ -30,6 +31,8 @@ export function useBrushDrawing(initialSettings?: {
}) {
const store = useMaskEditorStore()
const persistence = useBrushPersistence()
const { startBrushAdjustment, handleBrushAdjustment } =
useBrushAdjustment(initialSettings)
const coordinateTransform = useCoordinateTransform()
@@ -63,10 +66,6 @@ export function useBrushDrawing(initialSettings?: {
// Stroke processor instance
let strokeProcessor: StrokeProcessor | null = null
const initialPoint = ref<Point | null>(null)
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
persistence.loadAndApply()
// Handle external clear events
@@ -753,78 +752,6 @@ export function useBrushDrawing(initialSettings?: {
}
}
/**
* Starts the brush adjustment interaction.
* @param event - The pointer event.
*/
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
store.brushPreviewGradientVisible = true
initialPoint.value = coords_canvas
}
/**
* Handles the brush adjustment movement.
* @param event - The pointer event.
*/
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
if (!initialPoint.value) {
return
}
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const delta_x = coords_canvas.x - initialPoint.value.x
const delta_y = coords_canvas.y - initialPoint.value.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
if (useDominantAxis.value) {
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0
if (ratio > threshold) {
finalDeltaY = 0
} else if (ratio < 1 / threshold) {
finalDeltaX = 0
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
const newSize = Math.max(
1,
Math.min(
500,
store.brushSettings.size +
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
)
)
const newHardness = Math.max(
0,
Math.min(
1,
store.brushSettings.hardness -
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
)
)
store.setBrushSize(newSize)
store.setBrushHardness(newHardness)
}
/**
* Reads back the GPU textures to CPU ImageDatas.
* @returns Object containing mask and rgb ImageDatas.

View File

@@ -0,0 +1,70 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock } = vi.hoisted(() => ({
canvasInteractionsMock: {
handleWheel: vi.fn(),
handlePointer: vi.fn(),
forwardEventToCanvas: vi.fn()
}
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: () => canvasInteractionsMock
}))
// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.)
// that we cannot pull in here, so we stub only the constant we need.
vi.mock('@/scripts/app', () => ({
ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview'
}))
describe('useNodeAnimatedImage', () => {
function setup() {
vi.clearAllMocks()
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
showAnimatedPreview(node)
const element = node.widgets[0].element
document.body.append(element)
onTestFinished(() => element.remove())
return { node, element, showAnimatedPreview, removeAnimatedPreview }
}
it('forwards non-right-click pointer events and wheel to the canvas while alive', () => {
const { element } = setup()
element.dispatchEvent(new WheelEvent('wheel'))
element.dispatchEvent(new PointerEvent('pointermove'))
element.dispatchEvent(new PointerEvent('pointerup'))
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
const { element } = setup()
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
})
it('detaches every listener when the preview is removed', () => {
const { node, element, removeAnimatedPreview } = setup()
removeAnimatedPreview(node)
element.dispatchEvent(new WheelEvent('wheel'))
element.dispatchEvent(new PointerEvent('pointermove'))
element.dispatchEvent(new PointerEvent('pointerup'))
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
})

View File

@@ -1,3 +1,4 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
@@ -39,17 +40,20 @@ export function useNodeAnimatedImage() {
const { handleWheel, handlePointer, forwardEventToCanvas } =
useCanvasInteractions()
node.imgs[0].style.pointerEvents = 'none'
element.addEventListener('wheel', handleWheel)
element.addEventListener('pointermove', handlePointer)
element.addEventListener('pointerup', handlePointer)
const controller = new AbortController()
const { signal } = controller
element.addEventListener('wheel', handleWheel, { signal })
element.addEventListener('pointermove', handlePointer, { signal })
element.addEventListener('pointerup', handlePointer, { signal })
element.addEventListener(
'pointerdown',
(e) => {
return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)
},
true
(e) => (e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)),
{ capture: true, signal }
)
widget.onRemove = useChainCallback(widget.onRemove, () => {
controller.abort()
})
widget.serialize = false
widget.serializeValue = () => undefined
}

View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeVideo } from '@/composables/node/useNodeImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
canvasInteractionsMock: {
handleWheel: vi.fn(),
handlePointer: vi.fn()
},
nodeOutputStoreMock: {
getNodeImageUrls: vi.fn<(node: unknown) => string[] | undefined>()
}
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: () => canvasInteractionsMock
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => nodeOutputStoreMock
}))
vi.mock('@/utils/imageUtil', () => ({
fitDimensionsToNodeWidth: () => ({ minHeight: 256, minWidth: 256 })
}))
describe('useNodeVideo', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
async function setup() {
vi.clearAllMocks()
vi.useFakeTimers()
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
const node = createMockMediaNode({
size: [400, 400],
graph: { setDirtyCanvas: vi.fn() }
})
const createdVideos: HTMLVideoElement[] = []
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = realCreateElement(tag, opts)
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
return el
}
)
const { showPreview } = useNodeVideo(node)
showPreview()
// happy-dom does not auto-fire onloadeddata for src assignment, so we
// manually trigger it, then drain the resulting promise chain.
const video = createdVideos[0]
video.onloadeddata?.(new Event('loadeddata'))
await vi.runAllTimersAsync()
onTestFinished(() => {
node.widgets[0]?.onRemove?.()
})
return { node, video }
}
it('creates a video-preview widget and forwards canvas events while alive', async () => {
const { node, video } = await setup()
expect(node.widgets[0]?.name).toBe('video-preview')
video.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(2)
})
it('detaches every listener when the widget is removed', async () => {
const { node, video } = await setup()
node.widgets[0]?.onRemove?.()
video.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
})
})

View File

@@ -1,3 +1,4 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -151,11 +152,6 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
const video = document.createElement('video')
Object.assign(video, VIDEO_DEFAULT_OPTIONS)
// Add event listeners for canvas interactions
video.addEventListener('wheel', handleWheel)
video.addEventListener('pointermove', handlePointer)
video.addEventListener('pointerdown', handlePointer)
video.onloadeddata = () => {
setMinDimensions(video)
resolve(video)
@@ -176,6 +172,16 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
minHeight,
minWidth
})
const controller = new AbortController()
const { signal } = controller
container.addEventListener('wheel', handleWheel, { signal })
container.addEventListener('pointermove', handlePointer, { signal })
container.addEventListener('pointerdown', handlePointer, { signal })
widget.onRemove = useChainCallback(widget.onRemove, () => {
controller.abort()
})
}
}

View File

@@ -1,6 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
@@ -14,11 +17,29 @@ vi.mock('primevue/usetoast', () => ({
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
function setupComposable(): ReturnType<typeof useReconnectingNotification> {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
reconnecting: 'Reconnecting',
reconnected: 'Reconnected'
}
}
}
})
}))
let result!: ReturnType<typeof useReconnectingNotification>
const Wrapper = defineComponent({
setup() {
result = useReconnectingNotification()
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
return result
}
const settingMocks = vi.hoisted(() => ({
disableToast: false
@@ -47,7 +68,7 @@ describe('useReconnectingNotification', () => {
})
it('does not show toast immediately on reconnecting', () => {
const { onReconnecting } = useReconnectingNotification()
const { onReconnecting } = setupComposable()
onReconnecting()
@@ -55,7 +76,7 @@ describe('useReconnectingNotification', () => {
})
it('shows error toast after delay', () => {
const { onReconnecting } = useReconnectingNotification()
const { onReconnecting } = setupComposable()
onReconnecting()
vi.advanceTimersByTime(1500)
@@ -63,13 +84,13 @@ describe('useReconnectingNotification', () => {
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
summary: 'Reconnecting'
})
)
})
it('suppresses toast when reconnected before delay expires', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
const { onReconnecting, onReconnected } = setupComposable()
onReconnecting()
vi.advanceTimersByTime(500)
@@ -81,7 +102,7 @@ describe('useReconnectingNotification', () => {
})
it('removes toast and shows success when reconnected after delay', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
const { onReconnecting, onReconnected } = setupComposable()
onReconnecting()
vi.advanceTimersByTime(1500)
@@ -92,13 +113,13 @@ describe('useReconnectingNotification', () => {
expect(mockToastRemove).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
summary: 'Reconnecting'
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'g.reconnected',
summary: 'Reconnected',
life: 2000
})
)
@@ -106,7 +127,7 @@ describe('useReconnectingNotification', () => {
it('does nothing when toast is disabled via setting', () => {
settingMocks.disableToast = true
const { onReconnecting, onReconnected } = useReconnectingNotification()
const { onReconnecting, onReconnected } = setupComposable()
onReconnecting()
vi.advanceTimersByTime(1500)
@@ -117,7 +138,7 @@ describe('useReconnectingNotification', () => {
})
it('does nothing when onReconnected is called without prior onReconnecting', () => {
const { onReconnected } = useReconnectingNotification()
const { onReconnected } = setupComposable()
onReconnected()
@@ -126,7 +147,7 @@ describe('useReconnectingNotification', () => {
})
it('handles multiple reconnecting events without duplicating toasts', () => {
const { onReconnecting } = useReconnectingNotification()
const { onReconnecting } = setupComposable()
onReconnecting()
vi.advanceTimersByTime(1500) // first toast fires

View File

@@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget {
* origin.
*/
readonly disambiguatingSourceNodeId?: string
/** Whether the resolved source widget is workflow-persistent. */
readonly sourceSerialize: boolean
}
export function isPromotedWidgetView(

View File

@@ -1,6 +1,3 @@
import { isEqual } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
@@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
}
const designTokenCache = new Map<string, string>()
const promotedSourceWriteMetaByGraph = new WeakMap<
LGraph,
Map<string, PromotedSourceWriteMeta>
>()
interface PromotedSourceWriteMeta {
value: IBaseWidget['value']
writerInstanceId: string
}
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
value: TValue
): TValue {
return value != null && typeof value === 'object'
? (JSON.parse(JSON.stringify(value)) as TValue)
: value
}
function getPromotedSourceWriteMeta(
graph: LGraph,
sourceKey: string
): PromotedSourceWriteMeta | undefined {
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
}
function setPromotedSourceWriteMeta(
graph: LGraph,
sourceKey: string,
meta: PromotedSourceWriteMeta
): void {
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
if (!metaBySource) {
metaBySource = new Map<string, PromotedSourceWriteMeta>()
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
}
metaBySource.set(sourceKey, meta)
}
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
@@ -117,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
readonly serialize = false
/**
* Whether the resolved source widget is workflow-persistent.
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
* whose source sets serialize = false.
*/
get sourceSerialize(): boolean {
return this.resolveDeepest()?.widget.serialize !== false
}
last_y?: number
computedHeight?: number
@@ -198,52 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.resolveDeepest()?.widget.linkedWidgets
}
private get _instanceKey(): string {
return this.disambiguatingSourceNodeId
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
: `${this.sourceNodeId}:${this.sourceWidgetName}`
}
private get _sharedSourceKey(): string {
return this.disambiguatingSourceNodeId
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
}
get value(): IBaseWidget['value'] {
return this.getTrackedValue()
}
/**
* Execution-time serialization — returns the per-instance value stored
* during configure, falling back to the regular value getter.
*
* The widget state store is shared across instances (keyed by inner node
* ID), so the regular getter returns the last-configured value for all
* instances. graphToPrompt already prefers serializeValue over .value,
* so this is the hook that makes multi-instance execution correct.
*/
serializeValue(): IBaseWidget['value'] {
return this.getTrackedValue()
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
this.captureSiblingFallbackValues()
// Keep per-instance map in sync for execution (graphToPrompt)
this.subgraphNode._instanceWidgetValues.set(
this._instanceKey,
cloneWidgetValue(value)
)
setPromotedSourceWriteMeta(
this.subgraphNode.rootGraph,
this._sharedSourceKey,
{
value: cloneWidgetValue(value),
writerInstanceId: String(this.subgraphNode.id)
}
)
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
@@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolved
}
private getTrackedValue(): IBaseWidget['value'] {
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
this._instanceKey
)
const sharedValue = this.getSharedValue()
if (instanceValue === undefined) return sharedValue
const sourceWriteMeta = getPromotedSourceWriteMeta(
this.subgraphNode.rootGraph,
this._sharedSourceKey
)
if (
sharedValue !== undefined &&
sourceWriteMeta &&
!isEqual(sharedValue, sourceWriteMeta.value)
) {
this.subgraphNode._instanceWidgetValues.set(
this._instanceKey,
cloneWidgetValue(sharedValue)
)
return sharedValue
}
return instanceValue as IBaseWidget['value']
}
private getSharedValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
@@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
.filter((state): state is WidgetState => state !== undefined)
}
private captureSiblingFallbackValues(): void {
const { rootGraph } = this.subgraphNode
for (const node of rootGraph.nodes) {
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
if (node._instanceWidgetValues.has(this._instanceKey)) continue
const siblingView = node.widgets.find(
(widget): widget is IPromotedWidgetView =>
isPromotedWidgetView(widget) &&
widget.sourceNodeId === this.sourceNodeId &&
widget.sourceWidgetName === this.sourceWidgetName &&
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
)
if (!siblingView) continue
node._instanceWidgetValues.set(
this._instanceKey,
cloneWidgetValue(siblingView.value)
)
}
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget

View File

@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize stores widgets_values for promoted views', () => {
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => {
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toEqual(['value'])
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
})
test('serialize preserves proxyWidgets in properties', () => {

View File

@@ -497,7 +497,8 @@ useExtensionService().registerExtension({
const settings = {
loadFolder: 'output',
modelWidget: modelWidget,
cameraState: cameraState
cameraState: cameraState,
silentOnNotFound: true
}
config.configure(settings)
@@ -528,7 +529,8 @@ useExtensionService().registerExtension({
loadFolder: 'output',
modelWidget: modelWidget,
cameraState: cameraState,
bgImagePath: bgImagePath
bgImagePath: bgImagePath,
silentOnNotFound: true
}
config.configure(settings)

View File

@@ -1,11 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
GizmoConfig,
ModelConfig
} from '@/extensions/core/load3d/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
@@ -162,3 +164,88 @@ describe('Load3DConfiguration.loadModelConfig', () => {
expect(result.gizmo).toEqual(fullGizmo)
})
})
describe('Load3DConfiguration.silentOnNotFound propagation', () => {
let loadModelSpy: ReturnType<typeof vi.fn>
function makeLoad3dMock(): Load3d {
loadModelSpy = vi.fn().mockResolvedValue(undefined)
return {
loadModel: loadModelSpy,
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=model.glb'
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('configureForSaveMesh forwards silentOnNotFound: true to loadModel', async () => {
const config = new Load3DConfiguration(makeLoad3dMock())
config.configureForSaveMesh('output', 'model.glb', {
silentOnNotFound: true
})
await flush()
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
silentOnNotFound: true
})
})
it('configureForSaveMesh uses silentOnNotFound: false when option is omitted', async () => {
const config = new Load3DConfiguration(makeLoad3dMock())
config.configureForSaveMesh('output', 'model.glb')
await flush()
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
silentOnNotFound: false
})
})
it('configure forwards silentOnNotFound: true from settings to loadModel', async () => {
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output',
silentOnNotFound: true
})
await flush()
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
silentOnNotFound: true
})
})
it('configure uses silentOnNotFound: false when setting is omitted', async () => {
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output'
})
await flush()
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
silentOnNotFound: false
})
})
})

View File

@@ -21,6 +21,7 @@ type Load3DConfigurationSettings = {
width?: IBaseWidget
height?: IBaseWidget
bgImagePath?: string
silentOnNotFound?: boolean
}
class Load3DConfiguration {
@@ -29,8 +30,16 @@ class Load3DConfiguration {
private properties?: Dictionary<NodeProperty | undefined>
) {}
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
configureForSaveMesh(
loadFolder: 'input' | 'output',
filePath: string,
options?: { silentOnNotFound?: boolean }
) {
this.setupModelHandlingForSaveMesh(
filePath,
loadFolder,
options?.silentOnNotFound ?? false
)
this.setupDefaultProperties()
}
@@ -38,7 +47,8 @@ class Load3DConfiguration {
this.setupModelHandling(
setting.modelWidget,
setting.loadFolder,
setting.cameraState
setting.cameraState,
setting.silentOnNotFound ?? false
)
this.setupTargetSize(setting.width, setting.height)
this.setupDefaultProperties(setting.bgImagePath)
@@ -58,8 +68,16 @@ class Load3DConfiguration {
}
}
private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) {
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
private setupModelHandlingForSaveMesh(
filePath: string,
loadFolder: string,
silentOnNotFound: boolean
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
undefined,
silentOnNotFound
)
if (filePath) {
onModelWidgetUpdate(filePath)
@@ -69,11 +87,13 @@ class Load3DConfiguration {
private setupModelHandling(
modelWidget: IBaseWidget,
loadFolder: string,
cameraState?: CameraState
cameraState?: CameraState,
silentOnNotFound: boolean = false
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
cameraState
cameraState,
silentOnNotFound
)
if (modelWidget.value) {
onModelWidgetUpdate(modelWidget.value)
@@ -241,7 +261,8 @@ class Load3DConfiguration {
private createModelUpdateHandler(
loadFolder: string,
cameraState?: CameraState
cameraState?: CameraState,
silentOnNotFound: boolean = false
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
@@ -258,7 +279,7 @@ class Load3DConfiguration {
)
)
await this.load3d.loadModel(modelUrl, filename)
await this.load3d.loadModel(modelUrl, filename, { silentOnNotFound })
const modelConfig = this.loadModelConfig()
this.applyModelConfig(modelConfig)

View File

@@ -22,6 +22,7 @@ import type {
EventCallback,
GizmoMode,
Load3DOptions,
LoadModelOptions,
MaterialMode,
UpDirection
} from './interfaces'
@@ -500,7 +501,11 @@ class Load3d {
return this._loadGeneration
}
async loadModel(url: string, originalFileName?: string): Promise<void> {
async loadModel(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
this._loadGeneration += 1
if (this.loadingPromise) {
@@ -509,7 +514,11 @@ class Load3d {
} catch (e) {}
}
this.loadingPromise = this._loadModelInternal(url, originalFileName)
this.loadingPromise = this._loadModelInternal(
url,
originalFileName,
options
)
return this.loadingPromise
}
@@ -525,7 +534,8 @@ class Load3d {
private async _loadModelInternal(
url: string,
originalFileName?: string
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
@@ -533,7 +543,7 @@ class Load3d {
this.modelManager.clearModel()
this.animationManager.dispose()
await this.loaderManager.loadModel(url, originalFileName)
await this.loaderManager.loadModel(url, originalFileName, options)
// Auto-detect and setup animations if present
if (this.modelManager.currentModel) {

View File

@@ -436,6 +436,55 @@ describe('LoaderManager', () => {
expect(consoleError).toHaveBeenCalled()
})
it('suppresses the alert on a 404 when silentOnNotFound is set', async () => {
const { lm } = makeLoaderManager()
const notFound = new Error(
'fetch for "..." responded with 404: Not Found'
)
meshLoad.mockRejectedValueOnce(notFound)
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb', undefined, {
silentOnNotFound: true
})
expect(consoleError).toHaveBeenCalled()
expect(addAlert).not.toHaveBeenCalledWith(
'toastMessages.errorLoadingModel'
)
})
it('detects a 404 from the response status field on three.js HttpError', async () => {
const { lm } = makeLoaderManager()
const httpError = Object.assign(new Error('not found'), {
response: { status: 404 }
})
meshLoad.mockRejectedValueOnce(httpError)
vi.spyOn(console, 'error').mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb', undefined, {
silentOnNotFound: true
})
expect(addAlert).not.toHaveBeenCalledWith(
'toastMessages.errorLoadingModel'
)
})
it('still alerts on non-404 errors when silentOnNotFound is set', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockRejectedValueOnce(new Error('parse failure: bad header'))
vi.spyOn(console, 'error').mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb', undefined, {
silentOnNotFound: true
})
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
})
it('discards the result of a stale load when a newer one has started', async () => {
const { lm, modelManager, eventManager } = makeLoaderManager()

View File

@@ -10,10 +10,24 @@ import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import type {
EventManagerInterface,
LoadModelOptions,
LoaderManagerInterface,
ModelManagerInterface
} from './interfaces'
/**
* three.js's HttpError attaches the failed `Response` to the thrown Error.
* fetchModelData throws a plain Error whose message embeds the status code.
* Detect both forms so we can keep the toast for parse / network failures
* but stay silent on 404 when the caller opted in.
*/
function isNotFoundError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const withResponse = error as Error & { response?: { status?: number } }
if (withResponse.response?.status === 404) return true
return /\b404\b/.test(error.message)
}
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
@@ -53,7 +67,11 @@ export class LoaderManager implements LoaderManagerInterface {
dispose(): void {}
async loadModel(url: string, originalFileName?: string): Promise<void> {
async loadModel(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
const loadId = ++this.currentLoadId
try {
@@ -105,7 +123,9 @@ export class LoaderManager implements LoaderManagerInterface {
if (loadId === this.currentLoadId) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
if (!(options?.silentOnNotFound && isNotFoundError(error))) {
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
}
}
}

View File

@@ -198,8 +198,23 @@ export interface ModelManagerInterface {
setupModelMaterials(model: THREE.Object3D): void
}
export interface LoadModelOptions {
/**
* When true, suppress the user-facing toast for file-not-found
* (HTTP 404) errors. Other errors (parse failures, network drops)
* still surface a toast. Use for "preview" surfaces whose model
* file is server-produced and may legitimately be absent locally
* (e.g. shared workflows on a fresh machine).
*/
silentOnNotFound?: boolean
}
export interface LoaderManagerInterface {
init(): void
dispose(): void
loadModel(url: string, originalFileName?: string): Promise<void>
loadModel(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void>
}

View File

@@ -103,7 +103,9 @@ useExtensionService().registerExtension({
const loadFolder = fileInfo.type as 'input' | 'output'
config.configureForSaveMesh(loadFolder, filePath)
config.configureForSaveMesh(loadFolder, filePath, {
silentOnNotFound: true
})
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''

View File

@@ -0,0 +1,240 @@
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
setActor: vi.fn()
}
}))
function createGhostTestHarness() {
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
document.body.appendChild(canvasElement)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
const graph = new LGraph()
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
const node = new LGraphNode('test')
node.size = [200, 100]
graph.add(node)
return { canvas, canvasElement, graph, node }
}
describe('LGraphCanvas ghost placement auto-pan', () => {
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
let node: LGraphNode
beforeEach(() => {
vi.useFakeTimers()
;({ canvas, canvasElement, node } = createGhostTestHarness())
// Near left edge so autopan fires by default
canvas.mouse[0] = 5
canvas.mouse[1] = 300
})
afterEach(() => {
if (canvas.state.ghostNodeId != null) canvas.finalizeGhostPlacement(false)
canvasElement.remove()
vi.useRealTimers()
})
it('moves the ghost node when pointer is near edge', () => {
canvas.startGhostPlacement(node)
const posXBefore = node.pos[0]
vi.advanceTimersByTime(16)
expect(node.pos[0]).not.toBe(posXBefore)
})
it('does not pan when pointer is in the center', () => {
canvas.mouse[0] = 400
canvas.startGhostPlacement(node)
const offsetBefore = [...canvas.ds.offset]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
})
it('cleans up autopan and stops responding to document pointermove on finalize', () => {
const processMoveSpy = vi.spyOn(canvas, 'processMouseMove')
canvas.startGhostPlacement(node)
expect(canvas['_autoPan']).not.toBeNull()
document.dispatchEvent(new MouseEvent('pointermove'))
expect(processMoveSpy).toHaveBeenCalled()
processMoveSpy.mockClear()
canvas.finalizeGhostPlacement(false)
expect(canvas['_autoPan']).toBeNull()
document.dispatchEvent(new MouseEvent('pointermove'))
expect(processMoveSpy).not.toHaveBeenCalled()
})
it('survives linkConnector reset during ghost placement', () => {
canvas.startGhostPlacement(node)
canvas.linkConnector.reset()
expect(canvas['_autoPan']).not.toBeNull()
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).not.toBe(0)
})
})
describe('LGraphCanvas ghost placement cancellation via document keydown', () => {
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
let graph: LGraph
let node: LGraphNode
beforeEach(() => {
;({ canvas, canvasElement, graph, node } = createGhostTestHarness())
})
afterEach(() => {
if (canvas.state.ghostNodeId != null) canvas.finalizeGhostPlacement(false)
canvasElement.remove()
})
it('Escape on document removes the ghost node and clears ghost state', async () => {
canvas.startGhostPlacement(node)
expect(canvas.state.ghostNodeId).toBe(node.id)
await userEvent.keyboard('{Escape}')
expect(canvas.state.ghostNodeId).toBeNull()
expect(graph.getNodeById(node.id)).toBeFalsy()
})
it('Escape on document stops propagation so window-level keybindings do not fire', async () => {
const windowSpy = vi.fn()
window.addEventListener('keydown', windowSpy)
try {
canvas.startGhostPlacement(node)
await userEvent.keyboard('{Escape}')
expect(windowSpy).not.toHaveBeenCalled()
} finally {
window.removeEventListener('keydown', windowSpy)
}
})
it('Delete and Backspace also cancel ghost placement', async () => {
canvas.startGhostPlacement(node)
await userEvent.keyboard('{Delete}')
expect(canvas.state.ghostNodeId).toBeNull()
expect(graph.getNodeById(node.id)).toBeFalsy()
const node2 = new LGraphNode('test-2')
node2.size = [200, 100]
graph.add(node2)
canvas.startGhostPlacement(node2)
await userEvent.keyboard('{Backspace}')
expect(canvas.state.ghostNodeId).toBeNull()
expect(graph.getNodeById(node2.id)).toBeFalsy()
})
it('non-cancel keys do not finalize ghost placement', async () => {
canvas.startGhostPlacement(node)
const windowSpy = vi.fn()
window.addEventListener('keydown', windowSpy)
try {
await userEvent.keyboard('a')
expect(canvas.state.ghostNodeId).toBe(node.id)
expect(windowSpy).toHaveBeenCalledTimes(1)
} finally {
window.removeEventListener('keydown', windowSpy)
}
})
it('keydown listener is removed when ghost placement finalizes', async () => {
canvas.startGhostPlacement(node)
canvas.finalizeGhostPlacement(false)
const windowSpy = vi.fn()
window.addEventListener('keydown', windowSpy)
try {
await userEvent.keyboard('{Escape}')
expect(windowSpy).toHaveBeenCalledTimes(1)
} finally {
window.removeEventListener('keydown', windowSpy)
}
})
it('switching the active graph cancels any in-flight ghost', async () => {
canvas.startGhostPlacement(node)
expect(canvas.state.ghostNodeId).toBe(node.id)
canvas.setGraph(new LGraph())
expect(canvas.state.ghostNodeId).toBeNull()
expect(graph.getNodeById(node.id)).toBeFalsy()
// Listener should also be gone — Escape should reach the window now
const windowSpy = vi.fn()
window.addEventListener('keydown', windowSpy)
try {
await userEvent.keyboard('{Escape}')
expect(windowSpy).toHaveBeenCalledTimes(1)
} finally {
window.removeEventListener('keydown', windowSpy)
}
})
it('calling startGhostPlacement again cancels the previous ghost without leaking listeners', async () => {
canvas.startGhostPlacement(node)
const node2 = new LGraphNode('test-2')
node2.size = [200, 100]
graph.add(node2)
canvas.startGhostPlacement(node2)
expect(graph.getNodeById(node.id)).toBeFalsy()
expect(canvas.state.ghostNodeId).toBe(node2.id)
canvas.finalizeGhostPlacement(true)
const windowSpy = vi.fn()
window.addEventListener('keydown', windowSpy)
try {
await userEvent.keyboard('{Escape}')
// If a stale listener leaked, it would have stopPropagation'd this Escape.
expect(windowSpy).toHaveBeenCalledTimes(1)
} finally {
window.removeEventListener('keydown', windowSpy)
}
})
})

View File

@@ -1,107 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
setActor: vi.fn()
}
}))
describe('LGraphCanvas ghost placement auto-pan', () => {
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
let graph: LGraph
let node: LGraphNode
beforeEach(() => {
vi.useFakeTimers()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
document.body.appendChild(canvasElement)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
node = new LGraphNode('test')
node.size = [200, 100]
graph.add(node)
// Near left edge so autopan fires by default
canvas.mouse[0] = 5
canvas.mouse[1] = 300
})
afterEach(() => {
canvasElement.remove()
vi.useRealTimers()
})
it('moves the ghost node when pointer is near edge', () => {
canvas.startGhostPlacement(node)
const posXBefore = node.pos[0]
vi.advanceTimersByTime(16)
expect(node.pos[0]).not.toBe(posXBefore)
})
it('does not pan when pointer is in the center', () => {
canvas.mouse[0] = 400
canvas.startGhostPlacement(node)
const offsetBefore = [...canvas.ds.offset]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
})
it('cleans up autopan and document listener on finalize', () => {
const removeSpy = vi.spyOn(document, 'removeEventListener')
canvas.startGhostPlacement(node)
expect(canvas['_autoPan']).not.toBeNull()
canvas.finalizeGhostPlacement(false)
expect(canvas['_autoPan']).toBeNull()
expect(removeSpy).toHaveBeenCalledWith('pointermove', expect.any(Function))
removeSpy.mockRestore()
})
it('survives linkConnector reset during ghost placement', () => {
canvas.startGhostPlacement(node)
canvas.linkConnector.reset()
expect(canvas['_autoPan']).not.toBeNull()
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).not.toBe(0)
})
})

View File

@@ -683,6 +683,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
private _visibleReroutes: Set<Reroute> = new Set()
private _autoPan: AutoPanController | null = null
private _ghostPointerHandler: ((e: PointerEvent) => void) | null = null
private _ghostKeyHandler: ((e: KeyboardEvent) => void) | null = null
dirty_canvas: boolean = true
dirty_bgcanvas: boolean = true
@@ -1859,6 +1860,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const { graph } = this
if (newGraph === graph) return
// Drop any in-flight ghost so listeners don't outlive the graph it belongs to
if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true)
this.clear()
newGraph.attachCanvas(this)
@@ -3662,6 +3666,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param dragEvent Optional mouse event for positioning under cursor
*/
startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void {
// Cancel any in-flight ghost so we don't leak its listeners
if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true)
this.emitBeforeChange()
this.graph?.beforeChange()
@@ -3701,6 +3708,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
'pointerleave',
this._ghostPointerHandler
)
// Listen on document so cancellation works even when the canvas isnt focused
// e.g. the search dialog just closed.
// stopPropagation prevents window-level keybindings (like Comfy.Graph.ExitSubgraph on Escape) from firing alongside the cancel.
this._ghostKeyHandler = (e: KeyboardEvent) => {
if (e.key !== 'Escape' && e.key !== 'Delete' && e.key !== 'Backspace') {
return
}
this.finalizeGhostPlacement(true)
e.stopPropagation()
e.preventDefault()
}
document.addEventListener('keydown', this._ghostKeyHandler, true)
}
/**
@@ -3729,6 +3749,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this._ghostPointerHandler = null
}
if (this._ghostKeyHandler) {
document.removeEventListener('keydown', this._ghostKeyHandler, true)
this._ghostKeyHandler = null
}
const node = this.graph?.getNodeById(nodeId)
if (!node) return
@@ -3918,17 +3943,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const { graph } = this
if (!graph) return
// Cancel ghost placement
if (
(e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') &&
this.state.ghostNodeId != null
) {
this.finalizeGhostPlacement(true)
e.stopPropagation()
e.preventDefault()
return
}
let block_default = false
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName == 'input') return

View File

@@ -187,16 +187,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
if (!widget) return
// Special case: SubgraphNode widget.
// Prefer serializeValue (per-instance) over the shared .value getter
// so multiple SubgraphNode instances return their own configured values.
const widgetValue = widget.serializeValue
? widget.serializeValue(subgraphNode, -1)
: widget.value
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: widgetValue }
widgetInfo: { value: widget.value }
}
}

View File

@@ -1,328 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function createNodeWithWidget(
title: string,
widgetValue: number = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget, input }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
})
instance2.configure({
id: 202,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1[0].value).toBe(10)
expect(widgets2[0].value).toBe(20)
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
expect(instance1.serialize().widgets_values).toEqual([10])
expect(instance2.serialize().widgets_values).toEqual([20])
})
it('round-trips per-instance widget values through serialize and configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
originalInstance.configure({
id: 301,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
restoredInstance.configure({
...serialized,
id: 302,
type: subgraph.id
})
const restoredWidget = restoredInstance.widgets?.[0]
expect(restoredWidget?.value).toBe(33)
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('keeps fresh sibling instances isolated before save or reload', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 7)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
instance1.graph!.add(instance1)
instance2.graph!.add(instance2)
const widget1 = instance1.widgets?.[0]
const widget2 = instance2.widgets?.[0]
expect(widget1?.value).toBe(7)
expect(widget2?.value).toBe(7)
widget1!.value = 10
expect(widget1?.value).toBe(10)
expect(widget2?.value).toBe(7)
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
})
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
originalInstance.configure({
id: 601,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
restoredInstance.configure({
...serialized,
id: 602,
type: subgraph.id
})
expect(restoredInstance.widgets?.[0].value).toBe(33)
widget.value = 45
expect(restoredInstance.widgets?.[0].value).toBe(45)
expect(
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
).toBe(45)
})
it('clears stale per-instance values when reconfigured without widgets_values', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 5)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 701 })
instance.graph!.add(instance)
const promotedWidget = instance.widgets?.[0]
promotedWidget!.value = 11
widget.value = 17
const serialized = instance.serialize()
delete serialized.widgets_values
instance.configure({
...serialized,
id: instance.id,
type: subgraph.id
})
expect(instance.widgets?.[0].value).toBe(17)
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17)
})
it('skips non-serializable source widgets during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
// Mark the source widget as non-persistent (e.g. preview widget)
widget.serialize = false
const instance = createTestSubgraphNode(subgraph, { id: 501 })
instance.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: []
})
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
// it.fails pins the open #10849 SubgraphNode.configure regression on Main;
// drop the marker once the inline-proxyWidgets-state fix lands.
it.fails('falls back to source widget value when proxyWidgets is in legacy 2-tuple shape (regression for #10849)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE = 999
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 801 })
instance.configure({
id: 801,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [LEGACY_NOISE]
})
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.serializeValue?.(instance, 0)).toBe(SOURCE_DEFAULT)
})
it.fails('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE_A = 111
const LEGACY_NOISE_B = 222
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 802 })
instance.configure({
id: 802,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [LEGACY_NOISE_A, LEGACY_NOISE_B]
})
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
})
})

View File

@@ -992,21 +992,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
private _pendingWidgetsValues?: unknown[]
/**
* Per-instance promoted widget values.
* Multiple SubgraphNode instances share the same inner nodes, so
* promoted widget values must be stored per-instance to avoid collisions.
* Key: `${sourceNodeId}:${sourceWidgetName}`
*/
readonly _instanceWidgetValues = new Map<string, unknown>()
override configure(info: ExportedSubgraphInstance): void {
this._instanceWidgetValues.clear()
this._pendingWidgetsValues = info.widgets_values
for (const input of this.inputs) {
if (
input._listenerController &&
@@ -1137,21 +1123,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
}
// Hydrate per-instance promoted widget values from serialized data.
// LGraphNode.configure skips promoted widgets (serialize === false on
// the view), so they must be applied here after promoted views exist.
// Only iterate serializable views to match what serialize() wrote.
if (this._pendingWidgetsValues) {
const views = this._getPromotedViews()
let i = 0
for (const view of views) {
if (!view.sourceSerialize) continue
if (i >= this._pendingWidgetsValues.length) break
view.value = this._pendingWidgetsValues[i++] as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
/**
@@ -1546,7 +1517,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onRemoved(): void {
this._eventAbortController.abort()
this._invalidatePromotedViewsCache()
this._instanceWidgetValues.clear()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
@@ -1602,7 +1572,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization.
// Only sync for inputs that are linked to a promoted widget via _widget.
for (const input of this.inputs) {
if (!input._widget) continue
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
}
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
@@ -1610,22 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
this.properties.proxyWidgets = this._serializeEntries(entries)
const serialized = super.serialize()
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
if (serializableViews.length > 0) {
serialized.widgets_values = serializableViews.map((view) => {
const value = view.serializeValue
? view.serializeValue(this, -1)
: view.value
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: (value ?? null)
})
}
return serialized
return super.serialize()
}
override clone() {
const clone = super.clone()

View File

@@ -1017,6 +1017,7 @@
"cancel": "إلغاء",
"cancelled": "أُلغي",
"capture": "التقاط",
"categories": "الفئات",
"category": "الفئة",
"changeKeybinding": "تغيير اختصار المفتاح",
"chart": "مخطط",

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