Compare commits

...

55 Commits

Author SHA1 Message Date
Rizumu Ayaka
c499cf1de0 perf: reuse pendingChangedIds Set to reduce GC pressure
Clearing the existing Set avoids allocating a new one every RAF frame
(~60/s) in the TransformPane hot path.
2026-04-22 19:44:17 +08:00
Rizumu Ayaka
23b54494cb Merge branch 'main' into rizumu/perf/layer-compositing-performance
# Conflicts:
#	browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png
2026-04-22 18:10:14 +08:00
Dante
bea72410fd test: add unit tests for utility widgets (#11442)
## Summary

Adds 26 unit tests across 3 files covering BatchNavigation,
FormSearchInput, and WidgetLayoutField. Part of a widget-test-coverage
sequence.

## Changes

- **What**:
- \`BatchNavigation.test.ts\` (10) — hidden when count ≤ 1, counter
formatted as 1-based \`current / total\`, prev/next navigation, disabled
states at range boundaries.
- \`FormSearchInput.test.ts\` (8) — v-model binding as the user types,
clear-button visibility based on trimmed-query, debounced searcher
invocation with fake timers (250ms debounce, 1000ms maxWait).
- \`WidgetLayoutField.test.ts\` (8) — widget.name vs widget.label
preference, empty-name suppression, \`HideLayoutFieldKey\` injection
hides label but preserves slot, slot receives \`borderStyle\` scoped
prop.

## Review Focus

- Fake timers used in FormSearchInput tests for \`refDebounced\` — the
debounce assertion depends on the 250ms/1000ms window in the component
staying unchanged.
- \`HideLayoutFieldKey\` provided via \`global.provide\` using the
Symbol key.
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11442-test-add-unit-tests-for-utility-widgets-3486d73d365081a891cafe21b09b91c0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-22 17:36:08 +09:00
jaeone94
cbc479d8b4 refactor: extract test helpers and use UI-based subgraph entry in draft position test (#11327)
## Summary

Follow-up to #10828. Addresses all deferred review nits from @DrJKL
tracked in #10932.

- Remove YAGNI `timeout` parameter from `waitForDraftPersisted` —
default 5s from `waitForFunction` is sufficient
- Extract `reloadAndWaitForApp()` into `WorkflowHelper` — preserves
localStorage (drafts) and URL hash (subgraph navigation), unlike
`ComfyPage.setup()` which clears storage and navigates to base URL
- Replace programmatic `canvas.setGraph()` with
`vueNodes.enterSubgraph()` for real UI-based subgraph entry
- Add `@vue-nodes` tag required for `enterSubgraph()` button rendering
- Extract `getSubgraphNodePositions` to deduplicate three identical
inline `page.evaluate` calls
- Fix vacuous pass: capture `positionsBefore` inside `expect.poll` to
ensure the array is non-empty before the verification loop
- Remove inline comments, relying on descriptive helper method names

## Test plan

- [x] `pnpm typecheck:browser` passes
- [x] `pnpm lint` passes
- [x] `pnpm test:browser:local --
browser_tests/tests/subgraph/subgraphDraftPositions.spec.ts` passes
locally

Fixes #10932

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11327-refactor-extract-test-helpers-and-use-UI-based-subgraph-entry-in-draft-position-test-3456d73d3650813cacc1e69398e3f80a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-22 07:59:44 +00:00
Dante
a6b3aa1667 feat: rename "Default" sort option to "Recent" in widget image dropdown (#11526)
*PR Created by the Glary-Bot Agent*

---

## Summary

Renames the first sort option in the `FormDropdown` widget (the inline
image picker shown on node inputs like `LoadImage`) from "Default" to
"Recent" for clarity. Fixes FE-238.

## Why "Recent" is accurate

The `'default'` sort preserves server order (see `assetSortUtils.ts`).
The cloud assets backend orders by `create_time DESC` — see
`cloud/common/assets/repository_impl.go` `applySortOrder()`:

```go
default: // "created_at" or default
    return query.Order(asset.ByCreateTime(sql.OrderDesc()))
```

So the server already returns items newest-first and the user sees them
in recency order. "Recent" describes what's actually on screen.

## Scope

Minimal label-only change. The internal option id stays `'default'`
because `FormDropdown.vue` and `FormDropdownMenuActions.vue` use it as a
sentinel for "unmodified sort state" (e.g., the indicator dot that
appears when the user has changed the sort). A docstring on
`getDefaultSortOptions()` documents this intentional id/label asymmetry
so future maintainers don't silently rename the id and break the
sentinel checks.

The separate full-page asset browser (`AssetFilterBar.vue`) already uses
"Recent" as a distinct sort option that client-side-sorts by
`created_at`; it's untouched by this PR.

## Changes

- `shared.ts`: Swap i18n key `assetBrowser.sortDefault` →
`assetBrowser.sortRecent` (already translated in all 12 locales). Add a
docstring explaining the id/label relationship.
- `shared.test.ts`: Add an assertion that the first option is labeled
"Recent" so future label drift is caught.

## Verification

- `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/shared.test.ts`
— 18/18 pass
- `pnpm typecheck` — clean
- `pnpm format:check` — clean
- `pnpm lint` — no new issues (one pre-existing unrelated warning in
`useWorkspaceBilling.test.ts`)
- Manual verification in the Comfy Cloud local stack: opened
`gsc_starter_2` workflow, clicked the image widget on a `LoadImage`
node, opened the sort menu — confirmed it now shows "Recent" (selected)
and "A-Z" as expected. See screenshot.

## Screenshots

![Image widget dropdown in a LoadImage node with the sort menu open
showing 'Recent' (selected, checkmark) and 'A-Z'
options](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/b9c7e9e95d926b64d50b010edd2df25b6f1105b1c8ecdb1453c38f627fb23047/pr-images/1776809677529-41c87d46-3573-4ad7-96d6-143c3621f5d1.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11526-feat-rename-Default-sort-option-to-Recent-in-widget-image-dropdown-3496d73d365081278ce0d722f6060ccb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-22 02:06:46 +00:00
Christian Byrne
d682b3c7da [chore] Update Comfy Registry API types from comfy-api@ab85b74 (#11530)
## Automated API Type Update

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

- API commit: ab85b74
- Generated on: 2026-04-21T16:33:32Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11530-chore-Update-Comfy-Registry-API-types-from-comfy-api-ab85b74-34a6d73d365081008b30cf50cfd3e0a0)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-22 01:41:17 +00:00
Dante
65b8a5652c fix: render dates in Secrets panel for timestamps with >3 fractional-second digits (#11358)
## Summary

Cloud Prod renders "Invalid Date" in Settings → Secrets on strict JS
Date parsers (older Safari, some WebViews) because the backend emits
timestamps with variable fractional-second precision (e.g.
`"2026-04-18T10:04:55.6513Z"` — 4 digits), which falls outside the
3-digit-only ECMA-262 grammar.

## Changes

- **What**:
- Add `parseIsoDateSafe()` in `src/utils/dateTimeUtil.ts` — trims the
fractional portion to millisecond precision before `new Date(...)` and
returns `null` for missing or unparseable input.
- `SecretListItem.vue` uses the helper and hides the Created / Last Used
line when the timestamp is invalid instead of rendering the literal
string "Invalid Date".
- Unit tests for the parser (8) and for the component (4-digit
fractional seconds, garbage input).

## Review Focus

- The backend (Go `time.RFC3339Nano`) strips trailing zeros from
fractional seconds, producing 0–9 digits depending on the value. Modern
V8 parses this leniently; older Safari does not. A durable fix is
server-side — emit exactly 3 fractional digits — and should be filed
separately. This PR is a defensive frontend guard that also protects ~10
other `toLocaleDateString` callsites if they migrate to the helper.
- Regex `(\.\d{3})\d+(?=Z|[+-]\d{2}:?\d{2}|$)` trims only when there are
**more than** 3 digits; shorter fractions and zero-fraction timestamps
are unchanged.

## Screenshots (if applicable)

Reported in Slack:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776443594202969

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11358-fix-render-dates-in-Secrets-panel-for-timestamps-with-3-fractional-second-digits-3466d73d3650813cb855cfbd50b3650b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-22 00:27:02 +00:00
pythongosssss
5a598ef2e1 test: add GLSL execution e2e test (#11516)
## Summary

Add e2e tests for GLSL shader execution

## Changes

- **What**: 
- add test workflows containing GLSL nodes 
- tests execution, value propagation, error, subgraph handling
- adds console warn on invalid shader to surface error and allow test to
detect

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11516-tst-add-GLSL-execution-e2e-test-3496d73d36508199a8e0fa341186ee4d)
by [Unito](https://www.unito.io)
2026-04-21 20:15:20 -04:00
Dante
2c772077e0 test: add E2E tests for billing dialogs (CancelSubscription, TopUpCredits) (#10969)
## Summary
- Add Playwright E2E tests for `CancelSubscriptionDialogContent` and
`TopUpCreditsDialogContentLegacy`
- CancelSubscription tests: dialog display with date formatting, keep
subscription dismiss, confirm cancel with mocked API, error handling on
API failure
- TopUpCredits tests: dialog display with preset amounts, insufficient
credits variant, preset selection, close button dismiss, pricing link
visibility

Part of the FixIt Burndown test coverage initiative (Untested Dialogs).

## Test plan
- [ ] Verify tests pass in CI against OSS build
- [ ] `pnpm test:browser:local --
browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts`
- [ ] `pnpm test:browser:local --
browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10969-test-add-E2E-tests-for-billing-dialogs-CancelSubscription-TopUpCredits-33c6d73d36508164b268c08c99464ca1)
by [Unito](https://www.unito.io)
2026-04-21 23:17:58 +00:00
Dante
00c294297e test: add WidgetImageCrop unit tests (#11470)
## Summary

Splits the WidgetImageCrop test coverage out of #11446 so this widget
can be reviewed independently.

## Changes

- **What**: Adds WidgetImageCrop unit tests covering
empty/loading/loaded states, ratio-control gating, bounding-box
delegation, and disabled upstream behavior.

## Review Focus

Focused test-only PR extracted from #11446.
Includes small test-only cleanups from the earlier review: shared crop
mock defaults, accessible image querying, and reactive upstream mock
setup.
Validated with `pnpm test:unit -- --run
src/components/imagecrop/WidgetImageCrop.test.ts`.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11470-test-add-WidgetImageCrop-unit-tests-3486d73d365081ff9a1eed159a8eb9a3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-21 13:36:10 -04:00
Kelly Yang
983789753e refactor: remove @ts-expect-error suppressions in test files (#11337)
## Summary
Part of #11092 — Phase 3: remove @ts-expect-error suppressions from test
files.
This phase targets 22 suppressions across two test files:
- `src/utils/nodeDefUtil.test.ts` (18)
- `src/platform/workflow/validation/schemas/workflowSchema.test.ts` (4)

## Changes                                                        
`nodeDefUtil.test.ts`: Each test already constrains the inputs to a
known subtype (`IntInputSpec`, `FloatInputSpec`, `ComboInputSpecV2`), so
casting result to the expected subtype at the declaration site is both
correct and self-documenting. For the one test that uses the base
`InputSpec` type, the options object is extracted with an inline
structural cast.
`workflowSchema.test.ts`: validateComfyWorkflow returns
ComfyWorkflowJSON | null. The tests were accessing .nodes[0].pos without
narrowing, causing "object is possibly null" errors. Fixed with explicit
expect(validatedWorkflow).not.toBeNull() assertions before each property
access, which also improves failure messages — previously a null result
would throw a TypeError rather than a readable assertion failure.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only type-safety refactor with no runtime code changes; primary
risk is minor test assertion behavior changes if a helper unexpectedly
returns `null`.
> 
> **Overview**
> Removes `@ts-expect-error` suppressions from two test suites by making
nullability and return-type expectations explicit.
> 
> `workflowSchema.test.ts` now asserts `validateComfyWorkflow` results
are non-null before accessing `nodes[0]` fields, and
`nodeDefUtil.test.ts` casts `mergeInputSpec` results to the expected
spec subtype (or extracts typed options) so property assertions compile
cleanly under stricter TS settings.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9f3829862b. 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-11337-refactor-remove-ts-expect-error-suppressions-in-test-files-3456d73d3650815aa2a2fca5a9332377)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-21 13:34:04 -04:00
AustinMroz
91ed6a37e2 Fix nodeReplacement not triggering onRemoved (#11509)
Node Replacement failed to call onRemoved on the old node. This would
cause domWidgets to persist after a node is replaced.

<img width="474" height="257" alt="image"
src="https://github.com/user-attachments/assets/51641de7-81e9-4355-88d9-d1605f397076"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11509-Fix-nodeReplacement-not-triggering-onRemoved-3496d73d365081e19a4ae252aa87172d)
by [Unito](https://www.unito.io)
2026-04-21 08:14:51 +00:00
Comfy Org PR Bot
15c5a298a6 1.44.7 (#11485)
Patch version increment to 1.44.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11485-1-44-7-3496d73d36508175b725c4ffbed4c4d0)
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>
2026-04-21 04:42:23 +00:00
Dante
65e27b5cdf test: add unit tests for graph-level widgets (#11445)
## Summary

Adds 22 unit tests across 3 files covering WidgetDOM, MultiSelectWidget,
and TextPreviewWidget. Part of a widget-test-coverage sequence.

## Changes

- **What**:
- \`WidgetDOM.test.ts\` (4) — mounts the resolved DOMWidget element into
the container, empty container when no host node resolves, skips mount
when resolved widget is not a DOM widget, visible root for pointer-event
capture.
- \`MultiSelectWidget.test.ts\` (8) — forwards \`inputSpec.options\`,
falls back to empty options, placeholder from
\`multi_select.placeholder\`, default placeholder, chip vs comma
display, initial selection forwarding.
- \`TextPreviewWidget.test.ts\` (10) — plain text, newline→\`<br>\`,
bare-URL auto-linking, \`[[label|url]]\` http link with target/rel
safety, non-http falls back to escaped label (XSS-safe), skeleton
visibility transitions via mocked executionStore.

## Review Focus

- \`WidgetDOM\` mocks \`useCanvasStore\`, \`resolveWidgetFromHostNode\`,
and \`isDOMWidget\` at the module boundary; test asserts identity of the
mounted element (same \`HTMLElement\` reference) rather than
canvas-side-effects.
- \`TextPreviewWidget\` replaces \`useExecutionStore\` with a
\`reactive()\` proxy held in a hoisted holder so watcher assertions see
real reactive mutations (plain \`vi.hoisted\` objects don't trigger Vue
effects).
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11445-test-add-unit-tests-for-graph-level-widgets-3486d73d3650816180d5f31a523f5c22)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-21 03:10:09 +00:00
Dante
dd16e7a9ea test: add WidgetBoundingBox unit tests (#11468)
## Summary

Splits the WidgetBoundingBox test coverage out of #11446 so this widget
can be reviewed independently.

## Changes

- **What**: Adds WidgetBoundingBox unit tests covering labels, initial
values, min constraints, immutable v-model updates, and disabled
propagation.

## Review Focus

Focused test-only PR extracted from #11446.
Validated with `pnpm test:unit -- --run
src/components/boundingbox/WidgetBoundingBox.test.ts`.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11468-test-add-WidgetBoundingBox-unit-tests-3486d73d365081a682f8c5090e376ec6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-21 02:37:57 +00:00
Christian Byrne
63d0e3ae5d test: achieve 100% coverage on keybinding presetService (#11399)
## Summary

Add 7 new unit tests to achieve 100% statement/branch/function/line
coverage on `src/platform/keybindings/presetService.ts`.

## Changes

- **What**: 7 new tests in `presetService.test.ts` covering
previously-uncovered paths: importPreset JSON parse error, deletePreset
cancel/non-active preset, applyPreset with unset bindings, switchPreset
save-as-new flow (success and cancel), switchPreset to default after
unsaved changes dialog. Cherry-picked source files from 944f78adf since
they did not exist on this branch.

## Review Focus

Test quality and mock setup correctness. The source files are unchanged
from 944f78adf.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11399-test-achieve-100-coverage-on-keybinding-presetService-3476d73d36508196b78dfd8f0f6f751c)
by [Unito](https://www.unito.io)
2026-04-21 01:59:01 +00:00
Christian Byrne
71ca582325 fix: reset file input value after selection to allow same-file reupload (#11417)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes the "choose video to upload" button becoming unresponsive after
running a workflow with a subgraph a few times.

**Root cause**: The detached input element in `useNodeFileInput` never
resets its `value`. The browser's `onchange` only fires when the value
*changes* — re-selecting the same file silently drops the event. A page
refresh recreates the input with an empty value, which is why refreshing
fixes it.

## Changes

- `useNodeFileInput.ts`: Reset `fileInput.value` before invoking
callbacks so value is cleared even if a callback throws
- `useNodeDragAndDrop.ts`: Add `onRemoved` cleanup for installed
handlers (only clears own handlers; preserves replacements from
extensions)
- `useNodePaste.ts`: Add `onRemoved` cleanup for installed `pasteFiles`
handler (same reference-safe pattern)
- 3 new colocated test files with 26 test cases covering all branches

## Codebase Audit

Audited all 11 file upload implementations across the codebase. Found 5
using the ghost/virtual input pattern — 3 with the same missing
value-reset bug:
- `useNodeFileInput.ts` — fixed in this PR
- `scripts/utils.ts` (`uploadFile()`) — one-shot pattern, lower risk
- `extensions/core/load3d.ts` — partial reset only

The 4 Vue component implementations already reset correctly.

## Future Work

VueUse `useFileDialog` composable handles same-file reselection via
`reset: true` and provides automatic lifecycle cleanup. A follow-up PR
could migrate the ghost input patterns for a centralized solution.

## Test Plan

- 26 unit tests across 3 new test files (all pass)
- 9 existing useNodeImageUpload tests still pass
- Pre-commit hooks pass (oxfmt, oxlint, eslint, typecheck)
- Oracle code review addressed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11417-fix-reset-file-input-value-after-selection-to-allow-same-file-reupload-3476d73d3650814d95efdab602a3852d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-20 17:35:17 -07:00
pythongosssss
9ed7a7bd87 test: Add tests for help center (#11475)
## Summary

Test coverage for help center & associated popups

## Changes

- **What**: 
- Adds HelpCenterHelper for mocking endpoints and locators
- Tests for popup, menu items & positioning

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11475-test-Add-tests-for-help-center-3486d73d365081af91a2eb7465e503fe)
by [Unito](https://www.unito.io)
2026-04-20 22:43:28 +00:00
pythongosssss
3e62033f09 test: extract TestIdValue as mapped type (#11474)
## Summary

Prevent needing to update the union with newly added keys

## Changes

- **What**: 
- Change the `TestIdValue` union to a mapped type, excluding function
values

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11474-test-extract-TestIdValue-as-mapped-type-3486d73d365081d299efd87a0c46d66f)
by [Unito](https://www.unito.io)
2026-04-20 20:52:58 +00:00
Dante
78630f5485 test: add WidgetRange unit tests (#11471)
## Summary

Splits the WidgetRange test coverage out of #11446 so this widget can be
reviewed independently.

## Changes

- **What**: Adds WidgetRange unit tests covering value pass-through,
display propagation, disabled-state handling, upstream overrides, and
histogram derivation.

## Review Focus

Focused test-only PR extracted from #11446.
Validated with `pnpm test:unit -- --run
src/components/range/WidgetRange.test.ts`.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11471-test-add-WidgetRange-unit-tests-3486d73d365081d7a684ca3ff02320d6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-20 20:38:10 +00:00
Alexander Brown
55c5fce522 ci: stabilize Vercel website preview URLs per PR (#11478)
## Summary

Make the website preview URL stable per PR and make deployments show up
correctly in the Vercel dashboard.

## Changes

- **What**:
- Pass git metadata (`githubCommitRef`, `githubCommitSha`,
`githubCommitAuthorLogin`, `githubCommitMessage`, `githubPrId`,
`githubRepo`) via `vercel deploy --meta` so deployments group by
branch/PR in the dashboard and pick up branch-scoped env vars.
- Alias each preview deploy to a stable per-PR hostname:
`comfy-website-preview-pr-<N>.vercel.app`. URL no longer changes between
pushes on the same PR.
- PR comment now shows the stable URL prominently, the per-commit URL as
subtext, plus a last-updated timestamp and short SHA so reviewers can
tell if the preview is current.
- User-controlled PR fields routed through env vars (no shell
interpolation of untrusted strings).

## Review Focus

- `PREVIEW_ALIAS_PREFIX` is set to `comfy-website-preview` — confirm
this subdomain pattern is free within the Vercel team (first deploy will
claim it).
- Production job is untouched.
- `vercel.json` keeps `github.enabled: false` — intentional, we stay
CLI-driven.

### Known limitation (out of scope)

Vercel Shareable Links are bound to a specific deployment ID. Aliasing
the stable hostname to a new deployment does **not** carry over
previously-issued share links. If the team needs share links to persist
across pushes, follow-up options: Protection Bypass for Automation
(project-level token) or Deployment Protection Exceptions (Pro+).

### Follow-ups

- Optional `vercel alias rm` on PR close to clean up stale aliases.

## Screenshots (if applicable)

N/A — CI config only. Verification will land on this PR's own preview
run.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11478-ci-stabilize-Vercel-website-preview-URLs-per-PR-3486d73d3650815ab24be1f7895cecc5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:27:44 +00:00
Christian Byrne
4b5c15fc7d fix: show credits in legacy user popover on non-cloud distributions (#11463)
*PR Created by the Glary-Bot Agent*

---

## Summary

Credits no longer showed in the current user popover on local/desktop
builds. Root cause: the credits row in `CurrentUserPopoverLegacy.vue`
was gated behind `isCloud && isActiveSubscription`, and `isCloud` is a
compile-time constant that resolves to `false` on local
(`DISTRIBUTION='localhost'`) — so the element never rendered and
`fetchBalance()` never fired (no network request, no console logs).

This fix decouples the credits balance row from the `isCloud` gate.
Subscription-specific UI (subscribe button, partner nodes, plans &
pricing, manage plan, upgrade-to-add-credits) remains gated by `isCloud`
as intended by PR #9958.

## Changes

- `CurrentUserPopoverLegacy.vue`: credits row `v-if` changed from
`isCloud && isActiveSubscription` → `isActiveSubscription`. On
non-cloud, `isActiveSubscription` resolves to `true` via
`isSubscribedOrIsNotCloud` in `useSubscription.ts`, so credits display
for logged-in users.
- `CurrentUserPopoverLegacy.vue`: `upgrade-to-add-credits` button now
requires `isCloud && isFreeTier` (subscription-tier concept only
meaningful on cloud). The `add-credits` top-up button remains available
everywhere.
- `CurrentUserPopoverLegacy.test.ts`: updated non-cloud tests to assert
credits balance is visible and add-credits button renders, while
upgrade-to-add-credits and other subscription UI stay hidden.

Mirrors the behavior of `CurrentUserPopoverWorkspace.vue`, which never
had the `isCloud` gate on its credits row.

## Verification

- `pnpm vitest run
src/components/topbar/CurrentUserPopoverLegacy.test.ts`: **21/21
passing**, including new non-cloud assertions
- `pnpm typecheck`: clean
- `pnpm lint` / `pnpm format:check`: clean
- Live frontend dev server renders on localhost with
`__DISTRIBUTION__='localhost'` (the previously-failing scenario).
Attached screenshot shows the app running on local distribution; the
popover itself only appears for logged-in users, so its contents are
exercised by the unit tests.

Fixes FE-219

## Screenshots

![Frontend running on localhost distribution
(__DISTRIBUTION__='localhost'), the previously-failing
scenario](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7e49ca118370224f2a9be2db5b71b2ed78e095b999031b2cd040af1cf7a208f0/pr-images/1776661075381-a367eb49-a8f9-4737-be58-28b63a27f931.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11463-fix-show-credits-in-legacy-user-popover-on-non-cloud-distributions-3486d73d365081c587d8ee7eae9a5c3d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-20 20:17:48 +00:00
Christian Byrne
b36242475c test: add E2E tests for topbar menu commands (#11208)
## Summary

Add 5 Playwright E2E tests covering topbar menu command interactions.

## Changes

- **What**: New test file
`browser_tests/tests/topbarMenuCommands.spec.ts` with 5 tests:
  - New command creates a new workflow tab
  - Edit > Undo undoes the last action
  - Edit > Redo restores an undone action
  - File > Save opens save dialog
  - View > Bottom Panel toggles bottom panel visibility

## Review Focus

Tests use `triggerTopbarCommand()` for menu navigation and
`expect.poll()` for async assertions. The "New" command is a top-level
menu item (path `["New"]`), not nested under File.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11208-test-add-E2E-tests-for-topbar-menu-commands-3416d73d36508143afe5e67a98910f56)
by [Unito](https://www.unito.io)
2026-04-20 18:53:45 +00:00
Christian Byrne
2f4116fa81 test: add unit tests for numberUtil and dateTimeUtil (#11253)
## Summary

Adds unit tests for two untested utility modules to improve coverage:

- **`numberUtil.ts`** — `clampPercentInt`, `formatPercent0` (clamping,
rounding, locale formatting)
- **`dateTimeUtil.ts`** — `dateKey`, `isToday`, `isYesterday`,
`formatShortMonthDay`, `formatClockTime`

20 new tests total. This PR also serves as an E2E validation of the
coverage Slack notification workflow (#10977) — merging should trigger a
Slack notification showing the coverage improvement.

## Test Plan

- `pnpm test:unit -- src/utils/numberUtil.test.ts
src/utils/dateTimeUtil.test.ts`
- All 20 tests pass locally

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11253-test-add-unit-tests-for-numberUtil-and-dateTimeUtil-3436d73d365081aab388fd1f1fcac7d7)
by [Unito](https://www.unito.io)
2026-04-20 18:53:11 +00:00
Benjamin Lu
d83c84aa85 test: extract asset api browser fixture (#11279)
## Summary

Move asset API mocking off `ComfyPage` and into a standalone Playwright
fixture.

## Changes

- add `assetApiFixture` for browser tests that need asset API mocking
- remove `assetApi` from `ComfyPage`
- migrate `browser_tests/tests/assetHelper.spec.ts` to use the
standalone fixture

## Why

This is the first slice of the browser-fixture split. It reduces global
fixture surface area without changing test behavior.

## Validation

- `pnpm typecheck:browser`
- `pnpm exec oxlint browser_tests/fixtures/ComfyPage.ts
browser_tests/fixtures/assetApiFixture.ts
browser_tests/tests/assetHelper.spec.ts --type-aware`
- repo hooks during commit/push: `pnpm typecheck`, `pnpm
typecheck:browser`, `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11279-test-extract-asset-api-browser-fixture-3436d73d3650818393bcd43dc909c8a2)
by [Unito](https://www.unito.io)
2026-04-20 18:37:45 +00:00
Alexander Brown
c1c3fba1ac refactor: extract shared resolve-pr-from-workflow-run action (#11336)
## Summary

Extract duplicated PR-number-resolution logic from
`workflow_run`-triggered workflows into a shared composite action at
`.github/actions/resolve-pr-from-workflow-run/`.

## Changes

- **What**: New composite action that resolves PR number from
`workflow_run` context using `pull_requests[0]` with
`listPullRequestsAssociatedWithCommit` fallback. Updated 4 consumer
workflows; removed dead artifact-stored PR metadata from 2 CI workflows.
- **Files touched**:
  - `.github/actions/resolve-pr-from-workflow-run/action.yaml` (new)
- `.github/workflows/pr-vercel-website-preview.yaml` (uses shared
action)
- `.github/workflows/pr-report.yaml` (uses shared action with
`check-staleness: true`)
- `.github/workflows/ci-tests-storybook-forks.yaml` (replaced
`pulls.list` scan)
- `.github/workflows/ci-tests-e2e-forks.yaml` (replaced `pulls.list`
scan)
- `.github/workflows/ci-size-data.yaml` (removed dead
`number.txt`/`base.txt`/`head-sha.txt` writes)
- `.github/workflows/ci-perf-report.yaml` (removed dead `perf-meta`
artifact)

## Review Focus

- The fork workflows previously used `pulls.list` (fetches all open PRs,
linear scan by SHA). The shared action uses the more targeted
`workflow_run.pull_requests[0]` + `listPullRequestsAssociatedWithCommit`
fallback.
- `coverage-slack-notify.yaml` was intentionally left unchanged — it
parses merged commit messages on `main` pushes, which is a different use
case.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11336-refactor-extract-shared-resolve-pr-from-workflow-run-action-3456d73d365081e5b8f5ea29c020763e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-20 10:20:41 -07:00
pythongosssss
35bfe509b3 test: add/update terminal tests (#11239)
## Summary

Adds test coverage for the integrated terminal

## Changes

- **What**: 
- refactor and simplify existing tests
- add new tests for xterm integration

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11239-test-add-update-terminal-tests-3426d73d365081c99445c35d8808afb4)
by [Unito](https://www.unito.io)
2026-04-20 10:11:37 +00:00
Christian Byrne
5d98e11ba1 feat: enable queue panel v2 by default on nightly builds (#11376)
*PR Created by the Glary-Bot Agent*

---

## Summary
- Changes the `Comfy.Queue.QPOV2` setting's `defaultValue` from `false`
to `isNightly`
- On nightly builds, users get the docked job history/queue panel (v2)
by default
- On stable builds, behavior is unchanged (v1 floating overlay remains
default)
- Users can still toggle the setting manually regardless of build type

## Pattern
Follows the existing pattern used by `Comfy.VueNodes.Enabled` which uses
`isCloud || isDesktop` as its version-conditional default. This is a
compile-time constant from `@/platform/distribution/types`.

## Context
Part of a dual-variant audit to graduate experimental features. QPO v2
has 0 extension ecosystem dependencies (confirmed via GitHub
codesearch), making nightly default-on safe for gathering feedback
before promoting to all users.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11376-feat-enable-queue-panel-v2-by-default-on-nightly-builds-3466d73d36508140b814d1d684acacba)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-20 10:01:48 +00:00
Christian Byrne
60c7471818 feat: enable node replacement by default (#11439)
*PR Created by the Glary-Bot Agent*

---

## Summary

Enable node replacement suggestions by default so users see Quick Fix
options for deprecated/renamed nodes without toggling an experimental
setting.

- Change `Comfy.NodeReplacement.Enabled` default from `false` to `true`
and remove `experimental` flag
- Add `versionModified` metadata for release tracking
- No breaking change — users who previously disabled this setting keep
their preference

## Safety gates

This is an intentional global rollout, gated by two additional
server-side checks:

1. Server must provide `node_replacements` feature flag as true (PostHog
controlled)
2. `GET /api/node_replacements` must return data (cloud PR
Comfy-Org/cloud#2686)

Without both, changing this default alone has no effect. The three gates
ensure safe rollout.

## Companion PRs

- Comfy-Org/cloud#2686 — backend `GET /api/node_replacements` endpoint +
server-side validation bypass

Replicate of #11246, retargeted to `main` for backport automation.

Labels: `needs-backport`, `cloud/1.42`, `cloud/1.43`, `core/1.42`,
`core/1.43`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11439-feat-enable-node-replacement-by-default-3486d73d36508192b77aea9640986106)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-20 02:16:54 +00:00
Comfy Org PR Bot
0ac4c3d6c5 1.44.6 (#11433)
Patch version increment to 1.44.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11433-1-44-6-3486d73d365081778622e094f11b500c)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-20 02:12:56 +00:00
Dante
feafdc0b4a fix: chain Load3D node lifecycle callbacks to preserve widget cleanup (#11359)
## Summary

Undo on a workflow with an interactive 3D/camera node (e.g. Qwen
MultiAngle Camera) broke the interactive UI: it disappeared for Vue
Nodes 2.0 and desynced for LiteGraph.

Root cause: `initializeLoad3d` in `useLoad3d.ts` assigned
`node.onRemoved`, `node.onResize`, and the other node lifecycle handlers
by direct assignment, overwriting the cleanup chain that `addWidget()`
had already appended during node construction (line `node.onRemoved =
useChainCallback(node.onRemoved, () => widget.onRemove?.())` in
`domWidget.ts`). When undo cleared the graph, `widget.onRemove` never
ran, so the component widget stayed in `domWidgetStore` pointing at a
detached element while new nodes registered fresh widgets at the same
UUID keys.

Fix: wrap all of those assignments with `useChainCallback` so earlier
subscribers (widget registration, badge composables, extension
nodeCreated hooks) continue to fire.

- Fixes FE-214
(<https://linear.app/comfyorg/issue/FE-214/undo-breaks-and-desyncs-qwen-multiangle-camera-ui>)

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for FE-214 undo losing Load3D widget callback
chain` | 🔴 Red | Proves the test catches the bug |
| `fix: chain Load3D node lifecycle callbacks to preserve widget
cleanup` | 🟢 Green | Proves the fix resolves the bug |

## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] Manual: load Qwen MultiAngle Camera workflow, mutate camera, press
Ctrl+Z, confirm interactive UI stays mounted and value reflects restored
state (Vue Nodes 2.0 and LiteGraph)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11359-fix-chain-Load3D-node-lifecycle-callbacks-to-preserve-widget-cleanup-3466d73d365081e2b64de65c26ee6abf)
by [Unito](https://www.unito.io)
2026-04-20 01:55:44 +00:00
Christian Byrne
2fea0aa538 fix: trigger Vue reactivity on output slot type changes in matchType (#9935)
## Summary

Fix VHS unbatch output slot color not updating when slot types change
via matchType resolution in Vue renderer.

## Changes

- **What**: After `changeOutputType` mutates `output.type` on objects
inside a `shallowReactive` array, spread-copy `this.outputs` to trigger
the shallowReactive setter so `SlotConnectionDot` re-evaluates the slot
color.

## Review Focus

The fix adds `this.outputs = [...this.outputs]` after the matchType
resolution loop in `withComfyMatchType`. This forces Vue's
shallowReactive proxy to fire, since mutating a property on an object
inside the array doesn't trigger the setter. The spread is placed after
all outputs are updated to batch the reactivity trigger.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9935-fix-trigger-Vue-reactivity-on-output-slot-type-changes-in-matchType-3246d73d365081c4a293f57931892c61)
by [Unito](https://www.unito.io)
2026-04-20 01:51:08 +00:00
Christian Byrne
a1ba567dbc test: remove --listen 0.0.0.0 from E2E test mock argv (#11021)
## Summary

Remove `--listen 0.0.0.0` from mock `argv` in E2E test fixtures to avoid
normalizing a flag that exposes the server to all network interfaces.

## Changes

- **What**: Removed `--listen` and `0.0.0.0` from
`mockSystemStats.system.argv` in
`browser_tests/fixtures/data/systemStats.ts` (shared fixture) and the
ManagerDialog-specific override in
`browser_tests/tests/dialogs/managerDialog.spec.ts`. Neither value is
required for any test assertion.

Fixes #11008

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11021-test-remove-listen-0-0-0-0-from-E2E-test-mock-argv-33e6d73d365081c59d3fe9610afbeb6f)
by [Unito](https://www.unito.io)
2026-04-20 01:46:20 +00:00
Christian Byrne
d2e30645fe [chore] Update Ingest API types from cloud@9b9da80 (#11126)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 9b9da80
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-19 19:01:35 -07:00
Comfy Org PR Bot
fc61b19cb9 docs: Weekly Documentation Update (#10739)
# Documentation Accuracy Audit - PR Summary

## Summary

Conducted a comprehensive audit of all documentation files against the
current codebase. The documentation is **exceptionally well-maintained**
with 99%+ accuracy. Only one minor enhancement was needed.

- Added missing `pnpm dev:cloud` command to AGENTS.md
- Verified all 70+ documentation files for accuracy
- Confirmed all API examples, file paths, and configuration references
are correct
- Validated all script commands match package.json

## Changes Made

### Documentation Updates

**File: `AGENTS.md`**
- Added `pnpm dev:cloud` to the "Build, Test, and Development Commands"
section
- This command was documented in CONTRIBUTING.md but missing from
AGENTS.md
- Command connects dev server to cloud backend (testcloud.comfy.org)

## Audit Scope and Findings

### Areas Audited (All  Verified Accurate)

**Core Documentation:**
-  `README.md` - All extension API examples verified against source
code
-  `AGENTS.md` - All scripts, file paths, and patterns verified
-  `CLAUDE.md` - References to AGENTS.md confirmed valid
-  `CONTRIBUTING.md` - All commands and workflows verified

**Configuration Files:**
-  `vite.config.mts` - Exists and matches documentation
-  `playwright.config.ts` - Exists and matches documentation
-  `eslint.config.ts` - Exists and matches documentation
-  `.oxfmtrc.json` - Exists and matches documentation
-  `.oxlintrc.json` - Exists and matches documentation

**Documentation Directories:**
-  `docs/guidance/*.md` (6 files) - All code patterns match actual
implementations
-  `docs/testing/*.md` (5 files) - All testing patterns validated
-  `docs/extensions/*.md` (3 files) - Extension APIs verified
-  `docs/adr/*.md` (9 files) - All ADRs present and referenced
correctly
-  `docs/architecture/*.md` (8 files) - Architecture documentation
accurate
-  `.claude/commands/*.md` (8 files) - All skill documentation verified

**README Files:**
-  19 README files throughout repository verified for accuracy

**Key Verifications:**

1. **Package.json Scripts** - All documented commands exist:
   -  `pnpm dev`, `dev:electron`, `build`, `preview`
   -  `test:unit`, `test:browser:local`
   -  `lint`, `lint:fix`, `format`, `format:check`
   -  `typecheck`, `storybook`

2. **File Paths** - All referenced paths verified:
   -  `src/router.ts`, `src/i18n.ts`, `src/main.ts`
   -  `src/locales/en/main.json`
   -  `browser_tests/**/*.spec.ts`
   -  All component and composable paths

3. **API Examples in README.md** - All validated against source:
   -  `window['app'].extensionManager.dialog` (v1.6.13 API)
   -  `app.extensionManager.registerSidebarTab` (v1.2.4 API)
   -  `bottomPanelTabs` extension field (v1.3.22 API)
   -  `aboutPageBadges` extension field (v1.3.34 API)
   -  `getSelectionToolboxCommands` method (v1.10.9 API)
   -  Settings API migration (v1.3.22)
   -  Commands and keybindings API (v1.3.7)

4. **Code Patterns** - Documentation matches implementation:
   -  Vue 3.5+ Composition API patterns
   -  TypeScript strict mode usage
   -  Tailwind 4 utility-first approach
   -  Pinia store patterns
   -  VueUse composables
   -  Playwright testing patterns

## Review Notes

### Documentation Quality Assessment

The ComfyUI Frontend documentation demonstrates **exceptional quality**
across all categories:

**Strengths:**
1. **Accuracy** - 99%+ of documented information matches current
codebase
2. **Comprehensive Coverage** - All major systems documented
3. **Cross-Referencing** - Documents properly reference each other
4. **Code Examples** - All API examples are working and tested
5. **Maintenance** - Recently updated to reflect latest features
6. **Organization** - Logical structure with guidance by file type

**Notable Documentation Excellence:**
- `docs/guidance/playwright.md` - Exceptional detail on typed API mocks
with source-of-truth table
- `docs/extensions/development.md` - Clear explanation of extension shim
system
- `docs/testing/vitest-patterns.md` - Practical, actionable testing
patterns
- `README.md` - Comprehensive extension API examples with version
tracking
- `.agents/checks/adr-compliance.md` - Thorough architectural guardrails

### Minor Observations (Not Issues)

1. **Undocumented Scripts** - These exist but aren't in AGENTS.md
(likely intentional):
   - `pnpm dev:no-vue` - Internal development flag
- `pnpm build:desktop`, `pnpm build:cloud` - Distribution-specific
builds
   - `pnpm knip` - Dependency analysis tool
- `pnpm stylelint` - CSS linting (mentioned in workflows, not main docs)

2. **Vue Test Utils** - Minor inconsistency:
   - AGENTS.md says "Vue Test Utils is also accepted"
   - ESLint rule bans it with message "Use @testing-library/vue instead"
- Recommendation: Clarify if VTU is acceptable for existing tests only

3. **Extension Examples** - All working, no changes needed:
   - PrimeVue icons reference still valid (primevue.org/icons)
   - Toast API reference accurate (primevue.org/toast)
   - All extension lifecycle hooks documented correctly

### What Was NOT Changed

No changes were made to the following areas as they are all accurate:
- README.md extension API examples
- Configuration file documentation
- Testing documentation patterns
- Architecture decision records
- Extension development guides
- Vue component patterns
- TypeScript guidelines
- Git conventions
- Security guidelines

## Statistics

- **Total Files Audited:** 70+ markdown files
- **Critical Path Verifications:** 25+ items
- **Script Command Verifications:** 15+ commands
- **Configuration Files Checked:** 6 files
- **API Example Validations:** 10+ examples
- **Cross-Reference Validations:** 20+ references
- **Files Modified:** 1 (AGENTS.md)
- **Lines Added:** 1
- **Issues Found:** 0 critical, 0 high, 0 medium

## Conclusion

The documentation is in **excellent condition** and remains highly
accurate. This audit confirms that the ComfyUI Frontend team maintains
documentation as a first-class citizen alongside code. The single
enhancement (adding `pnpm dev:cloud`) improves discoverability of an
existing command that was already documented elsewhere.

**Recommendation:** This is a model example of documentation quality for
other projects to follow.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-19 18:56:11 -07:00
Alexander Brown
8a5a8f0a6e docs: add hyperlinks to all supporting files in ADR 008 (#11256)
*PR Created by the Glary-Bot Agent*

---

## Summary

ADR 008 (Entity Component System) referenced only 3 of 10 companion
architecture documents, making the rest undiscoverable to readers
browsing the design.

- Add inline contextual links in Context, Systems, and Migration
Strategy sections so readers encounter them while reading
- Add a comprehensive Supporting Documents table before Notes as a
complete index of all 10 companion docs

Previously unlinked files now referenced:
- `entity-interactions.md` — current entity relationship map
- `entity-problems.md` — structural problem catalog
- `proto-ecs-stores.md` — existing stores partially implementing ECS
- `ecs-target-architecture.md` — full target architecture
- `ecs-migration-plan.md` — phased migration roadmap
- `ecs-lifecycle-scenarios.md` — lifecycle operation walkthroughs
- `appendix-critical-analysis.md` — document accuracy verification
- `change-tracker.md` — current undo/redo system

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11256-docs-add-hyperlinks-to-all-supporting-files-in-ADR-008-3436d73d365081828cf9ffa77e034f2d)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-19 18:34:08 -07:00
Christian Byrne
0638e8e993 test: add unit tests for SceneModelManager (#11392)
## Summary

Add 44 unit tests for `SceneModelManager` in the 3D viewer
(`src/extensions/core/load3d/`).

## Changes

- **What**: New test file `SceneModelManager.test.ts` covering
constructor, dispose, createSTLMaterial, addModelToScene, setupModel,
setOriginalModel, clearModel, reset, setMaterialMode (all 5 modes),
setupModelMaterials, setUpDirection (all 7 directions), hasSkeleton,
setShowSkeleton, containsSplatMesh, and PLY mode switching (point cloud,
wireframe, vertex colors, cleanup).

## Review Focus

- Test coverage of PLY mode switching edge cases (vertex colors, old
model cleanup)
- Mock strategy for WebGLRenderer (happy-dom cannot instantiate it)
- SplatMesh mock leverages the existing global mock in `vitest.setup.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11392-test-add-unit-tests-for-SceneModelManager-3476d73d3650819097f3f6d73d8fbe02)
by [Unito](https://www.unito.io)
2026-04-19 20:48:34 -04:00
Dante
07ce7123c8 test: cover useErrorActions and useErrorReport (#11320)
Closes coverage gaps in \`src/components/rightSidePanel/errors/\` as
part of the unit-test backfill.

## Testing focus

\`useErrorActions\` is thin (telemetry + command + \`window.open\`), but
\`useErrorReport\` is a real async watcher with multiple store
dependencies, \`@vueuse/core\`'s \`until(...)\`, and a cancellation
guard. The tricky part is keeping \`until\` reactive without mocking
\`@vueuse/core\`.

### \`useErrorActions\` (8 tests)

- Three functions × telemetry-fired × command/window invocation × the
\`telemetry?.\` null-safe branch.
- \`findOnGitHub\` encoding: verifies \`encodeURIComponent\` runs on the
error message and \` is:issue\` is appended.
- \`window.open\` stubbed via \`vi.spyOn\`, restored in \`afterEach\`.

### \`useErrorReport\` (9 tests)

- **Reactive \`until()\`.** \`@vueuse/core\` is **not** mocked. The
\`useSystemStatsStore\` mock creates real Vue \`ref\`s and exposes them
via getter/setter so \`until(() => isLoading).toBe(false)\` resolves
through actual reactivity.
- **\`__setSystemStats\` / \`__setIsLoading\` helpers** on the mocked
store let tests mutate state from the outside without leaking global
mutable state beyond \`vi.hoisted\`.
- **Cancellation guard.** Manually-resolvable deferred \`getLogs\`
promise — while it's pending, the \`cardSource\` ref is swapped. The
previous run's results must **not** mutate \`enrichedDetails\`.
Regressions here would cause race-dependent UI state when users switch
between error cards quickly.
- **Fallback paths.** Missing \`exceptionType\` →
\`FALLBACK_EXCEPTION_TYPE\` ('Runtime Error'). \`serialize()\` throws →
early return. \`generateErrorReport\` throws → \`displayedDetailsMap\`
falls back to the raw \`error.details\`.
- **Watcher cleanup.** Swapping the card ref clears stale
\`enrichedDetails\` before re-enrichment.
- \`console.warn\` spy suppresses noise; restored in \`afterEach\`.

## Principles applied

- No mocks of \`vue\` or \`@vueuse/core\` — only our own modules
(\`api\`, \`app\`, \`systemStatsStore\`, \`errorReportUtil\`).
- \`@vue/test-utils\` isn't installed; a local \`flushPromises\` helper
is used (matches the existing pattern in
\`useNodeHelpContent.test.ts\`).
- All 17 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-20 08:49:36 +09:00
Dante
799ffcf4b6 test: cover useWorkspaceUI and useWorkspaceBilling (#11319)
Closes coverage gaps in \`src/platform/workspace/composables/\` as part
of the unit-test backfill.

## Testing focus

\`useWorkspaceUI\` is wrapped in \`createSharedComposable\` (shared
instance across all callers). \`useWorkspaceBilling\` is a large
stateful composable: parallel API calls, exponential-backoff polling,
computed mappers, lifecycle cleanup. Both need careful state isolation
and real lifecycle behavior — not faked hooks.

### \`useWorkspaceUI\` (8 tests)

- **Permission / UI-config matrix.** Three role/type combinations —
(personal × any), (team × owner), (team × member) — plus the
no-active-workspace default. Assertions target concrete flags that
differ per role (the table itself is the contract), not the return
shape.
- **\`createSharedComposable\` identity invariant.** Multiple calls
return the same instance.
- **Isolation.** Each test uses \`vi.resetModules()\` to get a fresh
shared instance so the memoization doesn't leak between cases.

### \`useWorkspaceBilling\` (23 tests)

- **Parallel init.** \`initialize\` runs \`Promise.all([status, balance,
plans])\` concurrently, then re-fetches balance when free-tier shows a
zero amount (lazy credit grant path).
- **Polling with fake timers.** \`cancelSubscription\`'s exponential
backoff (\`1000 * 2^attempt\`, max 5000ms) driven by
\`vi.useFakeTimers()\` + \`advanceTimersByTimeAsync()\`. Covers success,
failure, and the unmount-stops-polling case.
- **Real lifecycle.** \`onBeforeUnmount\` only fires inside a component
instance — not inside a raw \`effectScope\`. The unmount test mounts a
minimal Vue app via \`createApp\` / \`app.unmount\` so the production
cleanup path actually runs.
- **Computed getter mapping.** \`subscription\`, \`balance\`,
\`isActiveSubscription\`, \`isFreeTier\` assert the snake_case API shape
is remapped to the camelCase UI shape correctly.
- **\`window\` effects.** \`window.open\` stubbed via \`vi.spyOn\`,
\`window.location.href\` via \`vi.stubGlobal\`. Restored in
\`afterEach\`.

## Principles applied

- No mocks of \`vue\` or \`@vueuse/core\` — only our own workspace API,
stores, and sibling composables.
- Behavioral assertions only.
- All 31 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-20 08:23:23 +09:00
Comfy Org PR Bot
1020e8cf32 1.44.5 (#11213)
Patch version increment to 1.44.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-19 12:37:51 -07:00
jaeone94
b157182a20 refactor: inline node footer layout to fix selection bounding box (#10741)
## Summary

Refactor node footer from absolute overlay to inline flow layout, fixing
the selection bounding box not encompassing footer buttons and collapsed
node dimensions.

## Background

The node footer (Enter Subgraph, Advanced, Error buttons) was rendered
as an absolute overlay (`absolute top-full`) outside the node body. This
caused:

1. **Selection bounding box** did not include footer height — the dashed
multi-select border cut through footer buttons
2. **Footer offset compensation** required 3 hardcoded computed classes
(`footerStateOutlineBottomClass`, `footerRootBorderBottomClass`,
`footerResizeHandleBottomClass`) with magic pixel values (31px, 35px,
etc.) that had to stay in sync with CSS

## Solution: Inline Footer with `isolate -z-1`

The footer is moved into normal document flow (no longer `absolute
top-full`). The key challenge was keeping the footer visually behind the
body's rounded bottom edge (the "tuck under" effect) without adding
`z-index` to the body — because adding `z-index` to the body creates a
stacking context that traps slot connection dots, making them appear
behind overlay borders.

The solution uses CSS `isolation: isolate` combined with `-z-1` on the
footer wrapper:

- **`isolate`** creates an independent stacking context for the footer,
so internal z-index (Error button `z-10` above Enter button) does not
leak to the parent
- **`-z-1`** places the entire footer behind the body (`z-index: auto`),
achieving the visual overlap without touching the body's stacking
behavior
- **Slot dots remain free** — the body has no explicit z-index, so slots
participate in the root stacking context and are never trapped behind
overlay borders

This eliminates all 3 footer offset computed classes and their hardcoded
pixel values.

## Selection Box: `min-height` on root + unified size path

Moving `min-h-(--node-height)` from the body (`node-inner-wrapper`) to
the root element makes the footer height naturally included in
`node.size` via ResizeObserver → layoutStore → litegraph sync. This
means `boundingRect` is automatically correct for expanded nodes — no
callbacks or overrides needed.

For collapsed nodes, a pre-existing issue (since v1.40) caused
`_collapsed_width` to fall back to `NODE_COLLAPSED_WIDTH = 80px` because
Vue nodes lack a canvas context for text measurement.

The fix lets collapsed dimensions flow through the **same**
`batchUpdateNodeBounds` path as expanded nodes — no parallel data
structure, no separate accessor, no cache:

1. ResizeObserver writes the collapsed DOM dimensions to
`layoutStore.size` via `batchUpdateNodeBounds`
2. `useLayoutSync` syncs `layoutStore.size` → `liteNode.size` as it does
for any other size change
3. The expanded size survives the collapse→expand round trip via CSS
custom properties — the `isCollapsed` watcher in `LGraphNode.vue` swaps
`--node-width` to `--node-width-x` on collapse and restores it on expand
4. `measure()` reads `this.size` directly for Vue collapsed nodes via a
one-line gate: `if (!this.flags?.collapsed || LiteGraph.vueNodesMode)`.
Legacy behavior is unchanged.

## Changes

- **NodeFooter.vue**: `absolute top-full` overlay → inline flow with
`isolate -z-1` wrappers, Error/Enter button layering via `-mr-5` + DOM
order, reactive props destructuring, static `RADIUS_CLASS` lookup for
Tailwind scanning, Vue 3.3+ `defineEmits` property syntax
- **LGraphNode.vue**: Move `min-h-(--node-height)` from body to root;
remove `footerStateOutlineBottomClass`, `footerRootBorderBottomClass`,
`footerResizeHandleBottomClass`, `hasFooter` computed; replace dynamic
`beforeShapeClass` interpolation with static
`bypassOverlayClass`/`mutedOverlayClass` computeds for Tailwind scanning
- **LGraphNode.ts**: `measure()` collapsed branch gated by `||
LiteGraph.vueNodesMode` — Vue mode defers to `this.size`; legacy path
unchanged
- **useVueNodeResizeTracking.ts**: Collapsed and expanded nodes both
flow through `batchUpdateNodeBounds`; narrowed `useVueElementTracking`
parameter from `MaybeRefOrGetter<string>` to `string`;
`deferredElements.delete(element)` on unmount to prevent memory
retention
- **selectionBorder.ts**: Unchanged — `createBounds` just works because
`boundingRect` is now correct
- **12 parameterized E2E tests**: Vue mode (subgraph/regular ×
expanded/collapsed × bottom-left/bottom-right) + legacy mode
(expanded/collapsed × bottom-left/bottom-right), driven by
`keyboard.collapse()` (Alt+C)
- **Unit tests**: `measure()` branching (legacy fallback, Vue
`this.size` usage, expanded parity)
- **Shared test helpers**: `repositionNodes`, `KeyboardHelper.collapse`,
`measureSelectionBounds`, `assertSelectionEncompassesNodes`

## Review Focus

- `isolate -z-1` CSS layering pattern — is this acceptable long-term?
- `measure()` collapsed branch gated on `LiteGraph.vueNodesMode` —
one-line gate to avoid the canvas-ctx-less fallback in Vue mode
- Footer button overlap design (`-mr-5` with DOM order for painting)

## Screenshots
<img width="1392" height="800" alt="image"
src="https://github.com/user-attachments/assets/abaebff5-bb8c-4b5b-8734-8d44fdee4cb9"
/>
<img width="1493" height="872" alt="image"
src="https://github.com/user-attachments/assets/6b9c77f9-e3ae-4d4e-81dc-acfa9a24c768"
/>
<img width="813" height="515" alt="image"
src="https://github.com/user-attachments/assets/ce15bafb-e157-408c-971b-a650088f316a"
/>
<img width="1031" height="669" alt="image"
src="https://github.com/user-attachments/assets/20fdc336-4bc2-4d47-ab7e-c0cbcee0d150"
/>
<img width="753" height="525" alt="image"
src="https://github.com/user-attachments/assets/2dccbe31-7d18-49bc-9ed4-158b1659fddf"
/>
<img width="730" height="370" alt="image"
src="https://github.com/user-attachments/assets/ab87edfa-a4b4-46f7-86ae-4965a4509b42"
/>
<img width="1132" height="465" alt="image"
src="https://github.com/user-attachments/assets/54643f5b-4a31-4c3d-9475-c433f87aedb0"
/>
<img width="1102" height="449" alt="image"
src="https://github.com/user-attachments/assets/9c045df3-e1f5-481e-b1cb-ead1db1626f5"
/>

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-19 04:58:34 +00:00
Christian Byrne
2bfe3443ab [chore] Update Comfy Registry API types from comfy-api@8b5b293 (#11334)
## Automated API Type Update

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

- API commit: 8b5b293
- Generated on: 2026-04-16T22:08:45Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11334-chore-Update-Comfy-Registry-API-types-from-comfy-api-8b5b293-3456d73d365081e9ae7fc5a98bdfe194)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-18 22:09:58 -07:00
Hunter
4c35add5bc feat: add civitai.red hostname support (#11349)
*PR Created by the Glary-Bot Agent*

---

## Summary

Civitai split its domain — NSFW content moved to `civitai.red` while
`civitai.com` stays SFW. The frontend only recognized `civitai.com`
URLs, causing the import button to silently reject `.red` links. This
was the root cause of 8+ support tickets in 3 days.

Companion to backend PR: https://github.com/Comfy-Org/cloud/pull/3259

## Changes

### Import source recognition
- **`civitaiImportSource.ts`**: Added `'civitai.red'` to `hostnames`
array — this is the primary fix for "button doesn't recognize the links"

### Missing model auto-download
- **`missingModelDownload.ts`**: Added `'https://civitai.red/'` to
`ALLOWED_SOURCES`

### URL detection utilities
- **`formatUtil.ts`**: `isCivitaiModelUrl()` now accepts `civitai.red`
URLs with proper hostname validation
- **`assetMetadataUtils.ts`**: `getSourceName()` returns "Civitai" for
`.red` URLs

### Tests (4 files)
- `useUploadModelWizard.test.ts`: Added civitai.red hostnames and URL
test case
- `missingModelDownload.test.ts`: Added civitai.red cases for
`toBrowsableUrl` and `isModelDownloadable`
- `assetMetadataUtils.test.ts`: Added civitai.red case for
`getSourceName`
- `useMissingModelInteractions.test.ts`: Updated mock hostnames
- `formatUtil.test.ts`: Added civitai.red cases for `isCivitaiModelUrl`

## Not changed (intentionally)
- `getAssetSourceUrl()` ARN fallback (line 88) — ARNs don't carry domain
info, `civitai.com` is correct default
- `fetchCivitaiMetadata()` API URL (line 109) — REST API works on both
domains, keeping `civitai.com`

Resolves BE-353

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11349-feat-add-civitai-red-hostname-support-3456d73d3650810d9c62ef4ad95ae031)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-19 04:51:01 +00:00
Rizumu Ayaka
e4d9b1c214 perf: differential content bounds update via onChange
Instead of iterating all nodes on every version bump, subscribe to
layoutStore.onChange and collect changed node IDs between RAF frames.
Only the changed nodes are checked against the current bounds (O(k)
instead of O(n)). Full scans are reserved for create/delete events
(workflow load, node add/remove).
2026-04-15 20:23:00 +08:00
Rizumu Ayaka
7e5143d2f1 refactor: move version tracking into useContentBounds
Move updateContentBounds logic (version guard, workflow switch
detection, node iteration) from TransformPane into useContentBounds
as an update() method. TransformPane now calls
contentBounds.update(nodes, version) in the RAF loop.
2026-04-15 17:39:21 +08:00
Rizumu Ayaka
bf606a209e fix: address review feedback on TransformPane
- Remove toBeDefined test that only verified mock factory output
- Add test for content bounds offset with negative-coordinate nodes
- Remove unused expandToIncludePoint method (YAGNI)
- Use reactive props destructuring per project convention
2026-04-15 15:29:15 +08:00
GitHub Action
3159921280 [automated] Apply ESLint and Oxfmt fixes 2026-04-10 11:52:55 +00:00
Rizumu Ayaka
20198f1465 fix: reset content bounds on workflow switch and update stale docs
- Detect workflow switches by sampling a node ID: when the sampled
  node disappears from the map, the entire node set was replaced,
  so reset bounds to prevent unbounded growth across workflows.
- Update @example in useTransformState to reflect direct DOM mutation
  pattern used by TransformPane (replaces deprecated :style binding).
2026-04-10 19:49:53 +08:00
Rizumu Ayaka
b0e6942e92 merge: resolve conflicts with main in TransformPane tests 2026-04-10 19:21:37 +08:00
github-actions
bf906c26c6 [automated] Update test expectations 2026-04-02 06:38:13 +00:00
github-actions
d6aabe7d20 [automated] Update test expectations 2026-04-01 14:31:58 +00:00
Rizumu Ayaka
56b0f6b822 test: add pixel ratio tolerance for image preview screenshot
Sub-pixel rendering differences from TransformPane's two-layer
transform decomposition cause ~1% pixel variance across CI runs.
2026-04-01 22:23:09 +08:00
github-actions
5aa18edeb0 [automated] Update test expectations 2026-04-01 13:42:27 +00:00
Rizumu Ayaka
c9bf31e13b Merge branch 'main' into rizumu/perf/layer-compositing-performance 2026-04-01 20:33:12 +08:00
Rizumu Ayaka
e067c4434d perf: layer compositing performance 2026-03-27 20:43:54 +08:00
161 changed files with 13724 additions and 1138 deletions

View File

@@ -0,0 +1,88 @@
name: Resolve PR from workflow_run
description: >
Resolves the PR number from a workflow_run event using pull_requests[0]
with a listPullRequestsAssociatedWithCommit fallback.
Skips closed/merged PRs and stale runs (head SHA mismatch).
inputs:
token:
description: GitHub token for API calls
required: false
default: ${{ github.token }}
outputs:
skip:
description: "'true' when no open PR was found or the run is stale"
value: ${{ steps.resolve.outputs.skip }}
number:
description: The PR number (empty when skip is true)
value: ${{ steps.resolve.outputs.number }}
base:
description: The PR base branch (empty when skip is true)
value: ${{ steps.resolve.outputs.base }}
head-sha:
description: The PR head SHA (empty when skip is true)
value: ${{ steps.resolve.outputs.head-sha }}
runs:
using: composite
steps:
- name: Resolve PR
id: resolve
uses: actions/github-script@v8
with:
github-token: ${{ inputs.token }}
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
// Fork PRs: pull_requests is empty and commit SHA may not be in
// the base repo graph. Fall back to pulls.list with head filter.
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
per_page: 1,
});
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.state !== 'open') {
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
core.setOutput('skip', 'true');
return;
}
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
);
core.setOutput('skip', 'true');
return;
}
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));

View File

@@ -58,21 +58,6 @@ jobs:
retention-days: 30
if-no-files-found: warn
- name: Save PR metadata
if: github.event_name == 'pull_request'
run: |
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v6
with:
name: perf-meta
path: temp/perf-meta/
- name: Save perf baseline to perf-data branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
continue-on-error: true

View File

@@ -32,13 +32,6 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6
with:

View File

@@ -6,6 +6,10 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -30,40 +34,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get PR Number
- name: Resolve PR from workflow_run context
id: pr
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Test Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -72,7 +59,7 @@ jobs:
path: reports
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -85,6 +72,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -6,6 +6,10 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -30,40 +34,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get PR Number
- name: Resolve PR from workflow_run context
id: pr
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Storybook Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -72,7 +59,7 @@ jobs:
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -82,6 +69,6 @@ jobs:
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -18,6 +18,12 @@ on:
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
@@ -25,6 +31,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -32,28 +40,83 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
run: vercel pull --yes --environment=preview
- name: Build project artifacts
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
run: vercel build
- name: Fetch head commit metadata
id: head-commit
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
})
const author = data.author?.login || data.commit.author?.name || ''
const message = (data.commit.message || '').split('\n', 1)[0]
core.setOutput('author', author)
core.setOutput('message', message)
- name: Deploy project artifacts to Vercel
id: deploy
env:
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
GIT_PR_ID: ${{ github.event.pull_request.number }}
GIT_REPO: ${{ github.repository }}
run: |
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
URL=$(vercel deploy --prebuilt \
--meta githubCommitRef="$GIT_COMMIT_REF" \
--meta githubCommitSha="$GIT_COMMIT_SHA" \
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
--meta githubPrId="$GIT_PR_ID" \
--meta githubRepo="$GIT_REPO")
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
- name: Save PR metadata
- name: Alias deployment to stable PR hostname
id: alias-set
continue-on-error: true
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
run: |
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
- name: Publish preview outputs
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
run: |
if [[ "$ALIAS_OK" == "true" ]]; then
STABLE_URL="https://$ALIAS_HOST"
else
STABLE_URL="$DEPLOY_URL"
fi
mkdir -p temp/vercel-preview
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
{
echo "**Preview:** $STABLE_URL"
if [[ "$ALIAS_OK" == "true" ]]; then
echo "**This commit:** $DEPLOY_URL"
else
echo "_Stable alias update failed — URL reflects this commit only._"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload preview metadata
uses: actions/upload-artifact@v6
@@ -71,19 +134,24 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
run: vercel pull --yes --environment=production
- name: Build project artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
URL=$(vercel deploy --prebuilt --prod)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary

View File

@@ -30,42 +30,7 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Find size workflow run
if: steps.pr-meta.outputs.skip != 'true'

View File

@@ -12,6 +12,10 @@ permissions:
pull-requests: write
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
@@ -31,38 +35,24 @@ jobs:
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
if: steps.pr-meta.outputs.skip != 'true'
id: meta
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
env:
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
SHORT_SHA="${HEAD_SHA:0:7}"
cat > preview-report.md <<EOF
**Website Preview:** $STABLE_URL
<sub>This commit: $UNIQUE_URL</sub>
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
EOF
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'

View File

@@ -44,6 +44,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally

View File

@@ -23,6 +23,10 @@
"destination": "https://blog.comfy.org/",
"permanent": true
},
{ "source": "/press", "destination": "/about", "permanent": true }
{
"source": "/press",
"destination": "/about",
"permanent": true
}
]
}

View File

@@ -0,0 +1,135 @@
{
"id": "ee111111-2222-4333-8444-000000000001",
"revision": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "aa999999-8888-4777-a666-555555555555",
"pos": [400, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
"title": "GLSL Subgraph",
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "aa999999-8888-4777-a666-555555555555",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "GLSL Subgraph",
"inputNode": {
"id": -10,
"bounding": [50, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 200, 120, 60]
},
"inputs": [],
"outputs": [
{
"id": "bb888888-7777-4666-a555-444444444444",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"pos": { "0": 920, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [250, 180],
"size": [460, 320],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": [1]
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);\n}\n",
"from_input"
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,69 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [200, 200],
"size": [460, 320],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(v_texCoord.x, v_texCoord.y, 0.5, 1.0);\n}\n",
"from_input"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,179 @@
{
"id": "ee111111-2222-4333-8444-000000000002",
"revision": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "aa999999-8888-4777-a666-555555555556",
"pos": [400, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
"title": "GLSL Subgraph With Float",
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "aa999999-8888-4777-a666-555555555556",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "GLSL Subgraph With Float",
"inputNode": {
"id": -10,
"bounding": [50, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 200, 120, 60]
},
"inputs": [],
"outputs": [
{
"id": "bb888888-7777-4666-a555-444444444445",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"pos": { "0": 920, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [400, 180],
"size": [460, 320],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "u_float0",
"localized_name": "floats.u_float0",
"name": "floats.u_float0",
"shape": 7,
"type": "FLOAT",
"link": 2
},
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": [1]
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);\n}\n",
"from_input"
]
},
{
"id": 2,
"type": "PrimitiveFloat",
"pos": [80, 200],
"size": [270, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "FLOAT",
"widget": { "name": "value" },
"link": null
}
],
"outputs": [
{
"localized_name": "FLOAT",
"name": "FLOAT",
"type": "FLOAT",
"links": [2]
}
],
"properties": { "Node name for S&R": "PrimitiveFloat" },
"widgets_values": [1.0]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "FLOAT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,105 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [400, 200],
"size": [460, 320],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "u_bool0",
"localized_name": "bools.u_bool0",
"name": "bools.u_bool0",
"shape": 7,
"type": "BOOLEAN",
"link": 1
},
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform bool u_bool0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = u_bool0 ? vec4(1.0, 0.0, 0.0, 1.0) : vec4(0.0, 0.0, 1.0, 1.0);\n}\n",
"from_input"
]
},
{
"id": 2,
"type": "PrimitiveBoolean",
"pos": [80, 200],
"size": [270, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "BOOLEAN",
"widget": { "name": "value" },
"link": null
}
],
"outputs": [
{
"localized_name": "BOOLEAN",
"name": "BOOLEAN",
"type": "BOOLEAN",
"links": [1]
}
],
"properties": { "Node name for S&R": "PrimitiveBoolean" },
"widgets_values": [false]
}
],
"links": [[1, 2, 0, 1, 0, "BOOLEAN"]],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,105 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [400, 200],
"size": [460, 320],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "u_float0",
"localized_name": "floats.u_float0",
"name": "floats.u_float0",
"shape": 7,
"type": "FLOAT",
"link": 1
},
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, v_texCoord.y, 0.5, 1.0);\n}\n",
"from_input"
]
},
{
"id": 2,
"type": "PrimitiveFloat",
"pos": [80, 200],
"size": [270, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "FLOAT",
"widget": { "name": "value" },
"link": null
}
],
"outputs": [
{
"localized_name": "FLOAT",
"name": "FLOAT",
"type": "FLOAT",
"links": [1]
}
],
"properties": { "Node name for S&R": "PrimitiveFloat" },
"widgets_values": [0.25]
}
],
"links": [[1, 2, 0, 1, 0, "FLOAT"]],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,105 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [400, 200],
"size": [460, 320],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "u_int0",
"localized_name": "ints.u_int0",
"name": "ints.u_int0",
"shape": 7,
"type": "INT",
"link": 1
},
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform int u_int0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(float(u_int0) / 100.0, 0.0, 0.0, 1.0);\n}\n",
"from_input"
]
},
{
"id": 2,
"type": "PrimitiveInt",
"pos": [80, 200],
"size": [270, 82],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": { "name": "value" },
"link": null
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": [1]
}
],
"properties": { "Node name for S&R": "PrimitiveInt" },
"widgets_values": [25, "randomize"]
}
],
"links": [[1, 2, 0, 1, 0, "INT"]],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,92 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [1] },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 1,
"type": "GLSLShader",
"pos": [400, 200],
"size": [460, 320],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "image0",
"localized_name": "images.image0",
"name": "images.image0",
"type": "IMAGE",
"link": 1
},
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform sampler2D u_image0;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = texture(u_image0, v_texCoord);\n}\n",
"from_input"
]
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -30,8 +30,6 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
@@ -179,7 +177,6 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
@@ -233,7 +230,6 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
@@ -316,11 +312,20 @@ export class ComfyPage {
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
}
/**
* Wait for the app to finish initializing after navigation/reload:
* `window.app.extensionManager` is present, the PrimeVue block-UI mask is
* hidden, and one animation frame has elapsed. Shared by `setup()` and
* `WorkflowHelper.reloadAndWaitForApp()`.
*/
async waitForAppReady() {
await this.page.waitForFunction(
() =>
// window.app => GraphCanvas ready
// window.app.extensionManager => GraphView ready
window.app && window.app.extensionManager
// window.app => GraphCanvas ready
// window.app.extensionManager => GraphView ready
() => window.app?.extensionManager
)
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
await this.nextFrame()
@@ -499,7 +504,6 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

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

View File

@@ -1,5 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
class ShortcutsTab {
readonly essentialsTab: Locator
readonly viewControlsTab: Locator
@@ -16,6 +18,26 @@ class ShortcutsTab {
}
}
export class LogsTab {
readonly tab: Locator
readonly terminalRoot: Locator
readonly terminalHost: Locator
readonly copyButton: Locator
readonly errorMessage: Locator
readonly loadingSpinner: Locator
readonly xtermScreen: Locator
constructor(readonly page: Page) {
this.tab = page.getByRole('tab', { name: /Logs/i })
this.terminalRoot = page.getByTestId(TestIds.terminal.root)
this.terminalHost = page.getByTestId(TestIds.terminal.host)
this.copyButton = page.getByTestId(TestIds.terminal.copyButton)
this.errorMessage = page.getByTestId(TestIds.terminal.errorMessage)
this.loadingSpinner = page.getByTestId(TestIds.terminal.loadingSpinner)
this.xtermScreen = this.terminalHost.locator('.xterm-screen')
}
}
export class BottomPanel {
readonly root: Locator
readonly keyboardShortcutsButton: Locator
@@ -23,6 +45,7 @@ export class BottomPanel {
readonly closeButton: Locator
readonly resizeGutter: Locator
readonly shortcuts: ShortcutsTab
readonly logs: LogsTab
constructor(readonly page: Page) {
this.root = page.locator('.bottom-panel')
@@ -38,6 +61,15 @@ export class BottomPanel {
'.splitter-overlay-bottom > .p-splitter-gutter'
)
this.shortcuts = new ShortcutsTab(page)
this.logs = new LogsTab(page)
}
async toggleLogs() {
await this.toggleButton.click()
await this.logs.tab.waitFor({ state: 'visible' })
if ((await this.logs.tab.getAttribute('aria-selected')) !== 'true') {
await this.logs.tab.click()
}
}
async resizeByDragging(deltaY: number): Promise<void> {

View File

@@ -0,0 +1,32 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
export class CancelSubscriptionDialog extends BaseDialog {
readonly heading: Locator
readonly keepSubscriptionButton: Locator
readonly confirmCancelButton: Locator
constructor(page: Page) {
super(page)
this.heading = this.root.getByRole('heading', {
name: 'Cancel subscription'
})
this.keepSubscriptionButton = this.root.getByRole('button', {
name: 'Keep subscription'
})
this.confirmCancelButton = this.root.getByRole('button', {
name: 'Cancel subscription'
})
}
async open(cancelAt?: string) {
await this.page.evaluate((date) => {
void (
window.app!.extensionManager as WorkspaceStore
).dialog.showCancelSubscriptionDialog(date)
}, cancelAt)
await this.waitForVisible()
}
}

View File

@@ -0,0 +1,54 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
export class TopUpCreditsDialog extends BaseDialog {
readonly heading: Locator
readonly insufficientHeading: Locator
readonly preset10: Locator
readonly preset25: Locator
readonly preset50: Locator
readonly preset100: Locator
readonly payAmountInput: Locator
readonly pricingLink: Locator
constructor(page: Page) {
super(page)
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
this.insufficientHeading = this.root.getByRole('heading', {
name: 'Add more credits to run'
})
this.preset10 = this.root.getByRole('button', {
name: '$10',
exact: true
})
this.preset25 = this.root.getByRole('button', {
name: '$25',
exact: true
})
this.preset50 = this.root.getByRole('button', {
name: '$50',
exact: true
})
this.preset100 = this.root.getByRole('button', {
name: '$100',
exact: true
})
this.payAmountInput = this.root
.getByTestId('top-up-pay-amount')
.locator('input')
this.pricingLink = this.root.getByRole('link', {
name: 'View pricing details'
})
}
async open(options?: { isInsufficientCredits?: boolean }) {
await this.page.evaluate((opts) => {
void (
window.app!.extensionManager as WorkspaceStore
).dialog.showTopUpCreditsDialog(opts)
}, options)
await this.waitForVisible()
}
}

View File

@@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = {
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py', '--listen', '0.0.0.0'],
argv: ['main.py'],
ram_total: 67108864000,
ram_free: 52428800000
},

View File

@@ -0,0 +1,184 @@
import type { Locator, Page, Route } from '@playwright/test'
import type { components } from '@comfyorg/registry-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ReleaseNote = components['schemas']['ReleaseNote']
export type HelpMenuItemKey =
| 'feedback'
| 'help'
| 'docs'
| 'discord'
| 'github'
| 'manager'
| 'update-comfyui'
| 'more'
export class HelpCenterHelper {
public readonly button: Locator
public readonly popup: Locator
public readonly backdrop: Locator
public readonly whatsNewSection: Locator
constructor(public readonly page: Page) {
this.button = page.getByTestId(TestIds.helpCenter.button)
this.popup = page.getByTestId(TestIds.helpCenter.popup)
this.backdrop = page.getByTestId(TestIds.helpCenter.backdrop)
this.whatsNewSection = page.getByTestId(TestIds.dialogs.whatsNewSection)
}
menuItem(key: HelpMenuItemKey): Locator {
return this.page.getByTestId(TestIds.helpCenter.menuItem(key))
}
releaseItem(version: string): Locator {
return this.page.getByTestId(TestIds.helpCenter.releaseItem(version))
}
get releaseItems(): Locator {
return this.whatsNewSection.locator('[data-testid^="help-release-item-"]')
}
async open(): Promise<void> {
await this.button.waitFor({ state: 'visible' })
await this.button.click()
await this.popup.waitFor({ state: 'visible' })
}
async closeViaBackdrop(): Promise<void> {
await this.backdrop.click()
await this.popup.waitFor({ state: 'hidden' })
}
async toggle(): Promise<void> {
await this.button.click()
}
/**
* Mock the Comfy release API so the help center gets a deterministic
* list of releases. Empty array is used when `releases` is omitted.
*/
async mockReleases(releases: ReleaseNote[] = []): Promise<void> {
await this.page.route('**/releases**', async (route: Route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(releases)
})
} else {
await route.continue()
}
})
}
/**
* Intercept the Zendesk support URL so it never actually loads in the
* new tab opened by the Contact Support command.
*/
async stubSupportPage(): Promise<void> {
await this.page
.context()
.route('https://support.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
/**
* Intercept the docs.comfy.org changelog / guide pages so new tabs opened
* by help center actions don't hit the real site during tests.
*/
async stubDocsPage(): Promise<void> {
await this.page
.context()
.route('https://docs.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
/**
* Intercept outbound static URLs (discord, github, ...) so new tabs
* opened by help center actions don't navigate to the real sites.
*/
async stubExternalPages(): Promise<void> {
for (const pattern of [
'https://www.comfy.org/**',
'https://github.com/**'
]) {
await this.page.context().route(pattern, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
}
}
/**
* Arms the `popup` listener, runs the action that triggers `window.open`,
* then waits for the popup's initial navigation to commit so `popup.url()`
* doesn't race and return `about:blank`. Returns a parsed `URL` and closes
* the popup.
*
* @example
* ```ts
* const url = await waitForPopup(page, () => button.click())
* expect(url.hostname).toBe('example.com')
* ```
*/
export async function waitForPopup(
page: Page,
action: () => Promise<void>
): Promise<URL> {
const popupPromise = page.waitForEvent('popup')
await action()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const url = new URL(popup.url())
await popup.close()
return url
}
export function createMockRelease(
overrides: Partial<ReleaseNote> = {}
): ReleaseNote {
return {
id: 1,
project: 'comfyui',
version: '0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString(),
...overrides
}
}
/**
* Extends the main comfyPageFixture so that depending on `helpCenter`
* automatically boots the full Comfy app (via the underlying comfyPage
* fixture's setup). Tests only need to destructure `helpCenter`.
*/
export const helpCenterFixture = comfyPageFixture.extend<{
helpCenter: HelpCenterHelper
}>({
helpCenter: async ({ comfyPage }, use) => {
await use(new HelpCenterHelper(comfyPage.page))
}
})

View File

@@ -0,0 +1,75 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
let resolve!: () => void
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return resolve
}
async mockRawLogsError() {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {
return JSON.stringify({
type: 'logs',
data: { entries: LogsTerminalHelper.buildEntries(messages) }
})
}
private static buildRawLogsResponse(messages: string[]): LogsRawResponse {
return {
size: { cols: 80, row: 24 },
entries: LogsTerminalHelper.buildEntries(messages)
}
}
private static buildEntries(messages: string[]) {
return messages.map((m) => ({
t: '1970-01-01T00:00:00.000Z',
m: m.endsWith('\n') ? m : `${m}\n`
}))
}
}
export const logsTerminalFixture = base.extend<{
logsTerminal: LogsTerminalHelper
}>({
logsTerminal: async ({ page }, use) => {
await use(new LogsTerminalHelper(page))
}
})

View File

@@ -1,7 +1,10 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -120,6 +123,27 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -202,3 +226,13 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -60,16 +60,25 @@ export class WorkflowHelper {
await this.comfyPage.nextFrame()
}
async waitForDraftPersisted({ timeout = 5000 } = {}) {
await this.comfyPage.page.waitForFunction(
() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
),
{ timeout }
async waitForDraftPersisted() {
await this.comfyPage.page.waitForFunction(() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
)
)
}
/**
* Reloads the current page and waits for the app to initialize.
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and
* the URL hash (subgraph navigation state), so the app restores
* exactly where the user left off.
*/
async reloadAndWaitForApp() {
await this.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await this.comfyPage.waitForAppReady()
}
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
await this.comfyPage.page.evaluate(
(wf) => window.app!.loadGraphData(wf),

View File

@@ -0,0 +1,95 @@
import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
export type { CanvasRect }
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
const isLegacyCanvasNodeWithoutVueDom = !nodeEl
if (isLegacyCanvasNodeWithoutVueDom) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (!node) {
throw new Error(`Node ${id} not found in graph`)
}
const rect = node.boundingRect
nodeVisualBounds[id] = {
x: rect[0],
y: rect[1],
w: rect[2],
h: rect[3]
}
continue
}
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -115,6 +115,13 @@ export const TestIds = {
menu: {
moreMenuContent: 'more-menu-content'
},
helpCenter: {
button: 'help-center-button',
popup: 'help-center-popup',
backdrop: 'help-center-backdrop',
menuItem: (key: string) => `help-menu-item-${key}`,
releaseItem: (version: string) => `help-release-item-${version}`
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -199,6 +206,13 @@ export const TestIds = {
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
terminal: {
root: 'terminal-root',
host: 'terminal-host',
copyButton: 'terminal-copy-button',
errorMessage: 'terminal-error-message',
loadingSpinner: 'terminal-loading-spinner'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
@@ -211,34 +225,11 @@ export const TestIds = {
}
} as const
/**
* Helper type for accessing nested TestIds (excludes function values)
*/
export type TestIdValue =
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]
export type TestId<K extends keyof typeof TestIds> = Exclude<
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
(...args: never[]) => string
>
export type TestIdValue = {
[K in keyof typeof TestIds]: TestId<K>
}[keyof typeof TestIds]

View File

@@ -4,6 +4,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
return {
@@ -331,6 +332,22 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/**
* Toggle the node's collapsed state by simulating the same user interaction
* the runtime uses: DOM collapse button click in Vue mode, canvas icon click
* in legacy mode. Mode is detected by the presence of a Vue-rendered DOM
* element with `data-node-id`.
*/
async toggleCollapse() {
const vueLocator = this.comfyPage.page.locator(
`[data-node-id="${this.id}"]`
)
if ((await vueLocator.count()) > 0) {
await new VueNodeFixture(vueLocator).toggleCollapse()
return
}
await this.click('collapse')
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
@@ -17,6 +18,8 @@ import {
STABLE_OUTPUT
} from '@e2e/fixtures/data/assetFixtures'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets respects pagination params', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets filters by include_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('PUT /assets/:id updates asset in store', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({ comfyPage }) => {
test('POST /assets returns upload response', async ({
comfyPage,
assetApi
}) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('tracks POST, PUT, DELETE mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET requests are not tracked as mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('returns error status for all asset routes', async ({
comfyPage,
assetApi
}) => {
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()

View File

@@ -1,98 +1,151 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import {
LogsTerminalHelper,
logsTerminalFixture
} from '@e2e/fixtures/helpers/LogsTerminalHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/helpers/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
test.describe('panel', () => {
test.beforeEach(async ({ logsTerminal }) => {
await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs([])
})
await expect(bottomPanel.root).toBeHidden()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
test('opens to Logs tab via toggle button', async ({ comfyPage }) => {
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.tab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
})
test('closes via toggle button', async ({ comfyPage }) => {
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleButton.click()
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
test('switches from shortcuts to Logs tab', async ({ comfyPage }) => {
await comfyPage.bottomPanel.keyboardShortcutsButton.click()
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.tab).toBeVisible()
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeHidden()
})
})
test('should show Logs tab when terminal panel opens', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test.describe('terminal', () => {
test.beforeEach(async ({ logsTerminal }) => {
await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs([])
})
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
test('shows loading spinner while logs are loading', async ({
comfyPage,
logsTerminal
}) => {
const resolveRaw = await logsTerminal.mockRawLogsPending()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
})
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
resolveRaw()
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
})
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
test('renders initial log entries from the raw-logs API', async ({
comfyPage,
logsTerminal
}) => {
const logLine = 'Hello from ComfyUI backend!'
await logsTerminal.mockRawLogs([logLine])
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeHidden()
})
await comfyPage.bottomPanel.toggleLogs()
test('should switch between shortcuts and terminal panels', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
logLine
)
})
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
test('appends log entries received via WebSocket', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
await bottomPanel.toggleButton.click()
const ws = await getWebSocket()
const firstLine = 'First live log line'
const secondLine = 'Second live log line'
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeHidden()
})
ws.send(LogsTerminalHelper.buildWsLogFrame([firstLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
firstLine
)
test('should persist Logs tab content in bottom panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
firstLine
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
secondLine
)
})
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
test('copy button copies terminal contents to clipboard', async ({
comfyPage,
logsTerminal
}) => {
const logLine = 'Copy me to the clipboard'
await logsTerminal.mockRawLogs([logLine])
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
logLine
)
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
await interceptClipboardWrite(comfyPage.page)
const xtermContainer = bottomPanel.root.locator('.xterm')
await expect(xtermContainer).toBeVisible()
})
await comfyPage.bottomPanel.logs.terminalRoot.hover()
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
await comfyPage.bottomPanel.logs.copyButton.click()
test('should render xterm container in terminal panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await expect
.poll(() => getClipboardText(comfyPage.page))
.toContain(logLine)
})
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
test('shows error message when raw-logs API fails', async ({
comfyPage,
logsTerminal
}) => {
await logsTerminal.mockRawLogsError()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
await expect(xtermScreen.first()).toBeVisible()
await expect(comfyPage.bottomPanel.logs.errorMessage).toBeVisible()
await expect(comfyPage.bottomPanel.logs.errorMessage).toContainText(
'Unable to load logs'
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
test.describe('CancelSubscription dialog', { tag: '@ui' }, () => {
let dialog: CancelSubscriptionDialog
test.beforeEach(async ({ comfyPage }) => {
dialog = new CancelSubscriptionDialog(comfyPage.page)
})
test('displays dialog with title and formatted date', async () => {
await dialog.open('2025-12-31T12:00:00Z')
await expect(dialog.heading).toBeVisible()
await expect(dialog.root).toContainText('December 31, 2025')
})
test('"Keep subscription" button closes dialog', async () => {
await dialog.open()
await dialog.keepSubscriptionButton.click()
await expect(dialog.root).toBeHidden()
})
test('Escape key closes dialog', async ({ comfyPage }) => {
await dialog.open()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).toBeHidden()
})
test('"Cancel subscription" button initiates cancellation flow', async () => {
await dialog.open()
await expect(dialog.confirmCancelButton).toBeEnabled()
await dialog.confirmCancelButton.click()
// Next state: dialog closes once the cancellation flow completes
await expect(dialog.root).toBeHidden()
})
})

View File

@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
argv: ['main.py', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {

View File

@@ -0,0 +1,58 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
let dialog: TopUpCreditsDialog
test.beforeEach(async ({ comfyPage }) => {
dialog = new TopUpCreditsDialog(comfyPage.page)
})
test('displays dialog with heading and preset amounts', async () => {
await dialog.open()
await expect(dialog.heading).toBeVisible()
await expect(dialog.preset10).toBeVisible()
await expect(dialog.preset25).toBeVisible()
await expect(dialog.preset50).toBeVisible()
await expect(dialog.preset100).toBeVisible()
})
test('displays insufficient credits message when opened with flag', async () => {
await dialog.open({ isInsufficientCredits: true })
await expect(dialog.insufficientHeading).toBeVisible()
await expect(dialog.root).toContainText(
"You don't have enough credits to run this workflow"
)
})
test('selecting a preset amount updates the pay amount', async () => {
await dialog.open()
// Default preset is $50, click $10 instead
await dialog.preset10.click()
await expect(dialog.payAmountInput).toHaveValue('10')
})
test('close button dismisses dialog', async () => {
await dialog.open()
await dialog.closeButton.click()
await expect(dialog.root).toBeHidden()
})
test('pricing details link points to docs pricing page', async () => {
await dialog.open()
await expect(dialog.pricingLink).toBeVisible()
await expect(dialog.pricingLink).toHaveAttribute(
'href',
/partner-nodes\/pricing/
)
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
})
})

View File

@@ -0,0 +1,54 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('File input same-file reselection', () => {
test('should allow uploading the same file twice via LoadImage node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const loadImageNode = loadImageNodes[0]
const uploadWidget = await loadImageNode.getWidget(1)
const fileWidget = await loadImageNode.getWidget(0)
// First upload
const firstUpload = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
const firstChooser = comfyPage.page.waitForEvent('filechooser')
await uploadWidget.click()
await (
await firstChooser
).setFiles(comfyPage.assetPath('test_upload_image.png'))
await firstUpload
await expect
.poll(() => fileWidget.getValue(), {
message: 'First upload should set widget value'
})
.toContain('test_upload_image')
// Second upload of the SAME file — before the fix, the hidden input
// retained the previous value and onchange did not fire.
const secondUpload = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
const secondChooser = comfyPage.page.waitForEvent('filechooser')
await uploadWidget.click()
await (
await secondChooser
).setFiles(comfyPage.assetPath('test_upload_image.png'))
await secondUpload
await expect
.poll(() => fileWidget.getValue(), {
message: 'Second upload of the same file should still set widget value'
})
.toContain('test_upload_image')
})
})

View File

@@ -0,0 +1,174 @@
import { expect } from '@playwright/test'
import {
createMockRelease,
helpCenterFixture as test,
waitForPopup
} from '@e2e/fixtures/helpers/HelpCenterHelper'
test.describe('Help Center', () => {
test.describe('popup visibility', () => {
test('opens the popup and shows the backdrop when the sidebar button is clicked', async ({
helpCenter
}) => {
await helpCenter.toggle()
await expect(helpCenter.popup).toBeVisible()
await expect(helpCenter.backdrop).toBeVisible()
})
test('closes when the backdrop is clicked', async ({ helpCenter }) => {
await helpCenter.open()
await helpCenter.closeViaBackdrop()
await expect(helpCenter.popup).toBeHidden()
})
test('closes after clicking a menu item that opens an external tab', async ({
helpCenter
}) => {
await helpCenter.stubDocsPage()
await helpCenter.open()
await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('docs').click()
)
await expect(helpCenter.popup).toBeHidden()
})
})
test.describe('popup positioning', () => {
test('anchors to the left when sidebar location is left', async ({
comfyPage,
helpCenter
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await helpCenter.open()
await expect(helpCenter.popup).toHaveClass(/sidebar-left/)
await expect(helpCenter.popup).not.toHaveClass(/sidebar-right/)
})
test('anchors to the right when sidebar location is right', async ({
comfyPage,
helpCenter
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await helpCenter.open()
await expect(helpCenter.popup).toHaveClass(/sidebar-right/)
await expect(helpCenter.popup).not.toHaveClass(/sidebar-left/)
})
})
test.describe('menu item actions', () => {
test.beforeEach(async ({ helpCenter }) => {
await helpCenter.stubDocsPage()
await helpCenter.stubExternalPages()
await helpCenter.stubSupportPage()
await helpCenter.open()
})
test('Docs item opens docs.comfy.org/ in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('docs').click()
)
expect(url.hostname).toBe('docs.comfy.org')
expect(url.pathname).toBe('/')
})
test('Discord item opens comfy.org/discord in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('discord').click()
)
expect(url.hostname).toBe('www.comfy.org')
expect(url.pathname).toBe('/discord')
})
test('Github item opens the ComfyUI repo in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('github').click()
)
expect(url.hostname).toBe('github.com')
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
})
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('help').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
test('Give Feedback item opens Contact Support in OSS mode', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('feedback').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
})
test.describe("What's New releases", () => {
test('renders only the three most recent releases', async ({
comfyPage,
helpCenter
}) => {
const versions = ['0.4.10', '0.4.9', '0.4.8', '0.4.7', '0.4.6']
const now = Date.now()
const releases = versions.map((version, idx) =>
createMockRelease({
id: idx + 1,
version,
published_at: new Date(now - idx * 60_000).toISOString()
})
)
await helpCenter.mockReleases(releases)
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
await expect(helpCenter.whatsNewSection).toBeVisible()
await expect(helpCenter.releaseItems).toHaveCount(3)
await expect(helpCenter.releaseItem('0.4.10')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.9')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.8')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.7')).toHaveCount(0)
})
test('clicking a release opens the changelog with a version anchor', async ({
comfyPage,
helpCenter
}) => {
const release = createMockRelease({ version: '0.3.50' })
await helpCenter.mockReleases([release])
await helpCenter.stubDocsPage()
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.releaseItem('0.3.50').click()
)
expect(url.hostname).toBe('docs.comfy.org')
expect(url.pathname).toBe('/changelog')
expect(url.hash).toBe('#v0-3-50')
await expect(helpCenter.popup).toBeHidden()
})
})
})

View File

@@ -0,0 +1,235 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
type Layout = { ref: [number, number]; target: [number, number] }
const LAYOUTS: Record<string, Layout> = {
'bottom-left': { ref: [200, 100], target: [150, 500] },
'bottom-right': { ref: [100, 100], target: [600, 500] }
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
async function toggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
await comfyPage.keyboard.bypass()
}
async function assertSelectionEncompassesNodes(
page: Page,
comfyPage: ComfyPage,
nodeIds: string[]
) {
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, nodeIds)
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
for (const nodeId of nodeIds) {
const vis = result.nodeVisualBounds[nodeId]
expect(vis).toBeDefined()
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
}
}
test.describe(
'Selection bounding box (Vue mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const vueCases: ReadonlyArray<{
type: NodeType
state: NodeState
pos: Position
}> = [
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
]
for (const { type, state, pos } of vueCases) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: LAYOUTS[pos].ref,
[targetId]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
)
test.describe(
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('collapsed node narrows bounding box when bypass is removed', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
test('collapsed node widens bounding box when bypass is added', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
)
test.describe(
'Selection bounding box (legacy mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
{ state: 'expanded', pos: 'bottom-left' },
{ state: 'expanded', pos: 'bottom-right' },
{ state: 'collapsed', pos: 'bottom-left' },
{ state: 'collapsed', pos: 'bottom-right' }
]
for (const { state, pos } of legacyCases) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
[REGULAR_ID]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
)

View File

@@ -1,83 +1,65 @@
import { expect } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
interface SubgraphNodePosition {
id: NodeId
x: number
y: number
}
function getSubgraphNodePositions(): SubgraphNodePosition[] {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
}
test.describe(
'Subgraph node positions after draft reload',
{ tag: ['@subgraph'] },
{ tag: ['@subgraph', '@vue-nodes'] },
() => {
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
comfyPage
}) => {
test.setTimeout(30000)
// Enable workflow persistence explicitly
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// Load a workflow containing a subgraph
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// Enter the subgraph programmatically (fixture node is too small for UI click)
await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
if (sg) window.app!.canvas.setGraph(sg)
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.enterSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const positionsBefore = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
let positionsBefore: SubgraphNodePosition[] = []
await expect
.poll(async () => {
const positions = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
return positions.length
positionsBefore = await comfyPage.page.evaluate(
getSubgraphNodePositions
)
return positionsBefore.length
})
.toBeGreaterThan(0)
// Wait for the debounced draft persistence to flush to localStorage
expect(
positionsBefore.length,
'Expected nodes before reload'
).toBeGreaterThan(0)
await comfyPage.workflow.waitForDraftPersisted()
await comfyPage.workflow.reloadAndWaitForApp()
// Reload the page (draft auto-loads with hash preserved)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.locator('.p-blockui-mask').waitFor({
state: 'hidden'
})
await comfyPage.nextFrame()
// Wait for subgraph auto-entry via hash navigation
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
.toBe(true)
// Verify all internal node positions are preserved
for (const before of positionsBefore) {
await expect
.poll(async () => {
const positionsNow = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
const positionsNow = await comfyPage.page.evaluate(
getSubgraphNodePositions
)
const after = positionsNow.find((n) => n.id === before.id)
if (!after) return null
return { x: after.x, y: after.y }

View File

@@ -0,0 +1,81 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Topbar menu commands', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('New command creates a new workflow tab', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await topbar.triggerTopbarCommand(['New'])
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('File > Save opens save dialog', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['File', 'Save'])
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
await expect(saveDialog).toBeVisible()
})
test('View > Bottom Panel toggles bottom panel', async ({ comfyPage }) => {
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
})

View File

@@ -0,0 +1,512 @@
import type { Locator, WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyPageFixture,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const GLSL_NODE_ID = '1'
const GLSL_NODE_TITLE = 'GLSL Shader'
const PRIMITIVE_FLOAT_NODE_TITLE = 'Float'
const PRIMITIVE_INT_NODE_TITLE = 'Int'
const PRIMITIVE_BOOLEAN_NODE_TITLE = 'Boolean'
const RED_SHADER = [
'#version 300 es',
'precision highp float;',
'uniform vec2 u_resolution;',
'in vec2 v_texCoord;',
'layout(location = 0) out vec4 fragColor0;',
'void main() {',
' fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);',
'}'
].join('\n')
/** Shader that drives every pixel's red channel from `u_float0` alone. */
const FLOAT_RED_SHADER = [
'#version 300 es',
'precision highp float;',
'uniform float u_float0;',
'layout(location = 0) out vec4 fragColor0;',
'void main() {',
' fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);',
'}'
].join('\n')
/** Wait until an `<img>` locator has finished decoding. */
async function waitForImageDecoded(image: Locator): Promise<void> {
await expect
.poll(() =>
image.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
)
)
.toBe(true)
}
/** Page-object helper bound to the GLSLShader node under test. */
class GLSLShaderNode {
readonly node: Locator
/**
* Any `<img>` inside the node whose src is a `blob:` URL. Covers both
* the standalone-node `LivePreview` path and the subgraph-wrapped
* promoted-preview path (where the blob surfaces via `ImagePreview`).
*/
readonly previewImage: Locator
readonly shaderTextbox: Locator
readonly widthInput: Locator
readonly heightInput: Locator
constructor(
private readonly comfyPage: ComfyPage,
readonly nodeId: string,
readonly title: string
) {
this.node = comfyPage.vueNodes.getNodeLocator(nodeId)
this.previewImage = this.node.locator('img[src^="blob:"]')
this.shaderTextbox = this.node.getByRole('textbox', {
name: 'fragment_shader'
})
this.widthInput = this.node
.getByLabel('size_mode.width', { exact: true })
.locator('input')
this.heightInput = this.node
.getByLabel('size_mode.height', { exact: true })
.locator('input')
}
/**
* Fire `execution_start` + `executed` with an image output for this node,
* which satisfies the `hasExecutionOutput` gate in `useGLSLPreview`.
*/
async simulateExecutionOutput(ws: WebSocketRoute) {
const exec = new ExecutionHelper(this.comfyPage, ws)
const jobId = await exec.run()
await this.comfyPage.nextFrame()
exec.executionStart(jobId)
exec.executed(jobId, this.nodeId, {
images: [{ filename: 'glsl_test.png', subfolder: '', type: 'output' }]
})
exec.executionSuccess(jobId)
}
async getPreviewSrc(): Promise<string | null> {
return this.previewImage.getAttribute('src')
}
async getPreviewNaturalSize(): Promise<{ width: number; height: number }> {
return this.previewImage.evaluate((el: HTMLImageElement) => ({
width: el.naturalWidth,
height: el.naturalHeight
}))
}
async selectSizeMode(option: 'from_input' | 'custom'): Promise<void> {
await this.comfyPage.vueNodes.selectComboOption(
this.title,
'size_mode',
option
)
}
/** Wait until the preview image has a blob: URL and return it. */
async waitForBlobSrc(): Promise<string> {
await expect.poll(() => this.getPreviewSrc()).toMatch(/^blob:/)
return (await this.getPreviewSrc())!
}
/**
* Draw the preview blob to a 2D canvas and verify every pixel matches.
*/
async expectEveryPixelToBe(
expected: [number, number, number, number],
tolerance = 1
): Promise<void> {
await waitForImageDecoded(this.previewImage)
const mismatch = await this.previewImage.evaluate(
(
img: HTMLImageElement,
args: { exp: [number, number, number, number]; tol: number }
) => {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 4; c++) {
if (Math.abs(data[i + c] - args.exp[c]) > args.tol) {
return {
index: i / 4,
actual: [data[i], data[i + 1], data[i + 2], data[i + 3]]
}
}
}
}
return null
},
{ exp: expected, tol: tolerance }
)
const message = mismatch
? `expected every pixel ≈ [${expected.join(',')}] ±${tolerance}; pixel ${mismatch.index} was [${mismatch.actual.join(',')}]`
: undefined
expect(mismatch, message).toBeNull()
}
}
/**
* Drop an image file onto a LoadImage node and wait for its preview to render.
*/
async function dropImageOntoLoadImage(
comfyPage: ComfyPage,
nodeId: string,
filename: string
): Promise<void> {
const node = comfyPage.vueNodes.getNodeLocator(nodeId)
const box = await node.boundingBox()
expect(
box,
`LoadImage node ${nodeId} must have a bounding box`
).not.toBeNull()
await comfyPage.dragDrop.dragAndDropFile(filename, {
dropPosition: { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
})
const preview = node.locator('.image-preview img')
await expect(preview).toBeVisible()
await waitForImageDecoded(preview)
}
test.describe('GLSL Shader Preview', { tag: ['@vue-nodes', '@node'] }, () => {
test.describe('standalone node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_standalone')
await comfyPage.vueNodes.waitForNodes(1)
})
test('renders a blob preview into the node after execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await test.step('no preview is present before execution', async () => {
await expect(glsl.previewImage).toHaveCount(0)
})
await test.step('execution populates preview with a blob URL', async () => {
await glsl.simulateExecutionOutput(ws)
await expect(glsl.previewImage).toBeVisible()
await glsl.waitForBlobSrc()
})
})
test('refreshes the preview when the fragment shader is edited', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await glsl.simulateExecutionOutput(ws)
const initialSrc = await glsl.waitForBlobSrc()
await test.step('editing the shader replaces the blob URL', async () => {
await glsl.shaderTextbox.fill(RED_SHADER)
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
})
})
test('custom size_mode controls rendered resolution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await test.step('switch size_mode to custom and set width/height', async () => {
await glsl.selectSizeMode('custom')
await expect(glsl.widthInput).toBeVisible()
await expect(glsl.heightInput).toBeVisible()
await glsl.widthInput.fill('16')
await glsl.widthInput.blur()
await glsl.heightInput.fill('32')
await glsl.heightInput.blur()
})
await test.step('executed preview uses the custom resolution', async () => {
await glsl.simulateExecutionOutput(ws)
await expect(glsl.previewImage).toBeVisible()
await glsl.waitForBlobSrc()
await expect
.poll(() => glsl.getPreviewNaturalSize())
.toEqual({ width: 16, height: 32 })
})
})
test('logs a compile failure then recovers when shader becomes valid again', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
// Captures every `[GLSL] shader compilation failed` warning emitted
// by `useGLSLPreview.ts` during this test.
const compileFailure = comfyPage.page.waitForEvent('console', {
predicate: (msg) =>
msg.type() === 'warning' &&
msg.text().includes('[GLSL] shader compilation failed')
})
await glsl.simulateExecutionOutput(ws)
const goodSrc = await glsl.waitForBlobSrc()
await glsl.shaderTextbox.fill('not valid glsl at all')
await compileFailure // ensures the invalid shader actually hit the compiler
await glsl.shaderTextbox.fill(RED_SHADER)
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(goodSrc)
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
})
})
test.describe('with primitive float source', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_float')
await comfyPage.vueNodes.waitForNodes(2)
})
test('refreshes preview when upstream PrimitiveFloat value changes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
const floatValueWidget = comfyPage.vueNodes.getWidgetByName(
PRIMITIVE_FLOAT_NODE_TITLE,
'value'
)
const { input: floatValueInput } =
comfyPage.vueNodes.getInputNumberControls(floatValueWidget)
// Drive every pixel's red channel directly from u_float0 so the
// before/after refresh is visually obvious (dim red → pure red).
await glsl.shaderTextbox.fill(FLOAT_RED_SHADER)
await glsl.simulateExecutionOutput(ws)
const initialSrc = await glsl.waitForBlobSrc()
// Workflow default is 0.25 → ~64; RGBA16F → PNG round-trip can drift.
await glsl.expectEveryPixelToBe([64, 0, 0, 255], 2)
await test.step('changing the upstream float value re-renders the preview', async () => {
await expect(floatValueInput).toBeVisible()
await floatValueInput.fill('1.0')
await floatValueInput.blur()
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
})
})
})
test.describe('with upstream LoadImage', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_loadimage')
await comfyPage.vueNodes.waitForNodes(2)
})
const LOAD_IMAGE_NODE_ID = '2'
test('uses upstream image dimensions', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await dropImageOntoLoadImage(
comfyPage,
LOAD_IMAGE_NODE_ID,
'image64x64.webp'
)
await glsl.simulateExecutionOutput(ws)
await glsl.waitForBlobSrc()
await expect
.poll(() => glsl.getPreviewNaturalSize())
.toEqual({ width: 64, height: 64 })
})
test('ensures shaders are correctly executed', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await dropImageOntoLoadImage(
comfyPage,
LOAD_IMAGE_NODE_ID,
'image64x64.webp'
)
await glsl.shaderTextbox.fill(RED_SHADER)
await glsl.simulateExecutionOutput(ws)
await glsl.waitForBlobSrc()
await expect
.poll(() => glsl.getPreviewNaturalSize())
.toEqual({ width: 64, height: 64 })
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
})
})
test.describe('with primitive int source', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_int')
await comfyPage.vueNodes.waitForNodes(2)
})
test('refreshes preview when upstream PrimitiveInt value changes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
const intValueWidget = comfyPage.vueNodes.getWidgetByName(
PRIMITIVE_INT_NODE_TITLE,
'value'
)
const { input: intValueInput } =
comfyPage.vueNodes.getInputNumberControls(intValueWidget)
await glsl.simulateExecutionOutput(ws)
const initialSrc = await glsl.waitForBlobSrc()
await test.step('changing the upstream int value re-renders the preview', async () => {
await expect(intValueInput).toBeVisible()
await intValueInput.fill('100')
await intValueInput.blur()
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
})
await test.step('upstream int value flows through as the u_int0 uniform', async () => {
// Shader writes vec4(float(u_int0) / 100.0, 0, 0, 1); value 100 → red.
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
})
})
})
test.describe('with primitive boolean source', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_bool')
await comfyPage.vueNodes.waitForNodes(2)
})
test('upstream PrimitiveBoolean value flows through as the u_bool0 uniform', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
const booleanToggle = comfyPage.vueNodes
.getNodeByTitle(PRIMITIVE_BOOLEAN_NODE_TITLE)
.getByRole('switch', { name: 'value' })
await test.step('boolean=false renders blue', async () => {
await glsl.simulateExecutionOutput(ws)
await glsl.waitForBlobSrc()
// Blue (non-max channel) through RGBA16F → PNG round-trip can drift by 2.
await glsl.expectEveryPixelToBe([0, 0, 255, 255], 2)
})
await test.step('toggling boolean=true re-renders red', async () => {
const blueSrc = (await glsl.getPreviewSrc())!
await expect(booleanToggle).toBeVisible()
await booleanToggle.click()
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(blueSrc)
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
})
})
})
test.describe('GLSL inside a subgraph', () => {
const SUBGRAPH_NODE_ID = '1'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_in_subgraph')
await comfyPage.vueNodes.waitForNodes(1)
})
test('renders a GLSL blob preview on the outer subgraph node', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
// Inside a subgraph, the GLSL renderer writes the blob preview to the
// INNER GLSLShader's locator; the outer subgraph node surfaces it via
// the promoted-preview path (ImagePreview component), not LivePreview.
// Either way, the observable signal is an <img> with a blob: src.
const subgraph = new GLSLShaderNode(
comfyPage,
SUBGRAPH_NODE_ID,
'GLSL Subgraph'
)
await subgraph.simulateExecutionOutput(ws)
await expect(subgraph.previewImage).toBeVisible()
await subgraph.waitForBlobSrc()
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
})
})
test.describe('GLSL inside a subgraph with uniform source', () => {
const SUBGRAPH_NODE_ID = '1'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'nodes/glsl_shader_subgraph_with_float'
)
await comfyPage.vueNodes.waitForNodes(1)
})
test('extracts uniform sources from inner upstream widgets', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
// Inner PrimitiveFloat is wired to the inner GLSLShader's floats.u_float0
// input. useGLSLUniforms.extractUniformSources should pick it up and feed
// 1.0 as u_float0 — shader outputs vec4(u_float0, 0, 0, 1) → red.
const subgraph = new GLSLShaderNode(
comfyPage,
SUBGRAPH_NODE_ID,
'GLSL Subgraph With Float'
)
await subgraph.simulateExecutionOutput(ws)
await expect(subgraph.previewImage).toBeVisible()
await subgraph.waitForBlobSrc()
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -209,7 +209,8 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_drag_and_dropped.png'
'image_preview_drag_and_dropped.png',
{ maxDiffPixelRatio: 0.02 }
)
// Expect the filename combo value to be updated

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -26,6 +26,8 @@ An Entity Component System (ECS) separates **identity** (entities), **data** (co
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
For the full problem catalog with line-level code references, see [Entity System Structural Problems](../architecture/entity-problems.md). For a map of all current entity relationships, see [Entity Interactions](../architecture/entity-interactions.md).
## Decision
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
@@ -172,7 +174,7 @@ Systems are pure functions that query the World for entities with specific compo
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
System design is deferred to a future ADR.
System design is deferred to a future ADR. For detailed before/after walkthroughs of how lifecycle operations (node removal, link creation, subgraph nesting, etc.) transform under ECS, see [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md).
### Migration Strategy
@@ -182,6 +184,8 @@ System design is deferred to a future ADR.
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
### Relationship to ADR 0003 (Command Pattern / CRDT)
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
@@ -231,6 +235,23 @@ Planned mitigations for the ECS render path:
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
## Supporting Documents
Companion architecture documents that expand on the design in this ADR:
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
## Notes
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.

View File

@@ -284,7 +284,10 @@ export default defineConfig([
message:
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
}
]
],
// Tests routinely define stub and harness components side-by-side with the
// system under test, which is a distinct use case from production SFCs.
'vue/one-component-per-file': 'off'
}
},
{

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.4",
"version": "1.44.7",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -28,6 +28,7 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -44,11 +45,6 @@ export type {
CheckHubUsernameErrors,
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClaimInviteCodeData,
ClaimInviteCodeError,
ClaimInviteCodeErrors,
ClaimInviteCodeResponse,
ClaimInviteCodeResponses,
ClientOptions,
CreateAssetDownloadData,
CreateAssetDownloadError,
@@ -113,6 +109,13 @@ export type {
CreateWorkflowVersionRequest,
CreateWorkflowVersionResponse,
CreateWorkflowVersionResponses,
CreateWorkspaceApiKeyData,
CreateWorkspaceApiKeyError,
CreateWorkspaceApiKeyErrors,
CreateWorkspaceApiKeyRequest,
CreateWorkspaceApiKeyResponse,
CreateWorkspaceApiKeyResponse2,
CreateWorkspaceApiKeyResponses,
CreateWorkspaceData,
CreateWorkspaceError,
CreateWorkspaceErrors,
@@ -237,12 +240,16 @@ export type {
GetBillingStatusErrors,
GetBillingStatusResponse,
GetBillingStatusResponses,
GetCustomNodeProxyData,
GetCustomNodeProxyErrors,
GetCustomNodeProxyResponses,
GetDeletionRequestData,
GetDeletionRequestError,
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
GetFeaturesData,
GetFeaturesResponse,
@@ -263,7 +270,9 @@ export type {
GetGlobalSubgraphsResponse,
GetGlobalSubgraphsResponses,
GetHealthData,
GetHealthError,
GetHealthErrors,
GetHealthResponse,
GetHealthResponses,
GetHistoryData,
GetHistoryError,
@@ -285,11 +294,6 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetInviteCodeStatusData,
GetInviteCodeStatusError,
GetInviteCodeStatusErrors,
GetInviteCodeStatusResponse,
GetInviteCodeStatusResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -339,9 +343,19 @@ export type {
GetMyHubProfileErrors,
GetMyHubProfileResponse,
GetMyHubProfileResponses,
GetNodeByIdData,
GetNodeByIdErrors,
GetNodeByIdResponses,
GetNodeInfoData,
GetNodeInfoResponse,
GetNodeInfoResponses,
GetNodeInfoSchemaData,
GetNodeInfoSchemaResponses,
GetNodeReplacementsData,
GetNodeReplacementsError,
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetPaymentPortalData,
@@ -422,11 +436,15 @@ export type {
GetUserErrors,
GetUserResponse,
GetUserResponses,
GetUsersRawData,
GetUsersRawErrors,
GetUsersRawResponses,
GetUsersInfoData,
GetUsersInfoError,
GetUsersInfoErrors,
GetUsersInfoResponse,
GetUsersInfoResponses,
GetVhsQueryVideoData,
GetVhsQueryVideoError,
GetVhsQueryVideoErrors,
GetVhsQueryVideoResponse,
GetVhsQueryVideoResponses,
GetVhsViewAudioData,
GetVhsViewAudioErrors,
@@ -487,8 +505,6 @@ export type {
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
InviteCodeClaimResponse,
InviteCodeStatusResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
@@ -551,6 +567,12 @@ export type {
ListWorkflowsErrors,
ListWorkflowsResponse,
ListWorkflowsResponses,
ListWorkspaceApiKeysData,
ListWorkspaceApiKeysError,
ListWorkspaceApiKeysErrors,
ListWorkspaceApiKeysResponse,
ListWorkspaceApiKeysResponse2,
ListWorkspaceApiKeysResponses,
ListWorkspaceInvitesData,
ListWorkspaceInvitesError,
ListWorkspaceInvitesErrors,
@@ -601,6 +623,9 @@ export type {
PostAssetsFromWorkflowErrors,
PostAssetsFromWorkflowResponse,
PostAssetsFromWorkflowResponses,
PostCustomNodeProxyData,
PostCustomNodeProxyErrors,
PostCustomNodeProxyResponses,
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
@@ -661,6 +686,11 @@ export type {
ResubscribeResponse,
ResubscribeResponse2,
ResubscribeResponses,
RevokeWorkspaceApiKeyData,
RevokeWorkspaceApiKeyError,
RevokeWorkspaceApiKeyErrors,
RevokeWorkspaceApiKeyResponse,
RevokeWorkspaceApiKeyResponses,
RevokeWorkspaceInviteData,
RevokeWorkspaceInviteError,
RevokeWorkspaceInviteErrors,
@@ -668,13 +698,6 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SendUserInviteEmailData,
SendUserInviteEmailError,
SendUserInviteEmailErrors,
SendUserInviteEmailRequest,
SendUserInviteEmailResponse,
SendUserInviteEmailResponse2,
SendUserInviteEmailResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -718,6 +741,12 @@ export type {
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateHubWorkflowData,
UpdateHubWorkflowError,
UpdateHubWorkflowErrors,
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -734,6 +763,11 @@ export type {
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
UpdateSubscriptionCacheResponse,
UpdateSubscriptionCacheResponses,
UpdateWorkflowData,
UpdateWorkflowError,
UpdateWorkflowErrors,
@@ -765,6 +799,13 @@ export type {
UserResponse,
ValidationError,
ValidationResult,
VerifyApiKeyRequest,
VerifyApiKeyResponse,
VerifyWorkspaceApiKeyData,
VerifyWorkspaceApiKeyError,
VerifyWorkspaceApiKeyErrors,
VerifyWorkspaceApiKeyResponse,
VerifyWorkspaceApiKeyResponses,
ViewFileData,
ViewFileError,
ViewFileErrors,
@@ -779,6 +820,7 @@ export type {
WorkflowVersionContentResponse,
WorkflowVersionResponse,
Workspace,
WorkspaceApiKeyInfo,
WorkspaceSummary,
WorkspaceWithRole
} from './types.gen'

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,32 @@ export const zHubAssetUploadUrlRequest = z.object({
content_type: z.string()
})
/**
* Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token:
*
* * field omitted or null — leave unchanged
* * string field = "" — clear (for clearable string fields)
* * array field = [] — clear the list
* * any other value — set to the provided value
*
* Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value".
* Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint.
*
*/
export const zUpdateHubWorkflowRequest = z.object({
name: z.string().min(1).nullish(),
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
models: z.array(z.string()).nullish(),
custom_nodes: z.array(z.string()).nullish(),
tutorial_url: z.string().nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_token: z.string().nullish(),
thumbnail_comparison_token: z.string().nullish(),
sample_image_tokens_or_urls: z.array(z.string()).nullish(),
metadata: z.record(z.unknown()).nullish()
})
export const zPublishHubWorkflowRequest = z.object({
username: z.string(),
name: z.string(),
@@ -134,8 +160,43 @@ export const zHubWorkflowTemplateEntry = z.object({
thumbnailVariant: z.string().optional(),
mediaType: z.string().optional(),
mediaSubtype: z.string().optional(),
size: z.number().optional(),
vram: z.number().optional(),
size: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
vram: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
usage: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
searchRank: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
isEssential: z.boolean().optional(),
openSource: z.boolean().optional(),
profile: zHubProfileSummary.optional(),
tutorialUrl: z.string().optional(),
@@ -641,6 +702,53 @@ export const zJwksResponse = z.object({
keys: z.array(zJwkKey)
})
export const zVerifyApiKeyResponse = z.object({
user_id: z.string(),
email: z.string(),
name: z.string(),
is_admin: z.boolean(),
workspace_id: z.string(),
workspace_type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member']),
has_funds: z.boolean(),
is_active: z.boolean(),
permissions: z.array(z.string())
})
export const zVerifyApiKeyRequest = z.object({
api_key: z.string()
})
export const zWorkspaceApiKeyInfo = z.object({
id: z.string().uuid(),
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
revoked_at: z.string().datetime().optional(),
created_at: z.string().datetime()
})
export const zListWorkspaceApiKeysResponse = z.object({
api_keys: z.array(zWorkspaceApiKeyInfo)
})
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
created_at: z.string().datetime()
})
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
expires_at: z.string().datetime().optional()
})
export const zAcceptInviteResponse = z.object({
workspace_id: z.string(),
workspace_name: z.string()
@@ -979,22 +1087,6 @@ export const zAssetCreated = zAsset.and(
})
)
/**
* Response after sending an invite email
*/
export const zSendUserInviteEmailResponse = z.object({
success: z.boolean(),
message: z.string()
})
/**
* Request to send an invite email to a user
*/
export const zSendUserInviteEmailRequest = z.object({
email: z.string(),
force: z.boolean().optional().default(false)
})
export const zSetReviewStatusResponse = z.object({
share_ids: z.array(z.string()),
status: z.enum(['approved', 'rejected'])
@@ -1005,22 +1097,6 @@ export const zSetReviewStatusRequest = z.object({
status: z.enum(['approved', 'rejected'])
})
/**
* Response after successfully claiming an invite code
*/
export const zInviteCodeClaimResponse = z.object({
success: z.boolean(),
message: z.string()
})
/**
* Invite code status response
*/
export const zInviteCodeStatusResponse = z.object({
claimed: z.boolean(),
expired: z.boolean()
})
/**
* Response after deleting a session cookie
*/
@@ -1040,6 +1116,7 @@ export const zCreateSessionResponse = z.object({
* User information response
*/
export const zUserResponse = z.object({
id: z.string(),
status: z.string()
})
@@ -1194,8 +1271,16 @@ export const zQueueManageRequest = z.object({
* Queue information with pending and running jobs
*/
export const zQueueInfo = z.object({
queue_running: z.array(z.array(z.unknown())).optional(),
queue_pending: z.array(z.array(z.unknown())).optional()
queue_running: z
.array(
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
)
.optional(),
queue_pending: z
.array(
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
)
.optional()
})
/**
@@ -1315,6 +1400,10 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
export const zBindingErrorResponse = z.object({
message: z.string()
})
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
@@ -1427,6 +1516,17 @@ export const zGetFeaturesResponse = z.object({
max_upload_size: z.number().int().optional()
})
export const zGetNodeReplacementsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - Node replacement mappings
*/
export const zGetNodeReplacementsResponse = z.record(z.unknown())
export const zGetWorkflowTemplatesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1588,7 +1688,7 @@ export const zViewFileData = z.object({
})
/**
* Success - File content returned (used when channel or res parameter is present)
* Processed PNG image with extracted channel
*/
export const zViewFileResponse = z.string()
@@ -2429,6 +2529,56 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* List of API keys
*/
export const zListWorkspaceApiKeysResponse2 = zListWorkspaceApiKeysResponse
export const zCreateWorkspaceApiKeyData = z.object({
body: zCreateWorkspaceApiKeyRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* API key created (plaintext returned once)
*/
export const zCreateWorkspaceApiKeyResponse2 = zCreateWorkspaceApiKeyResponse
export const zRevokeWorkspaceApiKeyData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* API key revoked
*/
export const zRevokeWorkspaceApiKeyResponse = z.void()
export const zVerifyWorkspaceApiKeyData = z.object({
body: zVerifyApiKeyRequest,
path: z.never().optional(),
query: z
.object({
include_billing: z.boolean().optional().default(false)
})
.optional()
})
/**
* Key is valid
*/
export const zVerifyWorkspaceApiKeyResponse = zVerifyApiKeyResponse
export const zGetUserData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2440,43 +2590,6 @@ export const zGetUserData = z.object({
*/
export const zGetUserResponse = zUserResponse
export const zGetInviteCodeStatusData = z.object({
body: z.never().optional(),
path: z.object({
code: z.string()
}),
query: z.never().optional()
})
/**
* Success - invite code exists
*/
export const zGetInviteCodeStatusResponse = zInviteCodeStatusResponse
export const zClaimInviteCodeData = z.object({
body: z.never().optional(),
path: z.object({
code: z.string()
}),
query: z.never().optional()
})
/**
* Success - invite code claimed successfully
*/
export const zClaimInviteCodeResponse = zInviteCodeClaimResponse
export const zSendUserInviteEmailData = z.object({
body: zSendUserInviteEmailRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - invite email sent successfully
*/
export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse
export const zSetReviewStatusData = z.object({
body: zSetReviewStatusRequest,
path: z.never().optional(),
@@ -2488,6 +2601,19 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Updated hub workflow detail
*/
export const zUpdateHubWorkflowResponse = zHubWorkflowDetail
export const zGetDeletionRequestData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2527,6 +2653,23 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
is_active: z.boolean(),
tier: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Cache updated successfully
*/
export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zGetJobStatusData = z.object({
body: z.never().optional(),
path: z.object({
@@ -2991,6 +3134,25 @@ export const zGetExtensionsData = z.object({
query: z.never().optional()
})
/**
* URL paths (relative to web root) of available extension JS files
*/
export const zGetExtensionsResponse = z.array(z.string())
export const zGetNodeInfoSchemaData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetNodeByIdData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetVhsViewVideoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3019,12 +3181,32 @@ export const zGetVhsQueryVideoData = z.object({
})
})
export const zGetUsersRawData = z.object({
/**
* Video metadata
*/
export const zGetVhsQueryVideoResponse = z.object({
source: z.object({
size: z.tuple([z.number().int(), z.number().int()]),
fps: z.number(),
frames: z.number().int(),
duration: z.number()
})
})
export const zGetUsersInfoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Userdata storage information
*/
export const zGetUsersInfoResponse = z.object({
storage: z.string(),
migrated: z.boolean()
})
export const zGetApiViewVideoAliasData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3065,6 +3247,11 @@ export const zGetHealthData = z.object({
query: z.never().optional()
})
/**
* Service is healthy
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3134,3 +3321,19 @@ export const zGetStaticExtensionsData = z.object({
}),
query: z.never().optional()
})
export const zGetCustomNodeProxyData = z.object({
body: z.never().optional(),
path: z.object({
path: z.string()
}),
query: z.never().optional()
})
export const zPostCustomNodeProxyData = z.object({
body: z.never().optional(),
path: z.object({
path: z.string()
}),
query: z.never().optional()
})

View File

@@ -2907,7 +2907,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/generate instead. */
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/poll instead. */
post: operations["veoPoll"];
delete?: never;
options?: never;
@@ -10482,77 +10482,196 @@ export interface components {
};
VeoGenVidRequest: {
instances?: {
/** @description Text description of the video */
/** @description Text description of the video to generate */
prompt: string;
/** @description Optional image to guide video generation */
/** @description Optional first frame image to guide video generation */
image?: {
/** Format: byte */
/**
* Format: byte
* @description Base64-encoded image data
*/
bytesBase64Encoded?: string;
/** @description Cloud Storage URI of the image */
gcsUri?: string;
mimeType?: string;
/**
* @description MIME type of the image (image/jpeg or image/png)
* @enum {string}
*/
mimeType?: "image/jpeg" | "image/png";
} & (unknown | unknown);
/** @description Optional last frame image to guide video generation */
/** @description Optional last frame image. Used with image to generate video between first and last frames. Supported by Veo 3.0+ models. */
lastFrame?: {
/** Format: byte */
/**
* Format: byte
* @description Base64-encoded image data
*/
bytesBase64Encoded?: string;
/** @description Cloud Storage URI of the image */
gcsUri?: string;
/**
* @description MIME type of the image (image/jpeg or image/png)
* @enum {string}
*/
mimeType?: "image/jpeg" | "image/png";
} & (unknown | unknown);
/** @description Optional reference images to guide video generation. Supports up to 3 asset images or 1 style image. Supported by Veo 3.1 models (preview). */
referenceImages?: {
image: {
/**
* Format: byte
* @description Base64-encoded image data
*/
bytesBase64Encoded?: string;
/** @description Cloud Storage URI of the image */
gcsUri?: string;
/**
* @description MIME type of the image (image/jpeg or image/png)
* @enum {string}
*/
mimeType?: "image/jpeg" | "image/png";
} & (unknown | unknown);
/**
* @description Type of reference image
* @enum {string}
*/
referenceType: "asset" | "style";
/** @description Optional identifier for the reference image */
referenceId?: string;
}[];
/** @description Optional input video for video extension or editing. Incompatible with image and referenceImages. */
video?: {
/**
* Format: byte
* @description Base64-encoded video bytes
*/
bytesBase64Encoded?: string;
/** @description Cloud Storage URI of the input video */
gcsUri?: string;
/**
* @description MIME type of the video
* @enum {string}
*/
mimeType?: "video/mov" | "video/mpeg" | "video/mp4" | "video/mpg" | "video/avi" | "video/wmv" | "video/mpegps" | "video/x-flv";
} & (unknown | unknown);
/**
* @description Camera motion type. Requires image to be provided.
* @enum {string}
*/
cameraControl?: "fixed" | "pan_left" | "pan_right" | "tilt_up" | "tilt_down" | "truck_left" | "truck_right" | "pedestal_up" | "pedestal_down" | "push_in" | "pull_out";
/** @description Optional mask for video editing. Applies to input video. */
mask?: {
/**
* Format: byte
* @description Base64-encoded mask bytes
*/
bytesBase64Encoded?: string;
/** @description Cloud Storage URI to mask file */
gcsUri?: string;
/** @description MIME type of the mask (image/png, image/jpeg, image/webp, or video formats) */
mimeType?: string;
/**
* @description How the mask is applied
* @enum {string}
*/
maskMode?: "insert" | "remove" | "remove_static" | "outpaint";
} & (unknown | unknown);
}[];
parameters?: {
/** @example 16:9 */
aspectRatio?: string;
/**
* @description Aspect ratio of the generated video. Default: 16:9
* @example 16:9
* @enum {string}
*/
aspectRatio?: "16:9" | "9:16";
/** @description Text describing what to avoid in the generated video */
negativePrompt?: string;
/** @enum {string} */
personGeneration?: "ALLOW" | "BLOCK";
/**
* @description Controls people in generated videos. Default: allow_adult
* @enum {string}
*/
personGeneration?: "dont_allow" | "allow_adult" | "allowAll";
/** @description Number of videos to generate. If not specified, 1 video is generated. */
sampleCount?: number;
/** Format: uint32 */
/**
* Format: uint32
* @description Random seed for deterministic output. Different seeds used per video if sampleCount > 1.
*/
seed?: number;
/** @description Optional Cloud Storage URI to upload the video */
/** @description Cloud Storage URI (gs://) for saving generated videos */
storageUri?: string;
/** @description Target duration of the generated video in seconds. Veo 2: 5-8. Veo 3/3.1: 4, 6, or 8. Default: 8 */
durationSeconds?: number;
/** @description Frame rate of generated videos in frames per second */
fps?: number;
/** @description Automatically improve prompt for higher quality. Defaults to true. */
enhancePrompt?: boolean;
/** @description Generate audio for the video. Only supported by veo 3 models. */
/** @description Whether to generate audio along with the video. Defaults to true. Supported by Veo 3.0+ models. */
generateAudio?: boolean;
/**
* @description Output video resolution. Supported by Veo 3.0+ models. Default: 720p
* @enum {string}
*/
resolution?: "720p" | "1080p" | "4k";
/**
* @description Resize approach for input image. Default: pad
* @enum {string}
*/
resizeMode?: "pad" | "crop";
/**
* @description Video compression quality. Default: optimized
* @enum {string}
*/
compressionQuality?: "optimized" | "lossless";
/**
* @description Operation type for the video generation request
* @enum {string}
*/
task?: "textToVideo" | "imageToVideo" | "referenceToVideo" | "edit" | "extend" | "upscale";
/** @description Cloud Pub/Sub topic for progress updates (projects/{project}/topics/{topic}) */
pubsubTopic?: string;
};
};
/** @description Response from a Veo video generation request. Contains the operation name for polling. */
VeoGenVidResponse: {
/**
* @description Operation resource name
* @description Operation resource name used to poll for results via fetchPredictOperation
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8
*/
name: string;
};
VeoGenVidPollRequest: {
/**
* @description Full operation name (from predict response)
* @description Full operation name returned from the generate response
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID
*/
operationName: string;
};
/** @description Response from polling a Veo video generation operation */
VeoGenVidPollResponse: {
/** @description Operation resource name */
name?: string;
/** @description Whether the operation has completed */
done?: boolean;
/** @description The actual prediction response if done is true */
/** @description The prediction response, present when done is true */
response?: {
/** @example type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse */
"@type"?: string;
/** @description Count of media filtered by responsible AI policies */
/** @description Number of videos filtered by responsible AI policies */
raiMediaFilteredCount?: number;
/** @description Reasons why media was filtered by responsible AI policies */
/** @description Reasons why videos were filtered by responsible AI policies */
raiMediaFilteredReasons?: string[];
videos?: {
/** @description Cloud Storage URI of the video */
/** @description Cloud Storage URI of the generated video */
gcsUri?: string;
/** @description Base64-encoded video content */
bytesBase64Encoded?: string;
/** @description Video MIME type */
/** @description Video MIME type (video/mp4) */
mimeType?: string;
}[];
};
/** @description Error details if operation failed */
/** @description Error details, present if the operation failed */
error?: {
/** @description Error code */
/** @description gRPC error code */
code?: number;
/** @description Error message */
message?: string;
@@ -10653,8 +10772,8 @@ export interface components {
};
OpenAIImageGenerationRequest: {
/**
* @description The model to use for image generation
* @example dall-e-3
* @description The model to use for image generation (e.g., dall-e-2, dall-e-3, gpt-image-1, gpt-image-1.5, gpt-image-2)
* @example gpt-image-2
*/
model?: string;
/**
@@ -10721,8 +10840,8 @@ export interface components {
};
OpenAIImageEditRequest: {
/**
* @description The model to use for image editing
* @example gpt-image-1
* @description The model to use for image editing (e.g., dall-e-2, gpt-image-1, gpt-image-1.5, gpt-image-2)
* @example gpt-image-2
*/
model: string;
/**
@@ -15951,40 +16070,35 @@ export interface components {
QuiverTextToSVGRequest: {
/**
* @description Model identifier for SVG generation
* @default arrow-preview
* @example arrow-1.1
*/
model: string;
/** @description Text description of the desired SVG output */
prompt: string;
/** @description Additional style or formatting guidance */
instructions?: string;
/** @description Up to 4 reference images (URL or base64) */
references?: components["schemas"]["QuiverImageObject"][];
/** @description Optional reference images to guide style/composition. Accepts URL object, base64 object, or URL string shorthand. Runtime limits are model-specific. */
references?: (components["schemas"]["QuiverImageObject"] | string)[];
/**
* @description Number of SVGs to generate
* @default 1
*/
n: number;
/**
* @description Enable Server-Sent Events streaming
* @default false
*/
stream: boolean;
/**
* @description Randomness control
* @description Sampling temperature
* @default 1
*/
temperature: number;
/**
* @description Nucleus sampling parameter
* @description Nucleus sampling probability
* @default 1
*/
top_p: number;
/**
* @description Token presence penalty
* @description Penalty for tokens already present in prior output
* @default 0
*/
presence_penalty: number;
presence_penalty: number | null;
/** @description Maximum number of output tokens */
max_output_tokens?: number;
};
@@ -15992,42 +16106,37 @@ export interface components {
QuiverImageToSVGRequest: {
/**
* @description Model identifier for SVG vectorization
* @default arrow-preview
* @example arrow-1.1
*/
model: string;
image: components["schemas"]["QuiverImageObject"];
/**
* @description Automatically crop to dominant subject
* @description Auto-crop image to the dominant subject before vectorization
* @default false
*/
auto_crop: boolean;
/** @description Square resize target in pixels */
target_size?: number;
/**
* @description Number of SVGs to generate
* @default 1
*/
n: number;
/**
* @description Enable Server-Sent Events streaming
* @default false
*/
stream: boolean;
/**
* @description Randomness control
* @description Sampling temperature
* @default 1
*/
temperature: number;
/**
* @description Nucleus sampling parameter
* @description Nucleus sampling probability
* @default 1
*/
top_p: number;
/**
* @description Token presence penalty
* @description Penalty for tokens already present in prior output
* @default 0
*/
presence_penalty: number;
presence_penalty: number | null;
/** @description Maximum number of output tokens */
max_output_tokens?: number;
};
@@ -16044,24 +16153,39 @@ export interface components {
/** @description Response from Quiver AI SVG generation/vectorization */
QuiverSVGResponse: {
/** @description Unique identifier for the generation */
id?: string;
id: string;
/** @description Unix timestamp of creation */
created?: number;
data?: {
/** @description Generated SVG content */
svg?: string;
created: number;
data: {
/** @description Raw SVG markup */
svg: string;
/**
* @description MIME type of the output
* @default image/svg+xml
* @enum {string}
*/
mime_type: string;
mime_type: "image/svg+xml";
}[];
/** @description Credit cost for this request. Use this for billing instead of usage tokens. */
credits?: number;
/**
* @deprecated
* @description Deprecated. Use credits for billing values.
*/
usage?: {
/** @description Total tokens used */
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
total_tokens?: number;
/** @description Input tokens used */
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
input_tokens?: number;
/** @description Output tokens used */
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
output_tokens?: number;
};
};
@@ -27164,8 +27288,8 @@ export interface operations {
query?: never;
header?: never;
path: {
/** @description The ID of the model to use for generation */
modelId: string;
/** @description The Veo model ID to use for generation */
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
};
cookie?: never;
};
@@ -27219,8 +27343,8 @@ export interface operations {
query?: never;
header?: never;
path: {
/** @description The ID of the model to use for generation */
modelId: string;
/** @description The Veo model ID */
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
};
cookie?: never;
};

View File

@@ -7,6 +7,7 @@ import {
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
truncateFilename
} from './formatUtil'
@@ -357,4 +358,12 @@ describe('formatUtil', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})
describe('isCivitaiModelUrl', () => {
it('recognizes civitai.red model URLs', () => {
expect(
isCivitaiModelUrl('https://civitai.red/api/download/models/123456')
).toBe(true)
})
})
})

View File

@@ -361,9 +361,17 @@ export const generateUUID = (): string => {
*/
export const isCivitaiModelUrl = (url: string): boolean => {
if (!isValidUrl(url)) return false
if (!url.includes('civitai.com')) return false
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
const isCivitaiHost =
hostname === 'civitai.com' ||
hostname.endsWith('.civitai.com') ||
hostname === 'civitai.red' ||
hostname.endsWith('.civitai.red')
if (!isCivitaiHost) {
return false
}
const pathname = urlObj.pathname
return (

View File

@@ -0,0 +1,10 @@
/** Padding applied around the selection bounding rect (both screen + canvas). */
export const SELECTION_BOUNDS_PADDING = 10
/** Rectangle expressed in canvas-world coordinates. */
export interface CanvasRect {
x: number
y: number
w: number
h: number
}

View File

@@ -1,13 +1,22 @@
<template>
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
<div
ref="rootEl"
data-testid="terminal-root"
class="relative size-full overflow-hidden bg-neutral-900"
>
<div class="p-terminal size-full rounded-none p-2">
<div ref="terminalEl" class="terminal-host h-full" />
<div
ref="terminalEl"
data-testid="terminal-host"
class="terminal-host h-full"
/>
</div>
<Button
v-tooltip.left="{
value: tooltipText,
showDelay: 300
}"
data-testid="terminal-copy-button"
variant="secondary"
size="sm"
:class="

View File

@@ -1,10 +1,15 @@
<template>
<div class="size-full bg-transparent">
<p v-if="errorMessage" class="p-4 text-center">
<p
v-if="errorMessage"
data-testid="terminal-error-message"
class="p-4 text-center"
>
{{ errorMessage }}
</p>
<ProgressSpinner
v-else-if="loading"
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />

View File

@@ -0,0 +1,133 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { Bounds } from '@/renderer/core/layout/types'
import WidgetBoundingBox from './WidgetBoundingBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
}
}
})
const ScrubableNumberInputStub = defineComponent({
name: 'ScrubableNumberInput',
props: {
modelValue: { type: Number, default: 0 },
min: { type: Number, default: 0 },
step: { type: Number, default: 1 },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<input
type="number"
:value="modelValue"
:disabled="disabled"
:data-min="min"
:data-step="step"
@input="$emit('update:modelValue', Number(($event.target).value))"
/>
`
})
function renderBox(initial: Bounds, disabled = false) {
const value = ref<Bounds>(initial)
const Harness = defineComponent({
components: { WidgetBoundingBox },
setup: () => ({ value, disabled }),
template: '<WidgetBoundingBox v-model="value" :disabled="disabled" />'
})
const utils = render(Harness, {
global: {
plugins: [i18n],
stubs: { ScrubableNumberInput: ScrubableNumberInputStub }
}
})
return { ...utils, value }
}
describe('WidgetBoundingBox', () => {
describe('Label rendering', () => {
it('renders labels for x, y, width, and height', () => {
renderBox({ x: 0, y: 0, width: 100, height: 100 })
expect(screen.getByText('X')).toBeInTheDocument()
expect(screen.getByText('Y')).toBeInTheDocument()
expect(screen.getByText('Width')).toBeInTheDocument()
expect(screen.getByText('Height')).toBeInTheDocument()
})
})
describe('Initial values', () => {
it('displays the initial bounds across four inputs', () => {
renderBox({ x: 10, y: 20, width: 300, height: 400 })
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
expect(inputs.map((i) => i.value)).toEqual(['10', '20', '300', '400'])
})
})
describe('Constraints', () => {
it('sets min=0 for x/y and min=1 for width/height', () => {
renderBox({ x: 0, y: 0, width: 1, height: 1 })
const inputs = screen.getAllByRole('spinbutton')
expect(inputs[0].dataset.min).toBe('0') // x
expect(inputs[1].dataset.min).toBe('0') // y
expect(inputs[2].dataset.min).toBe('1') // width
expect(inputs[3].dataset.min).toBe('1') // height
})
})
describe('v-model updates', () => {
it('updates x immutably, preserving y/width/height', async () => {
const { value } = renderBox({ x: 10, y: 20, width: 100, height: 200 })
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
const user = userEvent.setup()
await user.clear(inputs[0])
await user.type(inputs[0], '55')
expect(value.value).toEqual({
x: 55,
y: 20,
width: 100,
height: 200
})
})
it('updates height immutably without mutating the original bounds', async () => {
const initial = { x: 10, y: 20, width: 100, height: 200 }
const { value } = renderBox(initial)
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
const user = userEvent.setup()
await user.clear(inputs[3])
await user.type(inputs[3], '500')
expect(value.value.height).toBe(500)
expect(initial).toEqual({ x: 10, y: 20, width: 100, height: 200 })
expect(value.value).not.toBe(initial)
})
})
describe('Disabled state', () => {
it('disables all four inputs when disabled=true', () => {
renderBox({ x: 0, y: 0, width: 1, height: 1 }, true)
for (const input of screen.getAllByRole('spinbutton')) {
expect(input).toBeDisabled()
}
})
it('leaves all four inputs enabled when disabled=false', () => {
renderBox({ x: 0, y: 0, width: 1, height: 1 }, false)
for (const input of screen.getAllByRole('spinbutton')) {
expect(input).not.toBeDisabled()
}
})
})
})

View File

@@ -13,6 +13,7 @@
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
@@ -52,7 +53,7 @@
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="flex flex-1 flex-col gap-3" data-testid="top-up-pay-amount">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>

View File

@@ -0,0 +1,116 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import MultiSelectWidget from './MultiSelectWidget.vue'
const MultiSelectStub = defineComponent({
name: 'MultiSelect',
inheritAttrs: false,
props: {
modelValue: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
display: { type: String, default: '' }
},
template: `<div data-testid="multiselect"
:data-options="JSON.stringify(options)"
:data-placeholder="placeholder"
:data-display="display"
:data-model-value="JSON.stringify(modelValue)" />`
})
function makeWidget(
inputSpec: Partial<ComboInputSpec>
): ComponentWidget<string[]> {
return {
name: 'multi',
inputSpec: {
type: 'COMBO',
name: 'multi',
...inputSpec
} as ComboInputSpec
} as unknown as ComponentWidget<string[]>
}
function renderWidget(
inputSpec: Partial<ComboInputSpec>,
initialValue: string[] = []
) {
const value = ref<string[]>(initialValue)
const widget = makeWidget(inputSpec)
const Harness = defineComponent({
components: { MultiSelectWidget },
setup: () => ({ value, widget }),
template: '<MultiSelectWidget v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: { plugins: [PrimeVue], stubs: { MultiSelect: MultiSelectStub } }
})
return { ...utils, value }
}
describe('MultiSelectWidget', () => {
describe('Option list', () => {
it('passes inputSpec.options through as MultiSelect options', () => {
renderWidget({ options: ['a', 'b', 'c'] })
const el = screen.getByTestId('multiselect')
expect(JSON.parse(el.dataset.options!)).toEqual(['a', 'b', 'c'])
})
it('falls back to an empty list when inputSpec.options is absent', () => {
renderWidget({})
const el = screen.getByTestId('multiselect')
expect(JSON.parse(el.dataset.options!)).toEqual([])
})
})
describe('Placeholder', () => {
it('reads placeholder from multi_select.placeholder', () => {
renderWidget({
options: ['a'],
multi_select: { placeholder: 'Pick one or more' }
})
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
'Pick one or more'
)
})
it('defaults placeholder to "Select items" when not provided', () => {
renderWidget({ options: ['a'] })
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
'Select items'
)
})
})
describe('Display mode', () => {
it('uses "chip" display when multi_select.chip is true', () => {
renderWidget({ options: ['a'], multi_select: { chip: true } })
expect(screen.getByTestId('multiselect').dataset.display).toBe('chip')
})
it('uses "comma" display when chip is false or missing', () => {
renderWidget({ options: ['a'], multi_select: { chip: false } })
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
})
it('uses "comma" display when multi_select is absent', () => {
renderWidget({ options: ['a'] })
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
})
})
describe('Value binding', () => {
it('forwards the initial selected items to MultiSelect', () => {
renderWidget({ options: ['a', 'b'] }, ['a'])
const el = screen.getByTestId('multiselect')
expect(JSON.parse(el.dataset.modelValue!)).toEqual(['a'])
})
})
})

View File

@@ -0,0 +1,206 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
const execHolder = vi.hoisted(() => ({
state: null as {
executingNodeIds: Array<string | number>
isIdle: boolean
} | null
}))
vi.mock('@/stores/executionStore', async () => {
const { reactive } = await import('vue')
execHolder.state = reactive({
executingNodeIds: [] as Array<string | number>,
isIdle: true
})
return {
useExecutionStore: () => execHolder.state
}
})
const execState = (): {
executingNodeIds: Array<string | number>
isIdle: boolean
} => execHolder.state!
import TextPreviewWidget from './TextPreviewWidget.vue'
const SkeletonStub = defineComponent({
name: 'Skeleton',
template: '<div data-testid="skeleton" />'
})
function renderPreview(
text: string,
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
) {
const value = ref(text)
const Harness = defineComponent({
components: { TextPreviewWidget },
setup: () => ({ value, nodeId }),
template: '<TextPreviewWidget v-model="value" :node-id="nodeId" />'
})
return render(Harness, {
global: {
plugins: [PrimeVue],
stubs: { Skeleton: SkeletonStub }
}
})
}
describe('TextPreviewWidget', () => {
beforeEach(() => {
execState().executingNodeIds = []
execState().isIdle = true
vi.clearAllMocks()
})
describe('Text formatting', () => {
it('renders plain text content', () => {
const { container } = renderPreview('hello world')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const span = container.querySelector('span')
expect(span?.innerHTML).toContain('hello world')
})
it('converts newlines to <br> tags', () => {
const { container } = renderPreview('line1\nline2')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const span = container.querySelector('span')
expect(span?.innerHTML).toContain('<br')
})
it('auto-links bare http URLs', () => {
const { container } = renderPreview('visit https://example.com for info')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('href')).toBe('https://example.com')
})
})
describe('Bracketed link tokens [[label|url]]', () => {
it('renders an http link with the supplied label', () => {
const { container } = renderPreview(
'see [[Docs|https://docs.example.com]]'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('href')).toBe('https://docs.example.com')
expect(anchor?.textContent).toBe('Docs')
})
it('sets target=_blank and rel=noopener for safety', () => {
const { container } = renderPreview('[[Docs|https://x.example.com]]')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor?.getAttribute('target')).toBe('_blank')
expect(anchor?.getAttribute('rel')).toContain('noopener')
})
it('renders label as plain text when url is not http(s)', () => {
const { container } = renderPreview('[[Local|javascript:alert(1)]]')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('a')).toBeNull()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('span')?.textContent).toContain('Local')
})
it('escapes HTML in the label to prevent XSS', () => {
const { container } = renderPreview(
'[[<img src=x>|https://x.example.com]]'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const span = container.querySelector('span')
expect(span?.innerHTML).toContain('&lt;img')
expect(span?.innerHTML).not.toContain('<img src')
})
})
describe('Raw HTML sanitisation in modelValue', () => {
it('drops img tags entirely (strict allowlist is <a> + <br> only)', () => {
const { container } = renderPreview('<img src=x onerror="alert(1)">')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toBeNull()
})
it('drops script tags from raw HTML in modelValue', () => {
const { container } = renderPreview(
'hello<script>window.__xss = true</script>world'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('script')).toBeNull()
})
it('drops iframe tags', () => {
const { container } = renderPreview(
'<iframe src="https://evil.example.com"></iframe>'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('iframe')).toBeNull()
})
it('strips inline javascript: hrefs on anchors', () => {
const { container } = renderPreview(
'<a href="javascript:alert(1)">click</a>'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor).not.toBeNull()
const href = anchor?.getAttribute('href')
expect(href == null || !href.startsWith('javascript:')).toBe(true)
})
it('preserves the <br> tag produced by nl2br', () => {
const { container } = renderPreview('line1\nline2')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('br')).toBeInTheDocument()
})
})
describe('Execution state', () => {
it('hides the Skeleton on mount when execution is already idle', () => {
execState().executingNodeIds = []
execState().isIdle = true
renderPreview('text', { nodeId: 'n1' })
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('shows a Skeleton on mount when the parent node is executing', () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
})
it('hides the Skeleton when execution transitions to idle', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
execState().executingNodeIds = []
execState().isIdle = true
await nextTick()
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
execState().executingNodeIds = ['other']
await nextTick()
expect(screen.queryByTestId('skeleton')).toBeNull()
})
})
})

View File

@@ -12,8 +12,9 @@
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, watch } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionStore } from '@/stores/executionStore'
@@ -25,7 +26,11 @@ const props = defineProps<{
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const isParentNodeExecuting = computed(() => {
if (executionStore.isIdle) return false
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
return executionStore.executingNodeIds.includes(parentNodeId)
})
const formattedText = computed(() => {
const src = modelValue.value
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
@@ -51,39 +56,27 @@ const formattedText = computed(() => {
: safeLabel
})
return html
// Strict allowlist: this widget only needs anchors and line breaks. Raw
// websocket progress text flows into modelValue, so we drop every other
// tag (img, script, iframe, etc.) to keep the v-html trust boundary tight.
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['a', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel']
})
})
let parentNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId
parentNodeId = props.nodeId ?? parentNodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
() => {
if (executionStore.isIdle) {
isParentNodeExecuting.value = false
stopWatching()
return
}
// Check if parent node is no longer in the executing nodes list
if (
parentNodeId &&
!executionStore.executingNodeIds.includes(parentNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
// Set parent node ID if not set yet
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
parentNodeId = executionStore.executingNodeIds[0]
}
// Lazily adopt the first executing node as the parent when no nodeId is known.
watch(
() => executionStore.executingNodeIds,
(ids) => {
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
}
)
</script>

View File

@@ -14,6 +14,7 @@
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
:data-testid="`help-menu-item-${menuItem.key}`"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@@ -103,6 +104,7 @@
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded-sm p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
:data-testid="`help-release-item-${release.version}`"
role="button"
tabindex="0"
@click="onReleaseClick(release)"

View File

@@ -4,6 +4,7 @@
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
data-testid="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
@@ -38,6 +39,7 @@
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
data-testid="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>

View File

@@ -0,0 +1,246 @@
/* eslint-disable vue/one-component-per-file */
/* eslint-disable vue/no-reserved-component-names */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { Ref } from 'vue'
const cropHolder = vi.hoisted(() => ({
state: null as Record<string, unknown> | null
}))
function createDefaultCropState() {
return {
imageUrl: ref<string | null>(null),
isLoading: ref(false),
selectedRatio: ref('1:1'),
isLockEnabled: ref(false),
cropBoxStyle: ref({}),
resizeHandles: ref([]),
handleImageLoad: () => {},
handleImageError: () => {},
handleDragStart: () => {},
handleDragMove: () => {},
handleDragEnd: () => {},
handleResizeStart: () => {},
handleResizeMove: () => {},
handleResizeEnd: () => {}
}
}
vi.mock('@/composables/useImageCrop', async () => {
return {
ASPECT_RATIOS: {
'1:1': 1,
'4:3': 4 / 3,
custom: null
},
useImageCrop: () => {
if (!cropHolder.state) {
cropHolder.state = createDefaultCropState()
}
return cropHolder.state
}
}
})
const upstreamHolder = vi.hoisted(() => ({
ref: null as Ref<unknown> | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
boundsExtractor: () => () => undefined
}
})
import WidgetImageCrop from './WidgetImageCrop.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
imageCrop: {
loading: 'Loading...',
noInputImage: 'No input image connected',
cropPreviewAlt: 'Crop preview',
ratio: 'Ratio',
lockRatio: 'Lock aspect ratio',
unlockRatio: 'Unlock aspect ratio',
custom: 'Custom'
},
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
}
}
})
const ButtonStub = defineComponent({
name: 'Button',
inheritAttrs: false,
template: '<button v-bind="$attrs" type="button"><slot /></button>'
})
const Passthrough = defineComponent({
template: '<div><slot /></div>'
})
const WidgetBoundingBoxStub = defineComponent({
name: 'WidgetBoundingBox',
props: {
modelValue: { type: Object, default: () => ({}) },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `<div data-testid="bbox-child"
:data-disabled="String(disabled)"
:data-model="JSON.stringify(modelValue)"
@click="$emit('update:modelValue', { x: 1, y: 2, width: 3, height: 4 })"
/>`
})
function primeCropState(overrides: Record<string, unknown> = {}) {
cropHolder.state = {
...createDefaultCropState(),
...overrides
}
}
function makeWidget(
overrides: Partial<SimplifiedWidget<Bounds>> = {}
): SimplifiedWidget<Bounds> {
return {
name: 'crop',
type: 'imagecrop',
value: { x: 0, y: 0, width: 512, height: 512 },
options: {},
...overrides
} as SimplifiedWidget<Bounds>
}
function renderWidget(
widget: SimplifiedWidget<Bounds> = makeWidget(),
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
) {
const value = ref<Bounds>(initialModel)
const Harness = defineComponent({
components: { WidgetImageCrop },
setup: () => ({ value, widget }),
template:
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
})
const utils = render(Harness, {
global: {
plugins: [i18n],
stubs: {
Button: ButtonStub,
Select: Passthrough,
SelectContent: Passthrough,
SelectTrigger: Passthrough,
SelectValue: Passthrough,
SelectItem: Passthrough,
WidgetBoundingBox: WidgetBoundingBoxStub
}
}
})
return { ...utils, value }
}
describe('WidgetImageCrop', () => {
beforeEach(() => {
cropHolder.state = null
upstreamHolder.ref = null
})
describe('Image states', () => {
it('shows the empty-state placeholder when imageUrl is null', () => {
primeCropState()
renderWidget()
expect(screen.getByTestId('crop-empty-state')).toBeInTheDocument()
expect(screen.getByText('No input image connected')).toBeInTheDocument()
})
it('shows the loading message when isLoading is true', () => {
primeCropState({ isLoading: ref(true), imageUrl: ref('/img.png') })
renderWidget()
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(screen.queryByTestId('crop-empty-state')).toBeNull()
})
it('renders an img when imageUrl is set and not loading', () => {
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
renderWidget()
expect(
screen.getByRole('img', { name: 'Crop preview' })
).toBeInTheDocument()
expect(screen.queryByText('Loading...')).toBeNull()
})
it('renders the crop overlay when an image is loaded', () => {
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
renderWidget()
expect(screen.getByTestId('crop-overlay')).toBeInTheDocument()
})
})
describe('Disabled state', () => {
it('hides the ratio controls when widget is disabled', () => {
renderWidget(makeWidget({ options: { disabled: true } }))
expect(screen.queryByText('Ratio')).toBeNull()
})
it('shows the ratio controls when widget is enabled', () => {
renderWidget()
expect(screen.getByText('Ratio')).toBeInTheDocument()
})
it('passes disabled=true to the bounding box child when disabled', () => {
renderWidget(makeWidget({ options: { disabled: true } }))
expect(screen.getByTestId('bbox-child').dataset.disabled).toBe('true')
})
})
describe('Bounds delegation', () => {
it('forwards v-model to the bounding box child', () => {
renderWidget(undefined, { x: 5, y: 10, width: 100, height: 200 })
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
expect(parsed).toEqual({ x: 5, y: 10, width: 100, height: 200 })
})
it('updates v-model when the bounding box emits a change', async () => {
const { value } = renderWidget()
const user = userEvent.setup()
await user.click(screen.getByTestId('bbox-child'))
expect(value.value).toEqual({ x: 1, y: 2, width: 3, height: 4 })
})
it('uses upstream bounds when disabled and upstream is available', () => {
upstreamHolder.ref = ref<unknown>({
x: 7,
y: 8,
width: 20,
height: 30
})
renderWidget(
makeWidget({
options: { disabled: true },
linkedUpstream: { nodeId: 'n1' }
}),
{ x: 0, y: 0, width: 512, height: 512 }
)
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
expect(parsed).toEqual({ x: 7, y: 8, width: 20, height: 30 })
})
})
})

View File

@@ -0,0 +1,185 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const upstreamHolder = vi.hoisted(() => ({
ref: null as { value: unknown } | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
singleValueExtractor: () => () => undefined
}
})
const outputsHolder = vi.hoisted(() => ({
nodeOutputs: {} as Record<string, unknown>
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => outputsHolder
}))
import WidgetRange from './WidgetRange.vue'
const RangeEditorStub = defineComponent({
name: 'RangeEditor',
props: {
modelValue: { type: Object, default: () => ({ min: 0, max: 1 }) },
disabled: { type: Boolean, default: false },
histogram: { type: Object, default: null },
display: { type: String, default: '' }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<div data-testid="range-editor"
:data-disabled="String(disabled)"
:data-has-histogram="String(!!histogram)"
:data-model="JSON.stringify(modelValue)"
:data-display="display"
@click="$emit('update:modelValue', { min: 5, max: 10 })"
/>
`
})
function makeWidget(
options: Partial<IWidgetRangeOptions> = {},
widgetOverrides: Partial<
SimplifiedWidget<RangeValue, IWidgetRangeOptions>
> = {}
): SimplifiedWidget<RangeValue, IWidgetRangeOptions> {
return {
name: 'range_w',
type: 'range',
value: { min: 0, max: 1 },
options: options as IWidgetRangeOptions,
...widgetOverrides
} as SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}
function setUpstream(value: RangeValue | undefined) {
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
upstreamHolder.ref.value = value
}
function renderWidget(
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>,
initialModel: RangeValue = { min: 0, max: 1 }
) {
const value = ref<RangeValue>(initialModel)
const Harness = defineComponent({
components: { WidgetRange },
setup: () => ({ value, widget }),
template: '<WidgetRange v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: { stubs: { RangeEditor: RangeEditorStub } }
})
return { ...utils, value }
}
describe('WidgetRange', () => {
beforeEach(() => {
upstreamHolder.ref = null
outputsHolder.nodeOutputs = {}
})
describe('Value pass-through', () => {
it('forwards modelValue to the RangeEditor', () => {
renderWidget(makeWidget(), { min: 0.2, max: 0.8 })
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.2, max: 0.8 })
})
it('propagates editor updates back to v-model', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('range-editor'))
expect(value.value).toEqual({ min: 5, max: 10 })
})
it('forwards the display option to the RangeEditor', () => {
renderWidget(makeWidget({ display: 'histogram' }))
expect(screen.getByTestId('range-editor').dataset.display).toBe(
'histogram'
)
})
})
describe('Disabled state', () => {
it('passes disabled=true when widget.options.disabled is set', () => {
renderWidget(makeWidget({ disabled: true }))
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('true')
})
it('passes disabled=false by default', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('false')
})
it('shows upstream value when disabled with a valid upstream', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(
makeWidget({ disabled: true } as IWidgetRangeOptions, {
linkedUpstream: { nodeId: 'n1' }
}),
{ min: 0, max: 1 }
)
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.3, max: 0.7 })
})
it('ignores upstream value when not disabled', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
min: 0,
max: 1
})
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
})
})
describe('Histogram', () => {
it('passes null histogram when nodeLocatorId is absent', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)
})
it('passes a histogram when node output has a matching histogram entry', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
})
it('treats an empty histogram array as null', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)
})
})
})

View File

@@ -50,7 +50,7 @@ vi.mock('@/stores/commandStore', () => ({
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
}
}))
}))
@@ -284,7 +284,7 @@ describe('ErrorNodeCard.vue', () => {
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
'_blank',
'noopener,noreferrer'
)

View File

@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useErrorActions } from './useErrorActions'
const mocks = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: vi.fn(),
execute: vi.fn(),
telemetry: null as {
trackUiButtonClicked: ReturnType<typeof vi.fn>
trackHelpResourceClicked: ReturnType<typeof vi.fn>
} | null,
staticUrls: {
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
}
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mocks.execute
})
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: mocks.staticUrls
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mocks.telemetry
}))
describe('useErrorActions', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mocks.telemetry = {
trackUiButtonClicked: mocks.trackUiButtonClicked,
trackHelpResourceClicked: mocks.trackHelpResourceClicked
}
mocks.trackUiButtonClicked.mockReset()
mocks.trackHelpResourceClicked.mockReset()
mocks.execute.mockReset()
windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
})
afterEach(() => {
windowOpenSpy.mockRestore()
})
describe('openGitHubIssues', () => {
it('tracks the button click and opens the GitHub issues URL in a new tab', () => {
const { openGitHubIssues } = useErrorActions()
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
'_blank',
'noopener,noreferrer'
)
})
it('still opens the link when telemetry is unavailable', () => {
mocks.telemetry = null
const { openGitHubIssues } = useErrorActions()
openGitHubIssues()
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
'_blank',
'noopener,noreferrer'
)
})
})
describe('contactSupport', () => {
it('tracks the help resource click and executes the contact support command', () => {
mocks.execute.mockReturnValue('executed')
const { contactSupport } = useErrorActions()
const result = contactSupport()
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(result).toBe('executed')
})
it('returns the execute promise when the command is async', async () => {
mocks.execute.mockResolvedValue('done')
const { contactSupport } = useErrorActions()
await expect(contactSupport()).resolves.toBe('done')
})
it('still executes the command when telemetry is unavailable', () => {
mocks.telemetry = null
const { contactSupport } = useErrorActions()
void contactSupport()
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})
describe('findOnGitHub', () => {
it('tracks the click and opens a URL-encoded issue search with " is:issue" appended', () => {
const { findOnGitHub } = useErrorActions()
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(
`${mocks.staticUrls.githubIssues}?q=${expectedQuery}`,
'_blank',
'noopener,noreferrer'
)
})
it('URL-encodes messages with special characters', () => {
const { findOnGitHub } = useErrorActions()
findOnGitHub('error with spaces & symbols?')
const [[url]] = windowOpenSpy.mock.calls as unknown as [[string]]
expect(url).toContain('?q=')
const queryPart = url.split('?q=')[1]
expect(decodeURIComponent(queryPart)).toBe(
'error with spaces & symbols? is:issue'
)
})
it('still opens the link when telemetry is unavailable', () => {
mocks.telemetry = null
const { findOnGitHub } = useErrorActions()
findOnGitHub('boom')
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
expect(windowOpenSpy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,392 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
const mocks = vi.hoisted(() => {
// Helpers only — imports happen inside factories below.
return {
getLogs: vi.fn(),
serialize: vi.fn(),
refetchSystemStats: vi.fn(),
generateErrorReport: vi.fn()
}
})
const storeState = vi.hoisted(() => {
// Plain objects wired up in beforeEach. Tests use setStoreState to swap values.
return {
systemStats: null as unknown,
isLoading: false
}
})
vi.mock('@/scripts/api', () => ({
api: {
getLogs: mocks.getLogs
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: mocks.serialize
}
}
}))
vi.mock('@/utils/errorReportUtil', () => ({
generateErrorReport: mocks.generateErrorReport
}))
vi.mock('@/stores/systemStatsStore', async () => {
const { ref: vueRef } = await import('vue')
const systemStatsRef = vueRef<unknown>(null)
const isLoadingRef = vueRef(false)
return {
useSystemStatsStore: () => ({
get systemStats() {
return systemStatsRef.value
},
set systemStats(value: unknown) {
systemStatsRef.value = value
},
get isLoading() {
return isLoadingRef.value
},
set isLoading(value: boolean) {
isLoadingRef.value = value
},
refetchSystemStats: mocks.refetchSystemStats,
__setSystemStats(value: unknown) {
systemStatsRef.value = value
},
__setIsLoading(value: boolean) {
isLoadingRef.value = value
}
})
}
})
type TestStore = ReturnType<typeof useSystemStatsStore> & {
__setSystemStats: (value: unknown) => void
__setIsLoading: (value: boolean) => void
}
async function getStore(): Promise<TestStore> {
const mod = await import('@/stores/systemStatsStore')
return mod.useSystemStatsStore() as unknown as TestStore
}
const sampleSystemStats = {
system: {
os: 'Linux',
comfyui_version: '1.0.0',
argv: [],
python_version: '3.11',
embedded_python: false,
pytorch_version: '2.3.0'
},
devices: []
}
function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: '42',
errors: [],
...overrides
}
}
describe('useErrorReport', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
mocks.getLogs.mockReset()
mocks.serialize.mockReset()
mocks.refetchSystemStats.mockReset()
mocks.generateErrorReport.mockReset()
storeState.systemStats = null
storeState.isLoading = false
const store = await getStore()
store.__setSystemStats(null)
store.__setIsLoading(false)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns early without enrichment when the card has no runtime errors', async () => {
const card = makeCard({
errors: [{ message: 'static', details: 'details' }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'details' })
})
it('enriches each runtime error with a generated report when systemStats is present', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('server logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionType }: { exceptionType: string }) =>
`report:${exceptionType}`
)
const card = makeCard({
errors: [
{
message: 'CUDA oom',
details: 'trace-0',
isRuntimeError: true,
exceptionType: 'RuntimeError'
},
{
message: 'static',
details: 'skip-me'
},
{
message: 'Other runtime error',
details: 'trace-2',
isRuntimeError: true
}
]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(2)
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(1, {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',
workflow: { nodes: [] }
})
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
exceptionType: 'Runtime Error',
exceptionMessage: 'Other runtime error',
traceback: 'trace-2'
})
)
expect(displayedDetailsMap.value).toEqual({
0: 'report:RuntimeError',
1: 'skip-me',
2: 'report:Runtime Error'
})
})
it('awaits the systemStats loading flag before proceeding', async () => {
const store = await getStore()
store.__setIsLoading(true)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockReturnValue('report')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
store.__setSystemStats(sampleSystemStats)
store.__setIsLoading(false)
await flushPromises()
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
expect(displayedDetailsMap.value).toEqual({ 0: 'report' })
})
it('calls refetchSystemStats when not loading and stats are missing', async () => {
const store = await getStore()
mocks.refetchSystemStats.mockImplementation(async () => {
store.__setSystemStats(sampleSystemStats)
})
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockReturnValue('report')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
useErrorReport(card)
await flushPromises()
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(1)
})
it('returns early and warns when refetchSystemStats throws', async () => {
mocks.refetchSystemStats.mockRejectedValue(new Error('boom'))
mocks.getLogs.mockResolvedValue('logs')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
useErrorReport(card)
await flushPromises()
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
})
it('returns early and warns when workflow serialization throws', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockImplementation(() => {
throw new Error('serialize failed')
})
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
})
it('falls back to original error.details when generateErrorReport throws', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(() => {
throw new Error('generate failed')
})
const card = makeCard({
errors: [
{ message: 'runtime', details: 'fallback', isRuntimeError: true }
]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(warnSpy).toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'fallback' })
})
it('re-enriches and clears stale enriched details when the card ref changes', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionMessage }: { exceptionMessage: string }) =>
`report:${exceptionMessage}`
)
const cardRef = ref<ErrorCardData>(
makeCard({
id: 'first',
errors: [
{ message: 'first-err', details: 'first', isRuntimeError: true }
]
})
)
const { displayedDetailsMap } = useErrorReport(cardRef)
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'report:first-err' })
cardRef.value = makeCard({
id: 'second',
errors: [{ message: 'plain', details: 'plain-details' }]
})
await nextTick()
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'plain-details' })
})
it('drops stale results when the card changes mid-flight', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionMessage }: { exceptionMessage: string }) =>
`report:${exceptionMessage}`
)
const firstLogsDeferred: {
resolve: (value: string) => void
promise: Promise<string>
} = (() => {
let resolve: (value: string) => void = () => {}
const promise = new Promise<string>((r) => {
resolve = r
})
return { resolve, promise }
})()
mocks.getLogs.mockImplementationOnce(() => firstLogsDeferred.promise)
mocks.getLogs.mockImplementationOnce(async () => 'second-logs')
const cardRef = ref<ErrorCardData>(
makeCard({
id: 'first',
errors: [
{ message: 'first-err', details: 'first', isRuntimeError: true }
]
})
)
const { displayedDetailsMap } = useErrorReport(cardRef)
await flushPromises()
cardRef.value = makeCard({
id: 'second',
errors: [
{ message: 'second-err', details: 'second', isRuntimeError: true }
]
})
await nextTick()
await flushPromises()
firstLogsDeferred.resolve('stale-logs')
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'report:second-err' })
})
})

View File

@@ -2,6 +2,7 @@
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"

View File

@@ -1,4 +1,3 @@
/* eslint-disable vue/one-component-per-file */
import { render, fireEvent } from '@testing-library/vue'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'

View File

@@ -90,7 +90,6 @@ vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
// eslint-disable-next-line vue/one-component-per-file
default: defineComponent({
name: 'CurrentUserPopoverLegacyMock',
emits: ['close'],
@@ -133,7 +132,6 @@ describe('CurrentUserButton', () => {
global: {
plugins: [i18n],
stubs: {
// eslint-disable-next-line vue/one-component-per-file
Popover: defineComponent({
setup(_, { slots, expose }) {
const shown = ref(false)

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
@@ -103,11 +103,13 @@ vi.mock('@/stores/authStore', () => ({
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: { value: true },
subscriptionTierName: { value: 'Creator' },
subscriptionTier: { value: 'CREATOR' },
isActiveSubscription: ref(true),
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
}))
}))
@@ -188,6 +190,7 @@ describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
@@ -406,14 +409,43 @@ describe('CurrentUserPopoverLegacy', () => {
})
})
describe('cloud free tier', () => {
beforeEach(() => {
mockIsCloud.value = true
mockIsFreeTier.value = true
})
it('shows upgrade-to-add-credits button and hides add-credits button', () => {
renderComponent()
expect(
screen.getByTestId('upgrade-to-add-credits-button')
).toBeInTheDocument()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
})
})
describe('non-cloud distribution', () => {
beforeEach(() => {
mockIsCloud.value = false
})
it('hides credits section', () => {
it('still shows credits balance', () => {
renderComponent()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows add-credits button and hides upgrade-to-add-credits button', () => {
renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
})
it('hides upgrade-to-add-credits button even when on free tier', () => {
mockIsFreeTier.value = true
renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
@@ -424,11 +456,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
})
it('hides partner nodes menu item', () => {
it('still shows partner nodes menu item', () => {
renderComponent()
expect(
screen.queryByTestId('partner-nodes-menu-item')
).not.toBeInTheDocument()
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
})
it('hides plans & pricing menu item', () => {
@@ -438,11 +468,9 @@ describe('CurrentUserPopoverLegacy', () => {
).not.toBeInTheDocument()
})
it('hides manage plan menu item', () => {
it('still shows manage plan menu item', () => {
renderComponent()
expect(
screen.queryByTestId('manage-plan-menu-item')
).not.toBeInTheDocument()
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
})
it('still shows user settings menu item', () => {

View File

@@ -29,11 +29,8 @@
</span>
</div>
<!-- Credits Section (cloud only) -->
<div
v-if="isCloud && isActiveSubscription"
class="flex items-center gap-2 px-4 py-2"
>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
@@ -49,7 +46,7 @@
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
<Button
v-if="isFreeTier"
v-if="isCloud && isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@@ -82,7 +79,7 @@
<Divider class="mx-0 my-2" />
<div
v-if="isCloud && isActiveSubscription"
v-if="isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
@@ -112,7 +109,7 @@
</div>
<div
v-if="isCloud && isActiveSubscription"
v-if="isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"

View File

@@ -0,0 +1,243 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDragAndDrop } from './useNodeDragAndDrop'
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
...overrides
})
}
function createFile(name: string, type = 'image/png'): File {
return new File(['data'], name, { type })
}
function createDragEvent(options: {
items?: Array<{ kind: string }>
files?: File[]
types?: string[]
uri?: string
}): DragEvent {
const { items = [], files = [], types = [], uri = '' } = options
return fromAny<DragEvent, unknown>({
dataTransfer: {
items: fromAny<DataTransferItemList, unknown>(items),
files: fromAny<FileList, unknown>(files),
types,
getData: vi.fn((format: string) =>
format === 'text/uri-list' ? uri : ''
)
}
})
}
describe('useNodeDragAndDrop', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('onDragOver detects file items by default', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const isDragging = node.onDragOver?.(
createDragEvent({ items: [{ kind: 'file' }] })
)
expect(isDragging).toBe(true)
})
it('onDragOver delegates to custom handler result', () => {
const node = createNode()
const onDragOver = vi.fn().mockReturnValue(false)
useNodeDragAndDrop(node, {
onDrop: vi.fn().mockResolvedValue([]),
onDragOver
})
const isDragging = node.onDragOver?.(
createDragEvent({ items: [{ kind: 'file' }] })
)
expect(onDragOver).toHaveBeenCalledTimes(1)
expect(isDragging).toBe(false)
})
it('onDragOver returns true for uri list drops without file items', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const isDragging = node.onDragOver?.(
createDragEvent({ items: [{ kind: 'string' }], types: ['text/uri-list'] })
)
expect(isDragging).toBe(true)
})
it('onDragOver returns false when drag event has no items', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const isDragging = node.onDragOver?.(fromAny<DragEvent, unknown>({}))
expect(isDragging).toBe(false)
})
it('onDragDrop calls onDrop with filtered files', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()
const keep = createFile('keep.png')
const skip = createFile('skip.jpg', 'image/jpeg')
useNodeDragAndDrop(node, {
onDrop,
fileFilter: (file) => file.type === 'image/png'
})
const result = await node.onDragDrop?.(
createDragEvent({ files: [keep, skip], items: [{ kind: 'file' }] })
)
expect(result).toBe(true)
expect(onDrop).toHaveBeenCalledWith([keep])
})
it('onDragDrop returns false for invalid drops', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(createDragEvent({}))
expect(result).toBe(false)
expect(onDrop).not.toHaveBeenCalled()
})
it('onDragDrop handles same-origin uri drops', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
})
)
const uri = `${location.origin}/api/file?filename=uri.png`
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(result).toBe(true)
expect(fetchSpy).toHaveBeenCalledWith(new URL(uri))
expect(onDrop).toHaveBeenCalledTimes(1)
expect(onDrop.mock.calls[0][0][0]).toBeInstanceOf(File)
expect(onDrop.mock.calls[0][0][0].name).toBe('uri.png')
})
it('onDragDrop returns false for cross-origin uri drops', async () => {
const node = createNode()
const onDrop = vi.fn().mockResolvedValue([])
const fetchSpy = vi.spyOn(globalThis, 'fetch')
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(
createDragEvent({
uri: 'https://example.com/api/file?filename=uri.png',
types: ['text/uri-list']
})
)
expect(result).toBe(false)
expect(fetchSpy).not.toHaveBeenCalled()
expect(onDrop).not.toHaveBeenCalled()
})
it('onDragDrop returns false when uri fetch throws', async () => {
const onDrop = vi.fn().mockResolvedValue([])
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'))
const uri = `${location.origin}/api/file?filename=uri.png`
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(result).toBe(false)
expect(onDrop).not.toHaveBeenCalled()
})
it('onDragDrop returns false when uri response is invalid or filtered out', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const uri = `${location.origin}/api/file?filename=uri.jpg`
const nodeA = createNode()
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
fromAny<Response, unknown>({ ok: false })
)
useNodeDragAndDrop(nodeA, { onDrop })
const badResponseResult = await nodeA.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(badResponseResult).toBe(false)
const nodeB = createNode()
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/jpeg' }))
})
)
useNodeDragAndDrop(nodeB, {
onDrop,
fileFilter: (file) => file.type === 'image/png'
})
const filteredOutResult = await nodeB.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(filteredOutResult).toBe(false)
expect(onDrop).not.toHaveBeenCalled()
})
it('onRemoved clears handlers and chains existing onRemoved', () => {
const previousOnRemoved = vi.fn()
const node = createNode({ onRemoved: previousOnRemoved })
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
expect(node.onDragOver).toBeTypeOf('function')
expect(node.onDragDrop).toBeTypeOf('function')
node.onRemoved?.call(node)
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
expect(node.onDragOver).toBeUndefined()
expect(node.onDragDrop).toBeUndefined()
})
it('onRemoved preserves handlers replaced by another extension', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const replacementDragOver = vi.fn()
const replacementDragDrop = vi.fn()
node.onDragOver = replacementDragOver
node.onDragDrop = replacementDragDrop
node.onRemoved?.call(node)
expect(node.onDragOver).toBe(replacementDragOver)
expect(node.onDragDrop).toBe(replacementDragDrop)
})
})

View File

@@ -1,3 +1,4 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type DragHandler = (e: DragEvent) => boolean
@@ -43,9 +44,10 @@ export const useNodeDragAndDrop = <T>(
return !!e?.dataTransfer?.getData('text/uri-list')
}
node.onDragOver = isDraggingFiles
const installedDragOver = isDraggingFiles
node.onDragOver = installedDragOver
node.onDragDrop = async function (e: DragEvent) {
const installedDragDrop = async function (e: DragEvent) {
if (!isDraggingValidFiles(e)) return false
const files = filterFiles(e.dataTransfer!.files)
@@ -73,4 +75,10 @@ export const useNodeDragAndDrop = <T>(
}
return true
}
node.onDragDrop = installedDragDrop
node.onRemoved = useChainCallback(node.onRemoved, () => {
if (node.onDragOver === installedDragOver) node.onDragOver = undefined
if (node.onDragDrop === installedDragDrop) node.onDragDrop = undefined
})
}

View File

@@ -0,0 +1,175 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeFileInput } from './useNodeFileInput'
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
...overrides
})
}
function createFile(name: string, type = 'image/png'): File {
return new File(['data'], name, { type })
}
function setInputFiles(input: HTMLInputElement, files: File[]) {
Object.defineProperty(input, 'files', {
configurable: true,
value: fromAny<FileList, unknown>(files)
})
}
function setInputValue(input: HTMLInputElement, value: string) {
Object.defineProperty(input, 'value', {
configurable: true,
writable: true,
value
})
}
describe('useNodeFileInput', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('creates a file input with configured attributes and defaults', () => {
const fileInput = document.createElement('input')
const createElementSpy = vi
.spyOn(document, 'createElement')
.mockReturnValue(fileInput)
const node = createNode()
useNodeFileInput(node, { onSelect: vi.fn() })
expect(createElementSpy).toHaveBeenCalledWith('input')
expect(fileInput.type).toBe('file')
expect(fileInput.accept).toBe('*')
expect(fileInput.multiple).toBe(false)
})
it('uses provided accept and allow_batch options', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const node = createNode()
useNodeFileInput(node, {
onSelect: vi.fn(),
accept: 'image/*',
allow_batch: true
})
expect(fileInput.accept).toBe('image/*')
expect(fileInput.multiple).toBe(true)
})
it('calls onSelect with filtered files and resets value on change', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const onSelect = vi.fn()
const node = createNode()
const keep = createFile('keep.png')
const skip = createFile('skip.jpg', 'image/jpeg')
useNodeFileInput(node, {
onSelect,
fileFilter: (file) => file.type === 'image/png'
})
setInputFiles(fileInput, [keep, skip])
setInputValue(fileInput, 'C:\\fakepath\\keep.png')
fileInput.onchange?.(new Event('change'))
expect(onSelect).toHaveBeenCalledWith([keep])
expect(fileInput.value).toBe('')
})
it('does not call onSelect for empty file list and still resets value', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const onSelect = vi.fn()
const node = createNode()
useNodeFileInput(node, { onSelect })
setInputFiles(fileInput, [])
setInputValue(fileInput, 'C:\\fakepath\\empty.png')
fileInput.onchange?.(new Event('change'))
expect(onSelect).not.toHaveBeenCalled()
expect(fileInput.value).toBe('')
})
it('resets value before invoking onSelect so it is cleared even on throw', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const node = createNode()
const onSelect = vi.fn(() => {
throw new Error('boom')
})
useNodeFileInput(node, { onSelect })
setInputFiles(fileInput, [createFile('test.png')])
setInputValue(fileInput, 'C:\\fakepath\\test.png')
expect(() => fileInput.onchange?.(new Event('change'))).toThrow('boom')
expect(fileInput.value).toBe('')
})
it('does not call onSelect when all files are filtered out', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const onSelect = vi.fn()
const node = createNode()
useNodeFileInput(node, {
onSelect,
fileFilter: () => false
})
setInputFiles(fileInput, [createFile('ignored.png')])
fileInput.onchange?.(new Event('change'))
expect(onSelect).not.toHaveBeenCalled()
})
it('openFileSelection clicks the generated input', () => {
const fileInput = document.createElement('input')
const clickSpy = vi.spyOn(fileInput, 'click')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const node = createNode()
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
openFileSelection()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
it('cleans up on removal, chains existing callback, and no-ops after removal', () => {
const fileInput = document.createElement('input')
const clickSpy = vi.spyOn(fileInput, 'click')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const previousOnRemoved = vi.fn()
const node = createNode({ onRemoved: previousOnRemoved })
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
expect(fileInput.onchange).toBeTypeOf('function')
node.onRemoved?.call(node)
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
expect(fileInput.onchange).toBeNull()
openFileSelection()
expect(clickSpy).not.toHaveBeenCalled()
})
})

View File

@@ -25,10 +25,12 @@ export function useNodeFileInput(node: LGraphNode, options: FileInputOptions) {
fileInput.multiple = allow_batch
fileInput.onchange = () => {
if (fileInput?.files?.length) {
const files = Array.from(fileInput.files).filter(fileFilter)
if (files.length) onSelect(files)
}
const files = fileInput?.files?.length
? Array.from(fileInput.files).filter(fileFilter)
: []
// Reset value so re-selecting the same file triggers onchange
if (fileInput) fileInput.value = ''
if (files.length) onSelect(files)
}
node.onRemoved = useChainCallback(node.onRemoved, () => {

View File

@@ -0,0 +1,107 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodePaste } from './useNodePaste'
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
...overrides
})
}
function createFile(name: string, type = 'image/png'): File {
return new File(['data'], name, { type })
}
describe('useNodePaste', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('pasteFiles calls onPaste with filtered files', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
const keep = createFile('keep.png')
const skip = createFile('skip.jpg', 'image/jpeg')
useNodePaste(node, {
onPaste,
fileFilter: (file) => file.type === 'image/png',
allow_batch: true
})
const result = node.pasteFiles?.([keep, skip])
expect(result).toBe(true)
expect(onPaste).toHaveBeenCalledWith([keep])
})
it('pasteFiles returns false when no files match filter', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
useNodePaste(node, {
onPaste,
fileFilter: () => false
})
const result = node.pasteFiles?.([createFile('ignored.png')])
expect(result).toBe(false)
expect(onPaste).not.toHaveBeenCalled()
})
it('pasteFiles limits to first file when allow_batch is false', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
const first = createFile('first.png')
const second = createFile('second.png')
useNodePaste(node, { onPaste, allow_batch: false })
const result = node.pasteFiles?.([first, second])
expect(result).toBe(true)
expect(onPaste).toHaveBeenCalledWith([first])
})
it('pasteFiles passes all files when allow_batch is true', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
const first = createFile('first.png')
const second = createFile('second.png')
useNodePaste(node, { onPaste, allow_batch: true })
const result = node.pasteFiles?.([first, second])
expect(result).toBe(true)
expect(onPaste).toHaveBeenCalledWith([first, second])
})
it('onRemoved clears pasteFiles and chains existing onRemoved', () => {
const previousOnRemoved = vi.fn()
const node = createNode({ onRemoved: previousOnRemoved })
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
expect(node.pasteFiles).toBeTypeOf('function')
node.onRemoved?.call(node)
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
expect(node.pasteFiles).toBeUndefined()
})
it('onRemoved preserves pasteFiles replaced by another extension', () => {
const node = createNode()
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
const replacementPasteFiles = vi.fn()
node.pasteFiles = replacementPasteFiles
node.onRemoved?.call(node)
expect(node.pasteFiles).toBe(replacementPasteFiles)
})
})

View File

@@ -1,3 +1,4 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type PasteHandler<T> = (files: File[]) => Promise<T>
@@ -17,7 +18,7 @@ export const useNodePaste = <T>(
) => {
const { onPaste, fileFilter = () => true, allow_batch = false } = options
node.pasteFiles = function (files: File[]) {
const installedPasteFiles = function (files: File[]) {
const filteredFiles = Array.from(files).filter(fileFilter)
if (!filteredFiles.length) return false
@@ -26,4 +27,9 @@ export const useNodePaste = <T>(
void onPaste(paste)
return true
}
node.pasteFiles = installedPasteFiles
node.onRemoved = useChainCallback(node.onRemoved, () => {
if (node.pasteFiles === installedPasteFiles) node.pasteFiles = undefined
})
}

View File

@@ -43,11 +43,9 @@ describe('useExternalLink', () => {
// Static URLs
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
expect(staticUrls.github).toBe(
'https://github.com/comfyanonymous/ComfyUI'
)
expect(staticUrls.github).toBe('https://github.com/Comfy-Org/ComfyUI')
expect(staticUrls.githubIssues).toBe(
'https://github.com/comfyanonymous/ComfyUI/issues'
'https://github.com/Comfy-Org/ComfyUI/issues'
)
expect(staticUrls.githubFrontend).toBe(
'https://github.com/Comfy-Org/ComfyUI_frontend'

View File

@@ -85,8 +85,8 @@ export function useExternalLink() {
const staticUrls = {
// Static external URLs
discord: 'https://www.comfy.org/discord',
github: 'https://github.com/comfyanonymous/ComfyUI',
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',

View File

@@ -1,5 +1,3 @@
/* eslint-disable vue/one-component-per-file */
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'

View File

@@ -321,6 +321,39 @@ describe('useLoad3d', () => {
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
// unregisters the component widget from the DOM widget store. If
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
// cleanup is lost and the interactive UI persists with a stale reference.
it('chains node.onRemoved with a preexisting callback', async () => {
const existingOnRemoved = vi.fn()
mockNode.onRemoved = existingOnRemoved
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onRemoved?.()
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
})
it('chains node.onResize with a preexisting callback', async () => {
const existingOnResize = vi.fn()
mockNode.onResize = existingOnResize
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onResize?.([512, 512] as Size)
expect(existingOnResize).toHaveBeenCalledTimes(1)
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)

View File

@@ -4,6 +4,7 @@ import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
@@ -133,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
await restoreConfigurationsFromNode(node)
node.onMouseEnter = function () {
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
load3d?.refreshViewport()
load3d?.updateStatusMouseOnNode(true)
}
})
node.onMouseLeave = function () {
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
load3d?.updateStatusMouseOnNode(false)
}
})
node.onResize = function () {
node.onResize = useChainCallback(node.onResize, () => {
load3d?.handleResize()
}
})
node.onDrawBackground = function () {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
node.onDrawBackground = useChainCallback(
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
}
}
)
node.onRemoved = function () {
node.onRemoved = useChainCallback(node.onRemoved, () => {
useLoad3dService().removeLoad3d(node)
pendingCallbacks.delete(node)
}
})
nodeToLoad3dMap.set(node, load3d)

View File

@@ -323,6 +323,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!(outputGroups?.[idx] == matchKey)) return
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -0,0 +1,699 @@
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { SceneModelManager } from './SceneModelManager'
function createMockRenderer(): THREE.WebGLRenderer {
return {
outputColorSpace: THREE.SRGBColorSpace,
dispose: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function createMockEventManager(): EventManagerInterface {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
function createManager(
overrides: {
scene?: THREE.Scene
eventManager?: EventManagerInterface
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = overrides.eventManager ?? createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const getActiveCamera = () => camera
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const manager = new SceneModelManager(
scene,
renderer,
eventManager,
getActiveCamera,
setupCamera,
setupGizmo
)
return {
manager,
scene,
renderer,
eventManager,
camera,
setupCamera,
setupGizmo
}
}
function createMeshModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
const group = new THREE.Group()
group.name = name
group.add(mesh)
return group
}
describe('SceneModelManager', () => {
describe('constructor', () => {
it('initializes default state', () => {
const { manager } = createManager()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.materialMode).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
expect(manager.appliedTexture).toBeNull()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
it('creates material instances', () => {
const { manager } = createManager()
expect(manager.normalMaterial).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.wireframeMaterial).toBeInstanceOf(THREE.MeshBasicMaterial)
expect(manager.wireframeMaterial.wireframe).toBe(true)
expect(manager.depthMaterial).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(manager.standardMaterial).toBeInstanceOf(
THREE.MeshStandardMaterial
)
})
})
describe('dispose', () => {
it('disposes all materials', () => {
const { manager } = createManager()
const normalDispose = vi.spyOn(manager.normalMaterial, 'dispose')
const standardDispose = vi.spyOn(manager.standardMaterial, 'dispose')
const wireframeDispose = vi.spyOn(manager.wireframeMaterial, 'dispose')
const depthDispose = vi.spyOn(manager.depthMaterial, 'dispose')
manager.dispose()
expect(normalDispose).toHaveBeenCalled()
expect(standardDispose).toHaveBeenCalled()
expect(wireframeDispose).toHaveBeenCalled()
expect(depthDispose).toHaveBeenCalled()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.dispose()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
})
describe('createSTLMaterial', () => {
it('returns a MeshStandardMaterial with expected properties', () => {
const { manager } = createManager()
const mat = manager.createSTLMaterial()
expect(mat).toBeInstanceOf(THREE.MeshStandardMaterial)
expect(mat.color.getHex()).toBe(0x808080)
expect(mat.metalness).toBe(0.1)
expect(mat.roughness).toBe(0.8)
expect(mat.side).toBe(THREE.DoubleSide)
})
})
describe('addModelToScene', () => {
it('adds the model to the scene and sets currentModel', () => {
const { manager, scene } = createManager()
const model = createMeshModel()
manager.addModelToScene(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
})
})
describe('setupModel', () => {
it('scales and positions the model, then adds to scene', async () => {
const { manager, scene, setupCamera } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalled()
})
it('does not skip materialMode when it differs from original', async () => {
const { manager } = createManager()
const model = createMeshModel()
// setupModel checks materialMode !== 'original' and calls
// setMaterialMode, but the guard `mode === this.materialMode`
// causes it to no-op. Then setupModelMaterials resets to 'original'.
manager.materialMode = 'wireframe'
const spy = vi.spyOn(manager, 'setMaterialMode')
await manager.setupModel(model)
// setMaterialMode is called with the stored mode and then 'original'
expect(spy).toHaveBeenCalledWith('wireframe')
expect(spy).toHaveBeenCalledWith('original')
})
it('applies current up direction if not original', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
manager.currentUpDirection = '+z'
await manager.setupModel(model)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'+z'
)
})
})
describe('setOriginalModel', () => {
it('stores the original model reference', () => {
const { manager } = createManager()
const model = new THREE.Group()
manager.setOriginalModel(model)
expect(manager.originalModel).toBe(model)
})
})
describe('clearModel', () => {
it('removes non-environment objects from scene', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const light = new THREE.DirectionalLight()
scene.add(light)
manager.clearModel()
expect(manager.currentModel).toBeNull()
expect(scene.children).toContain(light)
})
it('disposes mesh geometry and materials', async () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const geoDispose = vi.spyOn(mesh.geometry, 'dispose')
const matDispose = vi.spyOn(mesh.material as THREE.Material, 'dispose')
await manager.setupModel(model)
manager.clearModel()
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
})
describe('reset', () => {
it('resets all state to defaults', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalFileName = 'test.glb'
manager.originalURL = 'http://example.com/test.glb'
manager.originalModel = model
manager.reset()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.reset()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
it('removes and disposes skeleton helper', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mockHelper = new THREE.SkeletonHelper(model)
const helperDispose = vi.spyOn(mockHelper, 'dispose')
manager.skeletonHelper = mockHelper
scene.add(mockHelper)
manager.reset()
expect(helperDispose).toHaveBeenCalled()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
})
describe('setMaterialMode', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setMaterialMode('normal')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('does nothing when mode is unchanged', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
vi.mocked(eventManager.emitEvent).mockClear()
manager.setMaterialMode('original')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('switches to normal material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('normal')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.materialMode).toBe('normal')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'normal'
)
})
it('switches to wireframe material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('wireframe')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial)
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
it('switches to depth material', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
})
it('restores original material when switching back', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.setMaterialMode('normal')
manager.setMaterialMode('original')
expect(mesh.material).toBe(originalMat)
})
it('uses appliedTexture when no original material stored', async () => {
const { manager } = createManager()
const model = createMeshModel()
const texture = new THREE.Texture()
manager.appliedTexture = texture
manager.addModelToScene(model)
manager.materialMode = 'normal'
manager.setMaterialMode('original')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial)
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
})
it('sets renderer color space to SRGB for non-depth modes', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
manager.setMaterialMode('normal')
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
})
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalModel = new THREE.BufferGeometry()
;(manager.originalModel as THREE.BufferGeometry).setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 2, 2], 3)
)
manager.setMaterialMode('wireframe')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
})
describe('setupModelMaterials', () => {
it('stores original materials in the WeakMap', () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.currentModel = model
manager.setupModelMaterials(model)
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
})
})
describe('setUpDirection', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setUpDirection('+x')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('stores the original rotation on first call', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
expect(manager.originalRotation).not.toBeNull()
})
it('applies correct rotation for each direction', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const directions: Array<{
dir: '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
axis: 'x' | 'z'
value: number
}> = [
{ dir: '-x', axis: 'z', value: Math.PI / 2 },
{ dir: '+x', axis: 'z', value: -Math.PI / 2 },
{ dir: '-y', axis: 'x', value: Math.PI },
{ dir: '-z', axis: 'x', value: Math.PI / 2 },
{ dir: '+z', axis: 'x', value: -Math.PI / 2 }
]
for (const { dir, axis, value } of directions) {
manager.setUpDirection(dir)
expect(model.rotation[axis]).toBeCloseTo(value)
expect(manager.currentUpDirection).toBe(dir)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
dir
)
}
})
it('restores original rotation before applying new direction', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
const zAfterX = model.rotation.z
manager.setUpDirection('-z')
expect(model.rotation.x).toBeCloseTo(Math.PI / 2)
expect(model.rotation.z).not.toBeCloseTo(zAfterX)
})
it('emits upDirectionChange event', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('original')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'original'
)
})
})
describe('hasSkeleton', () => {
it('returns false when no current model', () => {
const { manager } = createManager()
expect(manager.hasSkeleton()).toBe(false)
})
it('returns false for model without skeleton', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.hasSkeleton()).toBe(false)
})
it('returns true for model with SkinnedMesh', () => {
const { manager } = createManager()
const group = new THREE.Group()
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial()
const bones = [new THREE.Bone(), new THREE.Bone()]
bones[0].add(bones[1])
const skeleton = new THREE.Skeleton(bones)
const skinnedMesh = new THREE.SkinnedMesh(geometry, material)
skinnedMesh.add(bones[0])
skinnedMesh.bind(skeleton)
group.add(skinnedMesh)
manager.currentModel = group
expect(manager.hasSkeleton()).toBe(true)
})
})
describe('setShowSkeleton', () => {
it('sets showSkeleton flag', () => {
const { manager } = createManager()
manager.setShowSkeleton(true)
expect(manager.showSkeleton).toBe(true)
})
it('emits skeletonVisibilityChange event', () => {
const { manager, eventManager } = createManager()
manager.setShowSkeleton(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'skeletonVisibilityChange',
true
)
})
it('hides existing skeleton helper when set to false', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(false)
expect(helper.visible).toBe(false)
})
it('shows existing skeleton helper when set to true', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
helper.visible = false
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(true)
expect(helper.visible).toBe(true)
})
})
describe('containsSplatMesh', () => {
it('returns false when no model', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for regular model', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for explicit null argument', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh(null)).toBe(false)
})
})
describe('PLY mode switching', () => {
function createPLYManager() {
const ctx = createManager()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
ctx.manager.standardMaterial.clone()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
ctx.scene.add(group)
ctx.manager.currentModel = group
ctx.manager.originalModel = geometry
return ctx
}
it('recreates model as point cloud', () => {
const { manager, scene, eventManager } = createPLYManager()
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
const points = mainModel!.children.find((c) => c instanceof THREE.Points)
expect(points).toBeInstanceOf(THREE.Points)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'pointCloud'
)
})
it('recreates model as wireframe mesh', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
let foundWireframe = false
mainModel!.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material instanceof THREE.MeshBasicMaterial
) {
foundWireframe = child.material.wireframe
}
})
expect(foundWireframe).toBe(true)
})
it('uses vertex colors when available', () => {
const { manager, scene } = createManager()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
new THREE.MeshBasicMaterial()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
scene.add(group)
manager.currentModel = group
manager.originalModel = geometry
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
const points = mainModel!.children.find(
(c) => c instanceof THREE.Points
) as THREE.Points
expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true)
})
it('removes old MainModel objects before adding new one', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModels = scene.children.filter((c) => c.name === 'MainModel')
expect(mainModels).toHaveLength(1)
})
})
})

View File

@@ -1,3 +1,4 @@
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
@@ -16,7 +17,7 @@ function drawSelectionBorder(
if (selectedItems.size <= 1) return
// Use the same bounds calculation as the toolbox
const bounds = createBounds(selectedItems, 10)
const bounds = createBounds(selectedItems, SELECTION_BOUNDS_PADDING)
if (!bounds) return
const [x, y, width, height] = bounds

View File

@@ -7,6 +7,7 @@ import type {
Point,
ISerialisedNode
} from '@/lib/litegraph/src/litegraph'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import {
LGraphNode,
LiteGraph,
@@ -653,4 +654,47 @@ describe('LGraphNode', () => {
)
})
})
describe('measure() collapsed branching', () => {
let out: Rect
beforeEach(() => {
out = [0, 0, 0, 0] as unknown as Rect
node.flags.collapsed = true
node.size[0] = 150
node.size[1] = 10
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
test('legacy mode uses NODE_TITLE_HEIGHT-based fallback when no ctx', () => {
LiteGraph.vueNodesMode = false
node.measure(out)
// No ctx → legacy collapsed branch falls back to NODE_COLLAPSED_WIDTH
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
})
test('Vue mode uses this.size directly for collapsed nodes', () => {
LiteGraph.vueNodesMode = true
node.measure(out)
// Vue mode collapsed takes the expanded-style branch
expect(out[2]).toBe(150)
expect(out[3]).toBe(10 + LiteGraph.NODE_TITLE_HEIGHT)
})
test('Vue mode expanded behaves identically to legacy expanded', () => {
LiteGraph.vueNodesMode = true
node.flags.collapsed = false
node.size[0] = 200
node.size[1] = 120
node.measure(out)
expect(out[2]).toBe(200)
expect(out[3]).toBe(120 + LiteGraph.NODE_TITLE_HEIGHT)
})
})
})

View File

@@ -2088,7 +2088,10 @@ export class LGraphNode
out[0] = this.pos[0]
out[1] = this.pos[1] + -titleHeight
if (!this.flags?.collapsed) {
// In Vue mode, `this.size` is kept in sync with the DOM-measured
// collapsed dimensions via ResizeObserver → layoutStore → useLayoutSync,
// so the expanded branch produces correct bounds for collapsed nodes too.
if (!this.flags?.collapsed || LiteGraph.vueNodesMode) {
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {

View File

@@ -168,6 +168,7 @@
"sortDefault": "الافتراضي",
"sortPopular": "الأكثر شعبية",
"sortRecent": "الأحدث",
"sortUnsorted": "غير مرتب",
"sortZA": "ي-أ",
"sortingType": "نوع الفرز",
"tags": "الوسوم",
@@ -584,6 +585,8 @@
"publishButton": "النشر على ComfyHub",
"publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.",
"publishFailedTitle": "فشل النشر",
"publishSuccessDescription": "تم نشر سير العمل الخاص بك على ComfyHub.",
"publishSuccessTitle": "تم النشر بنجاح",
"removeExampleImage": "إزالة الصورة النموذجية",
"selectAThumbnail": "اختر صورة مصغرة",
"shareAs": "مشاركة كـ",
@@ -1214,7 +1217,9 @@
"nothingToDelete": "لا يوجد ما يمكن حذفه",
"nothingToDuplicate": "لا يوجد ما يمكن نسخه",
"nothingToRename": "لا يوجد ما يمكن إعادة تسميته",
"off": "إيقاف",
"ok": "موافق",
"on": "تشغيل",
"openManager": "فتح المدير",
"openNewIssue": "فتح مشكلة جديدة",
"or": "أو",
@@ -1641,7 +1646,16 @@
"exportModel": "تصدير النموذج",
"exportRecording": "تصدير التسجيل",
"exportingModel": "جارٍ تصدير النموذج...",
"fitToViewer": "تكييف مع العارض",
"fov": "مجال الرؤية (FOV)",
"gizmo": {
"label": "أداة التحكم",
"reset": "إعادة ضبط التحويل",
"rotate": "تدوير",
"scale": "تغيير الحجم",
"toggle": "تبديل أداة التحكم",
"translate": "تحريك"
},
"hdri": {
"changeFile": "تغيير HDRI",
"intensity": "الشدة",
@@ -2236,6 +2250,7 @@
"Reve": "Reve",
"Rodin": "رودان",
"Runway": "رن واي",
"Sonilo": "Sonilo",
"Sora": "سورا",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2309,6 +2324,7 @@
"stable_cascade": "سلسلة ثابتة",
"string": "سلسلة نصية",
"style_model": "نموذج النمط",
"supir": "supir",
"text": "نص",
"textgen": "textgen",
"training": "تدريب",
@@ -2495,6 +2511,8 @@
"advancedInputs": "مدخلات متقدمة",
"bypass": "تجاوز",
"color": "لون العقدة",
"editSubgraph": "تعديل الرسم البياني الفرعي",
"editTitle": "تعديل العنوان",
"enterSubgraph": "دخول الرسم الفرعي",
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
"errorHelpGithub": "إرسال مشكلة على GitHub",
@@ -3445,7 +3463,9 @@
"failedToPurchaseCredits": "فشل في شراء الرصيد: {error}",
"failedToQueue": "فشل في الإضافة إلى قائمة الانتظار",
"failedToSaveDraft": "فشل في حفظ مسودة سير العمل",
"failedToSetGizmoMode": "فشل في تعيين وضع أداة التحكم",
"failedToToggleCamera": "فشل في تبديل الكاميرا",
"failedToToggleGizmo": "فشل في تبديل أداة التحكم",
"failedToToggleGrid": "فشل في تبديل الشبكة",
"failedToUpdateBackgroundColor": "فشل في تحديث لون الخلفية",
"failedToUpdateBackgroundImage": "فشل في تحديث صورة الخلفية",

View File

@@ -472,6 +472,137 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).",
"display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"first_frame": {
"name": "الإطار الأول",
"tooltip": "صورة الإطار الأول للفيديو."
},
"last_frame": {
"name": "الإطار الأخير",
"tooltip": "صورة الإطار الأخير للفيديو."
},
"model": {
"name": "النموذج",
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
},
"model_duration": {
"name": "المدة"
},
"model_generate_audio": {
"name": "توليد الصوت"
},
"model_prompt": {
"name": "الموجه"
},
"model_ratio": {
"name": "النسبة"
},
"model_resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "إنشاء أو تعديل أو تمديد فيديو باستخدام Seedance 2.0 مع صور أو فيديوهات أو صوتيات مرجعية. يدعم المراجع متعددة الوسائط، وتحرير الفيديو، وتمديد الفيديو.",
"display_name": "ByteDance Seedance 2.0 من مرجع إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"model": {
"name": "النموذج",
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
},
"model_duration": {
"name": "المدة"
},
"model_generate_audio": {
"name": "توليد الصوت"
},
"model_prompt": {
"name": "الموجه"
},
"model_ratio": {
"name": "النسبة"
},
"model_resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "إنشاء فيديو باستخدام نماذج Seedance 2.0 بناءً على موجه نصي.",
"display_name": "ByteDance Seedance 2.0 من نص إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"model": {
"name": "النموذج",
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
},
"model_duration": {
"name": "المدة"
},
"model_generate_audio": {
"name": "توليد الصوت"
},
"model_prompt": {
"name": "الموجه"
},
"model_ratio": {
"name": "النسبة"
},
"model_resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.",
"display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو",
@@ -1362,6 +1493,36 @@
}
}
},
"ColorTransfer": {
"description": "مطابقة ألوان صورة مع أخرى باستخدام خوارزميات متنوعة.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "الصورة أو الصور المرجعية لمطابقة الألوان معها. إذا لم يتم توفيرها، سيتم تخطي المعالجة."
},
"image_target": {
"name": "image_target",
"tooltip": "الصورة أو الصور التي سيتم تطبيق تحويل الألوان عليها."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: كل إطار يُطابق مع image_ref بشكل فردي. uniform: تجميع إحصائيات جميع الإطارات المصدرية كأساس، والمطابقة مع image_ref. target_frame: استخدام إطار محدد كأساس للتحويل إلى image_ref، ويُطبق بشكل موحد على جميع الإطارات (يحافظ على الفروقات النسبية)."
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "دمج الخطافات [2]",
"inputs": {
@@ -5461,6 +5622,22 @@
}
}
},
"JsonExtractString": {
"display_name": "استخراج سلسلة من JSON",
"inputs": {
"json_string": {
"name": "سلسلة_json"
},
"key": {
"name": "مفتاح"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "يستخدم النموذج المقدم، والتوجيه الإيجابي والسلبي لإزالة الضجيج من الصورة الكامنة.",
"display_name": "KSampler",
@@ -13755,6 +13932,44 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "يسحب المخرجات المنزوعة الضوضاء نحو الإدخال الكامن. قيمة أعلى = تطابق أقوى مع الإدخال. ۰ لتعطيل الميزة."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "عتبة سيغما التي دونها يتم تعطيل restore_cfg."
},
"strength_end": {
"name": "strength_end",
"tooltip": "التحكم في قوة التأثير في نهاية العينة (سيغما منخفضة). يتم الاستيفاء خطياً من البداية."
},
"strength_start": {
"name": "strength_start",
"tooltip": "التحكم في قوة التأثير في بداية العينة (سيغما عالية)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "تهيئة SV3D",
"inputs": {
@@ -14761,6 +14976,58 @@
}
}
},
"SoniloTextToMusic": {
"description": "إنشاء موسيقى من وصف نصي باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. اترك المدة ۰ ليقوم النموذج بتحديدها تلقائياً من الوصف.",
"display_name": "تحويل النص إلى موسيقى بواسطة Sonilo",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"duration": {
"name": "المدة",
"tooltip": "المدة المستهدفة بالثواني. ضع القيمة ۰ ليقوم النموذج بتحديد المدة تلقائياً من الوصف. الحد الأقصى: ٦ دقائق."
},
"prompt": {
"name": "الوصف",
"tooltip": "وصف نصي يصف الموسيقى المطلوب إنشاؤها."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "إنشاء موسيقى من محتوى الفيديو باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. يقوم بتحليل الفيديو وإنشاء موسيقى متوافقة.",
"display_name": "تحويل الفيديو إلى موسيقى بواسطة Sonilo",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"prompt": {
"name": "الوصف",
"tooltip": "وصف نصي اختياري لتوجيه إنشاء الموسيقى. اتركه فارغاً للحصول على أفضل جودة - سيقوم النموذج بتحليل محتوى الفيديو بالكامل."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني."
},
"video": {
"name": "الفيديو",
"tooltip": "فيديو الإدخال لإنشاء الموسيقى منه. الحد الأقصى للمدة: ٦ دقائق."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "يفصل الصوت إلى القناتين اليسرى واليمنى.",
"display_name": "فصل قنوات الصوت",
@@ -16025,6 +16292,10 @@
"thinking": {
"name": "التفكير",
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
},
"use_default_template": {
"name": "استخدام القالب الافتراضي",
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
}
},
"outputs": {
@@ -16076,6 +16347,10 @@
"thinking": {
"name": "التفكير",
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
},
"use_default_template": {
"name": "استخدام القالب الافتراضي",
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
}
},
"outputs": {

View File

@@ -1670,6 +1670,7 @@
"attention_experiments": "attention_experiments",
"flux": "flux",
"kandinsky5": "kandinsky5",
"postprocessing": "postprocessing",
"hooks": "hooks",
"combine": "combine",
"math": "math",
@@ -1701,7 +1702,6 @@
"HitPaw": "HitPaw",
"sd": "sd",
"Ideogram": "Ideogram",
"postprocessing": "postprocessing",
"transform": "transform",
"batch": "batch",
"upscaling": "upscaling",
@@ -1736,10 +1736,12 @@
"save": "save",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"Sonilo": "Sonilo",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model",
"supir": "supir",
"Tencent": "Tencent",
"textgen": "textgen",
"Topaz": "Topaz",
@@ -2992,6 +2994,7 @@
"sortingType": "Sorting Type",
"sortPopular": "Popular",
"sortRecent": "Recent",
"sortUnsorted": "Unsorted",
"sortZA": "Z-A",
"tags": "Tags",
"tagsHelp": "Separate tags with commas",

View File

@@ -472,6 +472,137 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video",
"description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
},
"first_frame": {
"name": "first_frame",
"tooltip": "First frame image for the video."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add a watermark to the video."
},
"last_frame": {
"name": "last_frame",
"tooltip": "Last frame image for the video."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
},
"model_generate_audio": {
"name": "generate_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"display_name": "ByteDance Seedance 2.0 Reference to Video",
"description": "Generate, edit, or extend video using Seedance 2.0 with reference images, videos, and audio. Supports multimodal reference, video editing, and video extension.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add a watermark to the video."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
},
"model_generate_audio": {
"name": "generate_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"display_name": "ByteDance Seedance 2.0 Text to Video",
"description": "Generate video using Seedance 2.0 models based on a text prompt.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add a watermark to the video."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
},
"model_generate_audio": {
"name": "generate_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"display_name": "ByteDance First-Last-Frame to Video",
"description": "Generate video using prompt and first and last frames.",
@@ -1362,6 +1493,36 @@
}
}
},
"ColorTransfer": {
"display_name": "ColorTransfer",
"description": "Match the colors of one image to another using various algorithms.",
"inputs": {
"image_target": {
"name": "image_target",
"tooltip": "Image(s) to apply the color transform to."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)"
},
"strength": {
"name": "strength"
},
"image_ref": {
"name": "image_ref",
"tooltip": "Reference image(s) to match colors to. If not provided, processing is skipped"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "Combine Hooks [2]",
"inputs": {
@@ -5461,6 +5622,22 @@
}
}
},
"JsonExtractString": {
"display_name": "Extract String from JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Kandinsky5ImageToVideo": {
"display_name": "Kandinsky5ImageToVideo",
"inputs": {
@@ -14678,6 +14855,58 @@
}
}
},
"SoniloTextToMusic": {
"display_name": "Sonilo Text to Music",
"description": "Generate music from a text prompt using Sonilo's AI model. Leave duration at 0 to let the model infer it from the prompt.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt describing the music to generate."
},
"duration": {
"name": "duration",
"tooltip": "Target duration in seconds. Set to 0 to let the model infer the duration from the prompt. Maximum: 6 minutes."
},
"seed": {
"name": "seed",
"tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"display_name": "Sonilo Video to Music",
"description": "Generate music from video content using Sonilo's AI model. Analyzes the video and creates matching music.",
"inputs": {
"video": {
"name": "video",
"tooltip": "Input video to generate music from. Maximum duration: 6 minutes."
},
"prompt": {
"name": "prompt",
"tooltip": "Optional text prompt to guide music generation. Leave empty for best quality - the model will fully analyze the video content."
},
"seed": {
"name": "seed",
"tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"display_name": "Split Audio Channels",
"description": "Separates the audio into left and right channels.",
@@ -15421,6 +15650,44 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"vae": {
"name": "vae"
},
"image": {
"name": "image"
},
"strength_start": {
"name": "strength_start",
"tooltip": "Control strength at the start of sampling (high sigma)."
},
"strength_end": {
"name": "strength_end",
"tooltip": "Control strength at the end of sampling (low sigma). Linearly interpolated from start."
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "Pulls denoised output toward the input latent. Higher = stronger fidelity to input. 0 to disable."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "Sigma threshold below which restore_cfg is disabled."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "SV3D_Conditioning",
"inputs": {
@@ -16005,6 +16272,10 @@
"name": "thinking",
"tooltip": "Operate in thinking mode if the model supports it."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Use the built in system prompt/template if the model has one."
},
"sampling_mode_min_p": {
"name": "min_p"
},
@@ -16056,6 +16327,10 @@
"name": "thinking",
"tooltip": "Operate in thinking mode if the model supports it."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Use the built in system prompt/template if the model has one."
},
"sampling_mode_min_p": {
"name": "min_p"
},

View File

@@ -168,6 +168,7 @@
"sortDefault": "Predeterminado",
"sortPopular": "Popular",
"sortRecent": "Reciente",
"sortUnsorted": "Sin ordenar",
"sortZA": "Z-A",
"sortingType": "Tipo de ordenación",
"tags": "Etiquetas",
@@ -584,6 +585,8 @@
"publishButton": "Publicar en ComfyHub",
"publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.",
"publishFailedTitle": "Error al publicar",
"publishSuccessDescription": "Tu flujo de trabajo ya está disponible en ComfyHub.",
"publishSuccessTitle": "Publicado con éxito",
"removeExampleImage": "Eliminar imagen de ejemplo",
"selectAThumbnail": "Selecciona una miniatura",
"shareAs": "Compartir como",
@@ -1214,7 +1217,9 @@
"nothingToDelete": "Nada para eliminar",
"nothingToDuplicate": "Nada para duplicar",
"nothingToRename": "Nada para renombrar",
"off": "Apagado",
"ok": "OK",
"on": "Encendido",
"openManager": "Abrir administrador",
"openNewIssue": "Abrir nuevo problema",
"or": "o",
@@ -1641,7 +1646,16 @@
"exportModel": "Exportar modelo",
"exportRecording": "Exportar grabación",
"exportingModel": "Exportando modelo...",
"fitToViewer": "Ajustar al visor",
"fov": "FOV",
"gizmo": {
"label": "Gizmo",
"reset": "Restablecer transformación",
"rotate": "Rotar",
"scale": "Escalar",
"toggle": "Gizmo",
"translate": "Trasladar"
},
"hdri": {
"changeFile": "Cambiar HDRI",
"intensity": "Intensidad",
@@ -2236,6 +2250,7 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2309,6 +2324,7 @@
"stable_cascade": "stable_cascade",
"string": "cadena",
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
"textgen": "textgen",
"training": "entrenamiento",
@@ -2495,6 +2511,8 @@
"advancedInputs": "ENTRADAS AVANZADAS",
"bypass": "Omitir",
"color": "Color del nodo",
"editSubgraph": "Editar subgrafo",
"editTitle": "Editar título",
"enterSubgraph": "Entrar en subgrafo",
"errorHelp": "Para más ayuda, {github} o {support}",
"errorHelpGithub": "envía un issue en GitHub",
@@ -3445,7 +3463,9 @@
"failedToPurchaseCredits": "No se pudo comprar créditos: {error}",
"failedToQueue": "Error al encolar",
"failedToSaveDraft": "No se pudo guardar el borrador del flujo de trabajo",
"failedToSetGizmoMode": "No se pudo establecer el modo de gizmo",
"failedToToggleCamera": "No se pudo alternar la cámara",
"failedToToggleGizmo": "No se pudo alternar el gizmo",
"failedToToggleGrid": "No se pudo alternar la cuadrícula",
"failedToUpdateBackgroundColor": "No se pudo actualizar el color de fondo",
"failedToUpdateBackgroundImage": "No se pudo actualizar la imagen de fondo",

View File

@@ -472,6 +472,137 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.",
"display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"first_frame": {
"name": "primer_fotograma",
"tooltip": "Imagen del primer fotograma para el video."
},
"last_frame": {
"name": "último_fotograma",
"tooltip": "Imagen del último fotograma para el video."
},
"model": {
"name": "modelo",
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
},
"model_duration": {
"name": "duración"
},
"model_generate_audio": {
"name": "generar_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "relación"
},
"model_resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"watermark": {
"name": "marca_de_agua",
"tooltip": "Indica si se debe añadir una marca de agua al video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "Genera, edita o extiende video usando Seedance 2.0 con imágenes, videos y audio de referencia. Soporta referencia multimodal, edición de video y extensión de video.",
"display_name": "ByteDance Seedance 2.0 Referencia a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
},
"model_duration": {
"name": "duración"
},
"model_generate_audio": {
"name": "generar_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "relación"
},
"model_resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"watermark": {
"name": "marca_de_agua",
"tooltip": "Indica si se debe añadir una marca de agua al video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "Genera video usando modelos Seedance 2.0 a partir de un prompt de texto.",
"display_name": "ByteDance Seedance 2.0 Texto a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
},
"model_duration": {
"name": "duración"
},
"model_generate_audio": {
"name": "generar_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "relación"
},
"model_resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"watermark": {
"name": "marca_de_agua",
"tooltip": "Indica si se debe añadir una marca de agua al video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "Generar video usando prompt y primer y último fotograma.",
"display_name": "ByteDance Primer-Último-Fotograma a Video",
@@ -1362,6 +1493,36 @@
}
}
},
"ColorTransfer": {
"description": "Iguala los colores de una imagen con otra utilizando varios algoritmos.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "Imagen(es) de referencia para igualar los colores. Si no se proporciona, se omite el procesamiento."
},
"image_target": {
"name": "image_target",
"tooltip": "Imagen(es) a las que se aplicará la transferencia de color."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: cada fotograma se iguala individualmente a image_ref. uniform: agrupa estadísticas de todos los fotogramas fuente como referencia, iguala a image_ref. target_frame: usa un fotograma elegido como referencia para la transformación a image_ref, aplicado uniformemente a todos los fotogramas (preserva las diferencias relativas)"
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "Combinar Hooks [2]",
"inputs": {
@@ -5461,6 +5622,22 @@
}
}
},
"JsonExtractString": {
"display_name": "Extraer cadena de JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "Utiliza el modelo proporcionado, el acondicionamiento positivo y negativo para deshacer el ruido de la imagen latente.",
"display_name": "KSampler",
@@ -13755,6 +13932,44 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "Atrae la salida denoised hacia el latent de entrada. Un valor más alto = mayor fidelidad a la entrada. 0 para desactivar."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "Umbral de sigma por debajo del cual restore_cfg se desactiva."
},
"strength_end": {
"name": "strength_end",
"tooltip": "Controla la intensidad al final del muestreo (sigma baja). Interpolado linealmente desde el inicio."
},
"strength_start": {
"name": "strength_start",
"tooltip": "Controla la intensidad al inicio del muestreo (sigma alta)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "SV3D_Acondicionamiento",
"inputs": {
@@ -14761,6 +14976,58 @@
}
}
},
"SoniloTextToMusic": {
"description": "Genera música a partir de un texto usando el modelo de IA de Sonilo. Deja la duración en 0 para que el modelo la infiera del texto.",
"display_name": "Sonilo Texto a Música",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration",
"tooltip": "Duración objetivo en segundos. Pon 0 para que el modelo infiera la duración del texto. Máximo: 6 minutos."
},
"prompt": {
"name": "prompt",
"tooltip": "Texto descriptivo de la música a generar."
},
"seed": {
"name": "seed",
"tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "Genera música a partir de contenido de video usando el modelo de IA de Sonilo. Analiza el video y crea música acorde.",
"display_name": "Sonilo Video a Música",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"prompt": {
"name": "prompt",
"tooltip": "Texto opcional para guiar la generación musical. Déjalo vacío para mejor calidad: el modelo analizará completamente el contenido del video."
},
"seed": {
"name": "seed",
"tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo."
},
"video": {
"name": "video",
"tooltip": "Video de entrada del que generar música. Duración máxima: 6 minutos."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "Separa el audio en canales izquierdo y derecho.",
"display_name": "Separar canales de audio",
@@ -16025,6 +16292,10 @@
"thinking": {
"name": "pensando",
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno."
}
},
"outputs": {
@@ -16076,6 +16347,10 @@
"thinking": {
"name": "pensando",
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno."
}
},
"outputs": {

View File

@@ -168,6 +168,7 @@
"sortDefault": "پیش‌فرض",
"sortPopular": "محبوب",
"sortRecent": "جدیدترین",
"sortUnsorted": "بدون مرتب‌سازی",
"sortZA": "ی-الف",
"sortingType": "نوع مرتب‌سازی",
"tags": "برچسب‌ها",
@@ -584,6 +585,8 @@
"publishButton": "انتشار در ComfyHub",
"publishFailedDescription": "در هنگام انتشار گردش‌کار شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"publishFailedTitle": "انتشار ناموفق بود",
"publishSuccessDescription": "گردش‌کار شما اکنون در ComfyHub فعال است.",
"publishSuccessTitle": "با موفقیت منتشر شد",
"removeExampleImage": "حذف تصویر نمونه",
"selectAThumbnail": "یک تصویر بندانگشتی انتخاب کنید",
"shareAs": "اشتراک‌گذاری به عنوان",
@@ -1214,7 +1217,9 @@
"nothingToDelete": "موردی برای حذف وجود ندارد",
"nothingToDuplicate": "موردی برای تکرار وجود ندارد",
"nothingToRename": "موردی برای تغییر نام وجود ندارد",
"off": "خاموش",
"ok": "تأیید",
"on": "روشن",
"openManager": "باز کردن مدیریت",
"openNewIssue": "ایجاد گزارش جدید",
"or": "یا",
@@ -1641,7 +1646,16 @@
"exportModel": "خروجی گرفتن مدل",
"exportRecording": "خروجی گرفتن ضبط",
"exportingModel": "در حال خروجی گرفتن مدل...",
"fitToViewer": "تنظیم بر اساس نمایشگر",
"fov": "زاویه دید (FOV)",
"gizmo": {
"label": "Gizmo",
"reset": "بازنشانی تغییرات",
"rotate": "چرخش",
"scale": "مقیاس",
"toggle": "فعال/غیرفعال کردن Gizmo",
"translate": "جابجایی"
},
"hdri": {
"changeFile": "تغییر HDRI",
"intensity": "شدت",
@@ -2236,6 +2250,7 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2309,6 +2324,7 @@
"stable_cascade": "stable cascade",
"string": "رشته",
"style_model": "مدل سبک",
"supir": "supir",
"text": "متن",
"textgen": "textgen",
"training": "آموزش",
@@ -2495,6 +2511,8 @@
"advancedInputs": "ورودی‌های پیشرفته",
"bypass": "عبور",
"color": "رنگ نود",
"editSubgraph": "ویرایش زیرگراف",
"editTitle": "ویرایش عنوان",
"enterSubgraph": "ورود به زیرگراف",
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
"errorHelpGithub": "ثبت یک issue در GitHub",
@@ -3457,7 +3475,9 @@
"failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}",
"failedToQueue": "صف‌بندی انجام نشد",
"failedToSaveDraft": "ذخیره پیش‌نویس workflow ناموفق بود",
"failedToSetGizmoMode": "تنظیم حالت Gizmo ناموفق بود",
"failedToToggleCamera": "تغییر وضعیت دوربین انجام نشد",
"failedToToggleGizmo": "فعال/غیرفعال کردن Gizmo ناموفق بود",
"failedToToggleGrid": "تغییر وضعیت شبکه انجام نشد",
"failedToUpdateBackgroundColor": "به‌روزرسانی رنگ پس‌زمینه انجام نشد",
"failedToUpdateBackgroundImage": "به‌روزرسانی تصویر پس‌زمینه انجام نشد",

View File

@@ -472,6 +472,137 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از Seedance 2.0 از تصویر اولین فریم و در صورت نیاز تصویر آخرین فریم.",
"display_name": "ByteDance Seedance 2.0 تبدیل اولین-آخرین فریم به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"first_frame": {
"name": "اولین فریم",
"tooltip": "تصویر اولین فریم برای ویدیو."
},
"last_frame": {
"name": "آخرین فریم",
"tooltip": "تصویر آخرین فریم برای ویدیو."
},
"model": {
"name": "مدل",
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت."
},
"model_duration": {
"name": "مدت زمان"
},
"model_generate_audio": {
"name": "تولید صدا"
},
"model_prompt": {
"name": "پرامپت"
},
"model_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"watermark": {
"name": "واترمارک",
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "تولید، ویرایش یا گسترش ویدیو با استفاده از Seedance 2.0 و تصاویر مرجع، ویدیوها و صدا. پشتیبانی از مرجع چندرسانه‌ای، ویرایش ویدیو و گسترش ویدیو.",
"display_name": "ByteDance Seedance 2.0 مرجع به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت."
},
"model_duration": {
"name": "مدت زمان"
},
"model_generate_audio": {
"name": "تولید صدا"
},
"model_prompt": {
"name": "پرامپت"
},
"model_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"watermark": {
"name": "واترمارک",
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "تولید ویدیو با استفاده از مدل‌های Seedance 2.0 بر اساس پرامپت متنی.",
"display_name": "ByteDance Seedance 2.0 متن به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت."
},
"model_duration": {
"name": "مدت زمان"
},
"model_generate_audio": {
"name": "تولید صدا"
},
"model_prompt": {
"name": "پرامپت"
},
"model_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"watermark": {
"name": "واترمارک",
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.",
"display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance",
@@ -1362,6 +1493,36 @@
}
}
},
"ColorTransfer": {
"description": "هماهنگ‌سازی رنگ‌های یک تصویر با تصویر دیگر با استفاده از الگوریتم‌های مختلف.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "تصویر(ها)ی مرجع برای هماهنگ‌سازی رنگ‌ها. در صورت عدم ارائه، پردازش انجام نمی‌شود."
},
"image_target": {
"name": "image_target",
"tooltip": "تصویر(ها)یی که تبدیل رنگ باید بر روی آن‌ها اعمال شود."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: هر فریم به صورت جداگانه با image_ref هماهنگ می‌شود. uniform: آمار تمام فریم‌های منبع به عنوان مبنا جمع‌آوری شده و با image_ref هماهنگ می‌شود. target_frame: یک فریم انتخابی به عنوان مبنا برای تبدیل به image_ref استفاده می‌شود و به طور یکنواخت بر همه فریم‌ها اعمال می‌گردد (تفاوت‌های نسبی حفظ می‌شوند)."
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "ترکیب هوک‌ها [۲]",
"inputs": {
@@ -5461,6 +5622,22 @@
}
}
},
"JsonExtractString": {
"display_name": "استخراج رشته از JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "با استفاده از مدل ارائه‌شده و شرط‌های مثبت و منفی، تصویر نهفته را از نویز پاک‌سازی می‌کند.",
"display_name": "KSampler",
@@ -13755,6 +13932,44 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "خروجی نویززدایی‌شده را به سمت ورودی latent می‌کشاند. مقدار بالاتر = وفاداری بیشتر به ورودی. ۰ برای غیرفعال‌سازی."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "آستانه سیگما که زیر آن restore_cfg غیرفعال می‌شود."
},
"strength_end": {
"name": "strength_end",
"tooltip": "کنترل شدت در انتهای نمونه‌گیری (سیگما پایین). به صورت خطی از مقدار ابتدایی میان‌یابی می‌شود."
},
"strength_start": {
"name": "strength_start",
"tooltip": "کنترل شدت در ابتدای نمونه‌گیری (سیگما بالا)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "شرط‌گذاری SV3D",
"inputs": {
@@ -14761,6 +14976,58 @@
}
}
},
"SoniloTextToMusic": {
"description": "تولید موسیقی از یک پرامپت متنی با استفاده از مدل هوش مصنوعی Sonilo. مدت زمان را روی ۰ قرار دهید تا مدل آن را از پرامپت تشخیص دهد.",
"display_name": "تبدیل متن به موسیقی با Sonilo",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration",
"tooltip": "مدت زمان هدف به ثانیه. برای تشخیص خودکار مدت زمان توسط مدل، مقدار را روی ۰ قرار دهید. حداکثر: ۶ دقیقه."
},
"prompt": {
"name": "prompt",
"tooltip": "پرامپت متنی برای توصیف موسیقی مورد نظر جهت تولید."
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته می‌شود اما برای سازگاری گراف حفظ شده است."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "تولید موسیقی از محتوای ویدیویی با استفاده از مدل هوش مصنوعی Sonilo. ویدیو را تحلیل کرده و موسیقی متناسب ایجاد می‌کند.",
"display_name": "تبدیل ویدیو به موسیقی با Sonilo",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"prompt": {
"name": "prompt",
"tooltip": "پرامپت متنی اختیاری برای راهنمایی تولید موسیقی. برای بهترین کیفیت خالی بگذارید - مدل به طور کامل محتوای ویدیو را تحلیل می‌کند."
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته می‌شود اما برای سازگاری گراف حفظ شده است."
},
"video": {
"name": "video",
"tooltip": "ویدیوی ورودی برای تولید موسیقی. حداکثر مدت زمان: ۶ دقیقه."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "صدا را به کانال‌های چپ و راست جدا می‌کند.",
"display_name": "تقسیم کانال‌های صوتی",
@@ -16025,6 +16292,10 @@
"thinking": {
"name": "تفکر",
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود."
}
},
"outputs": {
@@ -16076,6 +16347,10 @@
"thinking": {
"name": "تفکر",
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود."
}
},
"outputs": {

View File

@@ -168,6 +168,7 @@
"sortDefault": "Par défaut",
"sortPopular": "Populaire",
"sortRecent": "Récent",
"sortUnsorted": "Non trié",
"sortZA": "Z-A",
"sortingType": "Type de tri",
"tags": "Tags",
@@ -584,6 +585,8 @@
"publishButton": "Publier sur ComfyHub",
"publishFailedDescription": "Une erreur sest produite lors de la publication de votre workflow. Veuillez réessayer.",
"publishFailedTitle": "Échec de la publication",
"publishSuccessDescription": "Votre workflow est maintenant en ligne sur ComfyHub.",
"publishSuccessTitle": "Publication réussie",
"removeExampleImage": "Supprimer limage dexemple",
"selectAThumbnail": "Sélectionner une miniature",
"shareAs": "Partager en tant que",
@@ -1214,7 +1217,9 @@
"nothingToDelete": "Rien à supprimer",
"nothingToDuplicate": "Rien à dupliquer",
"nothingToRename": "Rien à renommer",
"off": "Désactivé",
"ok": "OK",
"on": "Activé",
"openManager": "Ouvrir le gestionnaire",
"openNewIssue": "Ouvrir un nouveau problème",
"or": "ou",
@@ -1641,7 +1646,16 @@
"exportModel": "Exportation du modèle",
"exportRecording": "Exporter l'enregistrement",
"exportingModel": "Exportation du modèle en cours...",
"fitToViewer": "Ajuster à la visionneuse",
"fov": "FOV",
"gizmo": {
"label": "Gizmo",
"reset": "Réinitialiser la transformation",
"rotate": "Pivoter",
"scale": "Échelle",
"toggle": "Gizmo",
"translate": "Déplacer"
},
"hdri": {
"changeFile": "Changer l'HDRI",
"intensity": "Intensité",
@@ -2236,6 +2250,7 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2309,6 +2324,7 @@
"stable_cascade": "stable_cascade",
"string": "chaîne",
"style_model": "modèle_de_style",
"supir": "supir",
"text": "texte",
"textgen": "textgen",
"training": "entraînement",
@@ -2495,6 +2511,8 @@
"advancedInputs": "ENTRÉES AVANCÉES",
"bypass": "Contourner",
"color": "Couleur du nœud",
"editSubgraph": "Modifier le sous-graphe",
"editTitle": "Modifier le titre",
"enterSubgraph": "Entrer dans le sous-graphe",
"errorHelp": "Pour plus d'aide, {github} ou {support}",
"errorHelpGithub": "soumettre un ticket GitHub",
@@ -3445,7 +3463,9 @@
"failedToPurchaseCredits": "Échec de l'achat de crédits : {error}",
"failedToQueue": "Échec de la mise en file d'attente",
"failedToSaveDraft": "Échec de lenregistrement du brouillon du flux de travail",
"failedToSetGizmoMode": "Échec du changement de mode du gizmo",
"failedToToggleCamera": "Échec de lactivation/désactivation de la caméra",
"failedToToggleGizmo": "Échec de lactivation du gizmo",
"failedToToggleGrid": "Échec de lactivation/désactivation de la grille",
"failedToUpdateBackgroundColor": "Échec de la mise à jour de la couleur darrière-plan",
"failedToUpdateBackgroundImage": "Échec de la mise à jour de limage darrière-plan",

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