Compare commits

...

110 Commits

Author SHA1 Message Date
Comfy Org PR Bot
036c79259b 1.44.14 (#11769)
Patch version increment to 1.44.14

**Base branch:** `main`

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

---------

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

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

## Changes

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

## Why Embedded Form

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

## Review Focus

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

## Local Checks

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

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

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

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

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

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

---------

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

---

## Summary

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

## Root cause

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

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

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

## Verification

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

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

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

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

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

## Changes

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

## Review Focus

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

## Validation

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

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

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

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

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

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

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

---------

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

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

## Changes

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

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

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


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





<!-- CURSOR_SUMMARY -->
---

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





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

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)



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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11636-fix-enable-Chrome-password-autofill-on-signup-form-34e6d73d36508180882cc9ebafb58417)
by [Unito](https://www.unito.io)
2026-04-30 00:11:20 +00:00
Benjamin Lu
9e16390c33 test: assert core command help urls (#11768)
## Summary

- Tighten the new `useCoreCommands` help command tests to assert the
exact external URL opened for GitHub issues and Discord.

## Testing

```bash
pnpm test:unit -- src/composables/useCoreCommands.test.ts
pnpm format:check src/composables/useCoreCommands.test.ts
```

Also passed pre-commit `pnpm typecheck` and push hook `pnpm knip`.

Stacked on #11748.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11768-test-assert-core-command-help-urls-3516d73d365081de99d0c71f707d0fb4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante01yoon <bunggl@naver.com>
2026-04-29 21:52:19 +00:00
Terry Jia
c88275b2a4 test(load3d): add unit tests for SceneManager (#11762)
## Summary

Add 35 unit tests for `SceneManager`, the largest remaining gap in the
load3d core (452 LOC). Targets only the logic-bearing methods
(background mode dispatch, render-mode switching, aspect-ratio scaling,
capture pipeline, dispose). Renderer-passthrough internals are
intentionally left to E2E. Follow-up to Tier 1 (#11733), Tier 2, Tier
3a, and Tier 3b.

## Changes

- **What**: 35 new tests covering construction (main scene + background
scene + grid + tiled mesh + default color mode), `toggleGrid`,
`setBackgroundColor` (color update, scene-bg cleanup, panorama-demote,
prior-texture dispose), `setBackgroundImage` (empty-path fallback,
loading-start emit, temp/output subfolder rewrite, /api prefix,
tiled-mesh material swap, panorama scene-background promotion,
prior-texture dispose, error-path fallback), `removeBackgroundImage`,
`setBackgroundRenderMode` (no-op same-mode, color-only emit, image
panorama-promote, image tiled-demote), `updateBackgroundSize`
(no-texture/no-mesh/no-map guards, wide vs. tall image scaling),
`handleResize` (image-bg active vs. color-only),
`getCurrentBackgroundInfo`, `captureScene` (returns 3 data URLs +
restores renderer state, restores grid visibility, propagates errors),
and `dispose` (resource cleanup + scene-background null).

## Review Focus

- **Coverage**: `SceneManager.ts` 89.5% lines / 74.2% branches / 89.5%
funcs. Uncovered lines are concentrated in `renderBackground` and the
deep mesh-traversal loop inside `captureScene` — exactly the
renderer-passthrough territory deferred per the Tier 3c plan.
- **`THREE.Material.needsUpdate` is a write-only setter** in THREE.js —
reading returns `undefined`. The "demote panorama → tiled" test asserts
`mat.map === texture` instead of `mat.needsUpdate === true`, with a
comment explaining why.
- **happy-dom canvas `clientWidth`/`clientHeight` default to 0** —
`makeRenderer()` overrides them via `Object.defineProperty` so
production code reading `renderer.domElement.clientWidth` gets the test
value.
- **`THREE.TextureLoader` is mocked via `vi.mock('three', ...)` with
`importOriginal`**, matching the pattern in `RecordingManager.test.ts`
and `HDRIManager.test.ts`. `mockTextureLoad` is hoisted so each test can
resolve/reject the load callback independently.
- **`vi.spyOn(manager, 'setBackgroundColor')` in three places** to
assert internal delegation (empty-path fallback, error fallback,
`removeBackgroundImage`). Defensible because the delegation IS the
documented contract for these methods.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11762-test-load3d-add-unit-tests-for-SceneManager-3516d73d365081628ff4c146defebac1)
by [Unito](https://www.unito.io)
2026-04-29 17:54:21 -04:00
pythongosssss
23e48b2140 feat: Node search - Improve category tree on mobile with collapse (#11687)
## Summary

Improves the search experience on mobile by collapsing the category menu
& reogranises the filer buttons

## Changes

- **What**: 
- add toggle button to collapse category selection
- auto collapse on mobile
- floating panel on mobile
- re-order filter buttons
- tests

## Screenshots (if applicable)
Closed:
<img width="415" height="373" alt="image"
src="https://github.com/user-attachments/assets/c99cd6cd-eb92-4ce3-9844-591dd1e80769"
/>

Desktop open:
<img width="455" height="328" alt="image"
src="https://github.com/user-attachments/assets/df15bdda-f77a-4c12-90e1-8608d67c55b4"
/>

Mobile open:
<img width="427" height="600" alt="image"
src="https://github.com/user-attachments/assets/a2b115ad-bce0-4ed1-9d30-126a35263259"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11687-feat-Node-search-Improve-category-tree-on-mobile-with-collapse-34f6d73d365081729075e8b0071a3bc1)
by [Unito](https://www.unito.io)
2026-04-29 20:52:58 +00:00
Terry Jia
af43619ae1 test(load3d): add unit tests for copyLoad3dState in load3dService (#11761)
## Summary

Add 19 unit tests for `Load3dService.copyLoad3dState` (the one method
intentionally deferred from Tier 1). Brings `load3dService.ts` from
54.5% to 100% line coverage. Follow-up to Tier 1 (#11733), Tier 2, and
Tier 3a.

## Changes

- **What**: 19 new tests covering every branch of `copyLoad3dState`:
no-source-model fast path, splat fast path (with and without
`originalURL`), mesh path (existing-target-model removal, SkeletonUtils
clone, originalModel/material/upDirection/texture copy, initial
transform on clone, gizmo transform application, gizmo enable/disable
across both source and target prior states, animation copy when
present/absent), background-image vs. background-color dispatch,
light-intensity falsy fallback, perspective-vs-orthographic FOV gating,
and the always-detach + setupForModel gizmo contract.

## Review Focus

- **Coverage**: `load3dService.ts` lines 54.5% → **100%**, branches 50%
→ **90.9%**, funcs 88.9% → **100%**. Remaining uncovered lines are minor
(`loadSkeletonUtils` cache-hit path, a couple of null-map early
returns).
- **Test fixtures use real `THREE.Object3D` and `THREE.Scene`** so
production code's `.position.set(...)`, `.rotation.set(...)`,
`scene.add/remove` calls work without further stubbing.
- **`makeTarget` memoizes the gizmo manager** (`getGizmoManager: () =>
gizmoManager` rather than returning a fresh literal each call).
Production code calls `getGizmoManager()` multiple times; without
memoization, the `detach` and `setupForModel` mocks would be
unobservable from tests.
- **`state` return on `makeTarget`** exposes mutable `modelManager`,
captured `gizmoManager`/`animationManager`, and
`sceneAdded`/`sceneRemoved` arrays so tests can assert post-state
directly without casts through the production-typed `Load3d` interface.
- **Background-image test uses `createMockLGraphNode({ id, properties
})` overrides** rather than mid-test property mutation.
- **Destructuring-default gotcha**: `const { lightsIntensity = 0.8 } =
overrides` applies the default even when `undefined` is passed
explicitly. The "fallback to setLightIntensity(1)" test passes `0`
instead — production code's `intensity || 1` short-circuits the same
way.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11761-test-load3d-add-unit-tests-for-copyLoad3dState-in-load3dService-3516d73d36508142bc72d97b27b0a36b)
by [Unito](https://www.unito.io)
2026-04-29 16:51:26 -04:00
Terry Jia
d2e88011aa test(load3d): add unit tests for EventManager, ViewHelperManager, and load3dLazy (#11760)
## Summary

Add unit tests for the three remaining small/wrapper modules in the
load3d domain (`EventManager` pub/sub, `ViewHelperManager` ViewHelper
wrapper, and `load3dLazy` lazy-extension loader). Follow-up to Tier 1
(#11733) and Tier 2.

## Changes

- **What**: 28 new unit tests across 3 files covering EventManager
add/remove/emit semantics, ViewHelperManager container DOM creation +
pointer-event interception + animation-finished camera-state emission +
perspective/orthographic zoom snapshotting + dispose ordering, and
load3dLazy extension registration + 3D-node-type recognition +
Load3D-specific `mesh_upload` injection + `beforeRegisterNodeDef` hook
replay for newly registered extensions.

## Review Focus

- **Coverage** (lines/branches/funcs): EventManager 100% / 100% / 100%,
ViewHelperManager 100% / 83.3% / 72.7%, load3dLazy 95.8% / 80% / 100%.
Aggregate: **98.6% / 85.3% / 85.7%**.
- **`vi.mock` factory side-effects only fire once per test file** —
`load3dLazy.test.ts` originally tried to count dynamic imports of
`./load3d` and `./saveMesh` via spies inside the mock factory, but
factories aren't re-invoked across `vi.resetModules()`. Switched to
verifying observable side effects (`enabledExtensions` getter call
counts, `beforeRegisterNodeDef` replay invocations).
- **Snapshot-vs-diff `enabledExtensions` queue** in
`load3dLazy.test.ts`: production code does `before = new
Set(enabledExtensions); await imports; diff =
enabledExtensions.filter(!before.has)`. To exercise the replay branch,
the mock returns `[]` first (for `before`) and `[newExtension]` second
(for the post-import snapshot) via `mockReturnValueOnce` queueing.
- **`MockViewHelper` is defined inside the `vi.mock()` factory** rather
than `vi.hoisted()`, matching the `GizmoManager.test.ts:15-41`
convention (hoisted handles only, classes inside the factory).
- **PointerEvent propagation tests** require the production code's
`event.stopPropagation()` to actually keep events from bubbling to the
parent in happy-dom; the parent listener gets attached and
asserted-not-called.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11760-test-load3d-add-unit-tests-for-EventManager-ViewHelperManager-and-load3dLazy-3516d73d3650814bb2dac3360ab0d2a1)
by [Unito](https://www.unito.io)
2026-04-29 16:50:48 -04:00
Terry Jia
180a0001e8 test(load3d): add unit tests for LightingManager, ControlsManager, exportMenuHelper, and ModelExporter (#11758)
## Summary

Add unit tests for the four Tier 2 untested logic modules in the load3d
domain (`LightingManager`, `ControlsManager`, `exportMenuHelper`, and
`ModelExporter`). Follow-up to the Tier 1 PR (#11733).

## Changes

- **What**: 43 new unit tests across 4 files covering light
setup/intensity scaling/HDRI mode/disposal, OrbitControls construction
(including DOM-parent fallback) and camera-state event emission, the
export submenu builder (item structure, submenu opening, format
dispatch, success/error toasts), and the static `ModelExporter` (URL
parsing, direct-URL fast paths for matching extensions, GLB/OBJ/STL
serialization branches, error toast paths).

## Review Focus

- **Coverage** (lines/branches/funcs): LightingManager 100% / 50% /
90.9%, ControlsManager 100% / 100% / 87.5%, exportMenuHelper 100% / 100%
/ 100%, ModelExporter 98.4% / 95.7% / 100%. Aggregate: **99.2% / 93.5% /
95.1%**.
- **`vi.mock(import('@/lib/litegraph/src/litegraph'), ...)` in
`exportMenuHelper.test.ts`** uses the dynamic-import form so
`importOriginal()` is auto-typed. Required because `apiSchema.ts`
transitively imports `LinkMarkerShape` from the same module — replacing
the whole module breaks the build. The mock replaces only
`LiteGraph.ContextMenu` in-place on the real singleton.
- **`MockContextMenu` must be a class**, not an arrow function —
production code does `new LiteGraph.ContextMenu(...)`. Initial
arrow-function mock failed with "is not a constructor".
- **Fake-timer rejection pattern in `ModelExporter.test.ts`**: rejection
assertions are attached *before* `vi.runAllTimersAsync()` (`const
assertion = expect(p).rejects.toThrow(...); await drain; await
assertion`) to avoid unhandled-rejection warnings.
- **Surprising `detectFormatFromURL` behavior**:
`detectFormatFromURL('?filename=cube')` returns `'cube'`, not `null`,
because `'cube'.split('.').pop()` returns the whole basename when no dot
is present. Test documents this rather than asserting an incorrect
expectation.
- **Two unreachable lines left uncovered**: `LightingManager:65` (`?? 1`
fallback in the `setLightIntensity` multiplier lookup — every light is
registered in the map at construction, so the fallback is dead) and
`ModelExporter:21` (a `try/catch` around `URLSearchParams` whose
constructor cannot throw on the inputs the production code passes).

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11758-test-load3d-add-unit-tests-for-LightingManager-ControlsManager-exportMenuHelper-and-3516d73d365081cb96fff33672503822)
by [Unito](https://www.unito.io)
2026-04-29 16:50:06 -04:00
Dante
8f011225bf test: add unit tests for useCoreCommands canvas/help commands (#11748)
## Summary

Adds 8 tests across three new describe blocks for
\`src/composables/useCoreCommands.ts\`:

- **Canvas view**: \`Comfy.Canvas.ResetView\`, \`Comfy.Canvas.ZoomIn\`,
\`Comfy.Canvas.ZoomOut\`.
- **Workflow lifecycle**: \`Comfy.OpenClipspace\`,
\`Comfy.RefreshNodeDefinitions\`.
- **Help**: \`Comfy.Help.OpenComfyUIIssues\`,
\`Comfy.Help.OpenComfyOrgDiscord\`, \`Comfy.Help.AboutComfyUI\`.

Adds \`vi.hoisted\` mocks for \`useTelemetry\`, \`useSettingsDialog\`,
and \`useLitegraphService.resetView\` so they remain isolated from the
existing 15-test suite.

## Why this slice

\`useCoreCommands.ts\` exports 118 distinct command callbacks (1356
LOC). A single coverage-backfill PR for the whole file would be unwieldy
and risk merge conflicts (this file is touched frequently). This PR
covers a coherent slice — view/lifecycle/help commands — and follow-up
PRs can pick off remaining clusters.

## Testing

\`\`\`bash
pnpm vitest run src/composables/useCoreCommands.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11748-test-add-unit-tests-for-useCoreCommands-canvas-help-commands-3516d73d365081c384ffcc72c15dfd47)
by [Unito](https://www.unito.io)
2026-04-29 13:23:07 -07:00
pythongosssss
3c50487c18 test: add test for MaxHistoryItems setting (#11750)
## Summary

Adds test to ensure MaxHistoryItems limits both API query & client
rendering

## Changes

- **What**: 
- ensure limit is added to url
- ensure virtual grid is capped to limit

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11750-test-add-test-for-MaxHistoryItems-setting-3516d73d365081daa973ccfd8a7f479e)
by [Unito](https://www.unito.io)
2026-04-29 19:10:31 +00:00
pythongosssss
818e549e8e fix: hide blueprint node id in search (#11759)
There is a setting that enables Node IDs to display on the search
results.
Subgraphs have long non-user friendly IDs which cause this to render
badly for the built in blueprints (and user published). This update
hides the IDs for blueprint nodes.

## Changes

- **What**: 
- hide if blueprint
- add test

## Screenshots (if applicable)

Before:
<img width="910" height="504" alt="image"
src="https://github.com/user-attachments/assets/9eea9fd7-8f72-4e1b-9522-46efba0ef71a"
/>

After:
<img width="797" height="552" alt="image"
src="https://github.com/user-attachments/assets/43d6fc62-4102-41c3-b9bb-a3efd244580d"
/>

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11759-fix-hide-blueprint-node-id-in-search-3516d73d365081baa055d12c6a31fadd)
by [Unito](https://www.unito.io)
2026-04-29 17:08:54 +00:00
AustinMroz
fd1a8e9432 Fix legacy widget width in app mode (#11574)
When in app mode, widgets can be drawn with size different from the size
of the parent node. Mouse events on legacy canvas widgets require that
the client code query the current state of the node and widget to
determine if any elements are being interacted with. This PR sets the
`widget.width` property when a legacy canvas widget draw operation
occurs so that custom nodes can properly resolve subsequent mouse
events.

At current, no core nodes exist that utilize legacy widgets. As a result
the setup code to test this bug fix is slightly involved.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11574-Fix-legacy-widget-width-in-app-mode-34b6d73d365081caaa34c6204f8361f6)
by [Unito](https://www.unito.io)
2026-04-29 09:31:57 -07:00
Dante
8f61ecd82e test: add unit tests for executionStore WebSocket handlers (#11746)
## Summary

Adds 22 new tests for \`src/stores/executionStore.ts\`, raising coverage
from **48.7% → 82.9%** lines (functions 47% → 72.5%). Tests drive each
WebSocket handler by capturing handlers registered through the mocked
\`api.addEventListener\` and dispatching CustomEvents at them.

## Test Coverage

WebSocket handlers (driven through \`bindExecutionEvents\`):
- \`execution_start\` — sets activeJobId, seeds queued job entry, clears
initializing state for the starting job.
- \`execution_cached\` — marks listed nodes; no-op when no active job.
- \`execution_interrupted\` — clears active job state.
- \`executed\` — marks executed node; no-op when no active job.
- \`execution_success\` — clears active job and progress state.
- \`executing\` — clears \`_executingNodeProgress\` and activeJobId on
null detail.
- \`progress\` — sets \`_executingNodeProgress\`.
- \`status\` — reads clientId once and stops listening.
- \`execution_error\` — service-level (no node_id) routes to
\`lastPromptError\`; runtime errors route to \`lastExecutionError\`.
- \`notification\` — marks job as initializing on "Waiting for a
machine"; ignores empty id and unrelated text.

Other:
- \`unbindExecutionEvents\` removes every listener registered.
- \`storeJob\` populates queuedJobs, jobIdToWorkflowId,
jobIdToSessionWorkflowPath.
- \`registerJobWorkflowIdMapping\` ignores empty inputs.
- \`ensureSessionWorkflowPath\` is idempotent and updates on change.

## Testing

\`\`\`bash
pnpm vitest run src/stores/executionStore.test.ts
pnpm vitest run src/stores/executionStore.test.ts --coverage
--coverage.include='src/stores/executionStore.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11746-test-add-unit-tests-for-executionStore-WebSocket-handlers-3516d73d3650810aa910f5a022fdc17b)
by [Unito](https://www.unito.io)
2026-04-29 08:29:59 -04:00
Dante
8fe0385a57 test: add unit tests for pnginfo wrappers and getLatentMetadata (#11745)
## Summary

Extends \`src/scripts/pnginfo.test.ts\` with 5 new tests covering the
format-specific delegating wrappers and the safetensors metadata reader.
Lifts pnginfo.ts coverage from **17.6% → 23.2%** lines (the remaining
gap is \`importA1111\`, which needs a refactor before it can be tested
cleanly — left to a follow-up).

## Test Coverage

- \`getPngMetadata\`, \`getFlacMetadata\`, \`getAvifMetadata\` delegate
to their respective \`metadata/*\` modules (mocked).
- \`getLatentMetadata\` returns the \`__metadata__\` object from a
hand-built safetensors header.
- \`getLatentMetadata\` resolves \`undefined\` when the header has no
\`__metadata__\` entry.

## Out of scope

\`importA1111\` (lines 176-542) is a 270-line A1111-prompt →
ComfyUI-graph builder. Testing it requires either heavy LiteGraph mocks
or a refactor that extracts pure parsing helpers from the graph-mutation
code. Tracking separately.

## Testing

\`\`\`bash
pnpm vitest run src/scripts/pnginfo.test.ts
pnpm vitest run src/scripts/pnginfo.test.ts --coverage
--coverage.include='src/scripts/pnginfo.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11745-test-add-unit-tests-for-pnginfo-wrappers-and-getLatentMetadata-3516d73d365081c080a6c8146aa1bee8)
by [Unito](https://www.unito.io)
2026-04-29 08:25:26 -04:00
Dante
d078af3a79 test: add unit tests for avif metadata parser (#11744)
## Summary

Adds 12 tests for `src/scripts/metadata/avif.ts`, raising line coverage
from **2.3% → 90.4%** (statements 88.5%, functions 93.3%).

## Test Coverage

Happy paths:
- Workflow JSON extracted from EXIF Exif item (LE)
- Prompt JSON extracted
- Big-endian (MM) EXIF parsing
- Both prompt and workflow present in separate EXIF entries

Negative paths (each yields `{}` without throwing):
- AVIF major brand is not "avif"
- Meta box missing
- iinf has no Exif item
- EXIF entry uses an unrecognized key
- EXIF entry has malformed JSON
- infe version is unsupported (1)
- iloc box missing while iinf has Exif
- Buffer too short for valid header

## Testing

\`\`\`bash
pnpm vitest run src/scripts/metadata/avif.test.ts
pnpm vitest run src/scripts/metadata/avif.test.ts --coverage
--coverage.include='src/scripts/metadata/avif.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11744-test-add-unit-tests-for-avif-metadata-parser-3516d73d365081c5b29adf7a2b9eff62)
by [Unito](https://www.unito.io)
2026-04-29 08:21:55 -04:00
Terry Jia
57d708767a test(load3d): add unit tests for AnimationManager, CameraManager, RecordingManager, and load3dService (#11733)
## Summary

Add unit tests for the four largest untested logic-heavy modules in the
load3d domain (`AnimationManager`, `CameraManager`, `RecordingManager`,
and the `load3dService` façade).

## Changes

- **What**: 78 new unit tests across 4 files covering animation
lifecycle (mixer setup, clip switching, play/pause/seek, dispose),
camera state (perspective↔orthographic toggling, FOV gating, state
round-trip, controls rebinding, resize math, model fitting), recording
lifecycle (MediaRecorder wiring, indicator visibility, chunk handling,
export/clear paths, dispose ordering), and the service singleton
(sync/async map access, viewer cache, `handleViewerClose` apply-changes
flow, `handleViewportRefresh` camera-toggle dance).

## Review Focus

- **Coverage**: AnimationManager 100%, CameraManager 88.8%,
RecordingManager 89.1%, load3dService 54.5% lines / 88.9% functions. The
service gap is concentrated in one method — `copyLoad3dState` (lines
217-333) — which is entangled with 3-4 subsystems and is intentionally
deferred to a follow-up PR.
- **happy-dom shims** in `RecordingManager.test.ts`: `MediaRecorder`,
`HTMLCanvasElement.prototype.captureStream`, and `getContext('2d')` are
all stubbed because happy-dom doesn't provide them.
`THREE.TextureLoader` is also mocked because the constructor eagerly
loads an SVG indicator.
- **Singleton state reset** in `load3dService.test.ts`: the service
holds a module-level `viewerInstances` Map that can't be reached from
outside. Tests track every node they create in a `Set` and drain via
`removeViewer` in `beforeEach`. Cleaner than `vi.resetModules()` (which
would re-import the service and break the singleton identity).
- **One subtle THREE behavior**: `AnimationManager.setAnimationTime`
clamps to `duration`, but `AnimationMixer.setTime(duration)` with
`LoopRepeat` wraps `action.time` back to 0. The clamping is therefore
only observable through the emitted progress event, not via
`action.time` directly — the test asserts via the event.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11733-test-load3d-add-unit-tests-for-AnimationManager-CameraManager-RecordingManager-and--3516d73d3650812485a0c91065e161f0)
by [Unito](https://www.unito.io)
2026-04-29 08:14:30 -04:00
Benjamin Lu
b3f5f82216 Remove unused queue job components (#11621)
## Summary
Remove the unused legacy queue job row implementation before changing
the live queue popover behavior.

## Changes
- Deleted `JobGroupsList` and its test.
- Deleted `QueueJobItem` and its Storybook story.
- Deleted `QueueAssetPreview`, which was only used by `QueueJobItem`.

## Review Focus
- This PR is deletion-only.
- `rg` found no remaining references to these components.
- `knip` and `typecheck` pass.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11621-Remove-unused-queue-job-components-34d6d73d36508164bf32cb581594cd9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-29 04:55:10 +00:00
Dante
9df4e02189 refactor: unify media asset downloads (#11717)
## Summary

Unifies media asset download actions behind a single
`downloadAssets(assets?)` API to avoid single and multi asset download
path drift.

## Changes

- **What**: Replaces `downloadAsset` and `downloadMultipleAssets` with
`downloadAssets`, preserving no-arg media context fallback and explicit
asset arrays.
- **Dependencies**: None.

## Review Focus

Download behavior for single-card, context-menu, and sidebar bulk
actions should continue to use the same ZIP-export path for cloud
multi-output jobs.

Fixes #11715

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11717-refactor-unify-media-asset-downloads-3506d73d3650810d8bcec9c0194e743d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-29 04:50:45 +00:00
AustinMroz
1c541d8577 Short circuit asset reuploads, simplify node dnd (#11691)
When an output is dragged from the assets panel onto a node, outputs
were being reuploaded. This logic has been simplified to instead
reference the existing asset by resolving the annotated path.

As part of this change, async drop handlers on nodes are also fixed.
Rather than placing obligation of event handling on client code, not
respecting async handlers, or completely ignoring return types, the vue
drop handler will now simply set `app.dragOverNode` and allow the
`document` drop handler to resolve node drag/drop operations without any
of the difficulty from propagation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11691-Short-circuit-asset-reuploads-simplify-node-dnd-34f6d73d36508157af86e6cf09229781)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-28 18:49:35 -07:00
Comfy Org PR Bot
389ff8ba49 1.44.13 (#11732)
Patch version increment to 1.44.13

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11732-1-44-13-3516d73d36508119acabf5f86256f2aa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-29 01:23:53 +00:00
Terry Jia
bb74ec94de test(load3d): add unit tests for 9 previously-untested controls (#11730)
## Summary
Mirror the maskeditor coverage approach for load3d sub-components. Each
component gets behavior tests covering rendering, conditional branches,
v-model bidirectional sync, and emitted events.

- ViewerLightControls: setting-store min/max/step + v-model
- ViewerExportControls: format dropdown + click-to-export
- ViewerCameraControls: type select, FOV slider visibility
- ViewerSceneControls: with/without bg image branches, render mode
- PopupSlider: trigger toggle, click-outside dismissal, defaults
- CameraControls: switch button, FOV PopupSlider visibility
- ExportControls: trigger popup, format selection, click-outside
- AnimationControls: empty-list bypass, controls, time formatting
- ViewerControls: dialog open routing + onClose wiring

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11730-test-load3d-add-unit-tests-for-9-previously-untested-controls-3506d73d365081eaa9e7c5d0b922fc14)
by [Unito](https://www.unito.io)
2026-04-28 19:23:09 -04:00
Kelly Yang
e7640d414b test: add E2E tests for ActionBarButtons toolbar component (#11561)
## Summary

- Add E2E tests for the `ActionBarButtons` toolbar component (FE-111)
- Add `data-testid="action-bar-buttons"` to the container div for stable
test targeting
- Register `TestIds.topbar.actionBarButtons` in `selectors.ts`

## Changes

- `browser_tests/tests/actionBarButtons.spec.ts` — 6 tests across 5
scenarios: empty state, button rendering, icon rendering, multiple
buttons, click handler, mobile label hiding
- `src/components/topbar/ActionBarButtons.vue` — adds `data-testid` to
container
- `browser_tests/fixtures/selectors.ts` — registers new test ID

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily adds Playwright coverage and a `data-testid` attribute;
runtime behavior is unchanged aside from an extra DOM attribute.
> 
> **Overview**
> Adds a new Playwright spec (`actionBarButtons.spec.ts`) that verifies
the ActionBarButtons container empty state, rendering (label/icon),
multiple buttons, click handler execution, and mobile label-hiding
behavior by registering buttons via `window.app!.registerExtension`.
> 
> Updates the UI and test selector plumbing by adding
`data-testid="action-bar-buttons"` to `ActionBarButtons.vue` and
exposing it as `TestIds.topbar.actionBarButtons` for stable E2E
targeting.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
80f90d1f1d. 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-11561-test-add-E2E-tests-for-ActionBarButtons-toolbar-component-34b6d73d36508153874fda856a78817f)
by [Unito](https://www.unito.io)
2026-04-28 19:13:12 -04:00
Alexander Brown
c168c37c94 chore: update comfyui-ci-container to 0.0.17 (#11569)
Update `comfyui-ci-container` image to `0.0.17` across all CI workflows.

| Workflow | Before | After |
|---|---|---|
| `ci-perf-report.yaml` | `0.0.12` | `0.0.17` |
| `ci-tests-e2e.yaml` (×2) | `0.0.16` | `0.0.17` |
| `pr-update-playwright-expectations.yaml` | `0.0.16` | `0.0.17` |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11569-chore-update-comfyui-ci-container-to-0-0-17-34b6d73d365081b8a52ac995855354cb)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 22:05:28 +00:00
pythongosssss
089051824c test: add tests for link related settings (#11612)
## Summary

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

## Changes

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

## Review Focus

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

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

## Screenshots (if applicable)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11612-test-add-tests-for-link-related-settings-34c6d73d36508145885bd017162e6fae)
by [Unito](https://www.unito.io)
2026-04-28 22:02:42 +00:00
pythongosssss
517da289f6 feat: Search - add ghost node following setting and increase opacity (#11365)
## Summary

Adds setting to disable the node auto-follow cursor behavior when adding
nodes from the search, and increased the visibilty of Vue ghost nodes.

## Changes

- **What**: 
- add setting
- increase opacity
- add test

## Review Focus

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

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

## Screenshots (if applicable)

Before  
<img width="452" height="517" alt="image"
src="https://github.com/user-attachments/assets/369c0d90-5352-482b-a1b3-36180bffb3ee"
/>

After  
<img width="440" height="536" alt="image"
src="https://github.com/user-attachments/assets/2066fdd4-6eb4-4bfb-ac7c-559fc99de57d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11365-feat-Search-add-ghost-node-following-setting-and-increase-opacity-3466d73d3650811b9c27ed4cc930816d)
by [Unito](https://www.unito.io)
2026-04-28 22:02:33 +00:00
Dante
98c327b3c6 test: add unit tests for colorUtil edge cases (#11671)
## Summary

Extends `colorUtil.test.ts` with boundary tests that the existing suite
did not cover: malformed `parseToRgb` inputs, alpha-hex parsing through
`parseToRgb`, negative hue normalization in `hsbToRgb`, the full
`isTransparent` matrix, HSV-vs-HSB equivalence in `toHexFromFormat`, the
bare-hex prefix path, and a non-primary-color round-trip through
`hexToHsva` / `hsvaToHex`.

## Changes

- **What**: Adds 13 Vitest cases across 5 new `describe` blocks
(`parseToRgb edge cases`, `hsbToRgb normalization`, `isTransparent`,
`toHexFromFormat`, plus a non-primary round-trip in the existing
`hexToHsva / hsvaToHex` block). Uses the existing
`vi.mock('es-toolkit/compat')` memoize stub.

## Review Focus

- The non-primary palette round-trip allows ±1 per RGB channel because
`hsbToRgb` floors while `rgbToHex` rounds; the test asserts the bound
rather than exact equality.
- `parseToRgb` is exercised with alpha-bearing hex (`#f008`,
`#ff000080`); the function returns RGB-only, so the alpha is
intentionally discarded.
- `toHexFromFormat({h, s, v}, 'hsb')` covers the HSV-shaped object path
that wraps `hsbToRgb`.

## Testing

\`\`\`bash
pnpm exec vitest run src/utils/colorUtil.test.ts
pnpm format -- src/utils/colorUtil.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

73 tests pass (60 prior + 13 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11671-test-add-unit-tests-for-colorUtil-edge-cases-34f6d73d36508136bac9edfae32815ec)
by [Unito](https://www.unito.io)
2026-04-28 16:50:20 -04:00
Terry Jia
fc2a4e82cf feat(load3d): bind UI capability gating to ModelAdapterCapabilities (#11711)
> Final piece of the PLY / 3D Gaussian Splatting series. Previous PR
made `ModelAdapterCapabilities` load-bearing on the engine side; the UI
was still gating off `isSplatModel` / `isPlyModel` proxies. This PR
routes the viewer and the viewer-mode dialog through the capability
fields directly, so the same source of truth that drives `Load3d`
behavior also drives what the user sees. Eighth and last in the series
splitting up.

## Summary

Snapshot `Load3d.getCurrentModelCapabilities()` into 5 Vue refs on each
model load and pipe them through the existing `Load3D` /
`Load3DControls` / `Load3dViewerContent` / `ModelControls` /
`ViewerModelControls` / `Preview3d` props. Replaces the format-specific
`:is-splat-model` / `:is-ply-model` props and the hardcoded "splat →
drop light/gizmo/export" subtraction with additive capability gates. No
engine behavior changes — capability values are what previous PR already
produces; UI now consumes them.

## Changes

- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: 5 new refs
(`canFitToViewer` / `canUseGizmo` / `canUseLighting` / `canExport` /
`materialModes`) refreshed on every load via
`load3d.getCurrentModelCapabilities()`. `useLoad3dViewer` extracts the
snapshot into a single `captureAdapterFlags(source)` helper because it
runs in three places (initializeViewer / initializeStandaloneViewer /
loadStandaloneModel).
- **`Load3D.vue`**: gate the fit-to-viewer button on `canFitToViewer`;
pass capability refs to `Load3DControls` instead of `isSplatModel` /
`isPlyModel`.
- **`Load3DControls.vue`**: build `availableCategories` additively
(`['scene','model','camera']` plus `light` / `gizmo` / `export` if their
capability is true) rather than subtracting from a fixed list when
`isSplatModel` is true. Forwards `materialModes` to `ModelControls`.
- **`Load3dViewerContent.vue`**: gate the light / gizmo / export sidebar
sections on the capability refs; pass `materialModes` to
`ViewerModelControls`.
- **`ModelControls.vue` / `ViewerModelControls.vue`**: drop the local
`materialModes` computed (which derived its options from `isPlyModel`
and a hardcoded mesh list) and accept `materialModes` as a `readonly
MaterialMode[]` prop. An empty array hides the dropdown entirely.
- **`Preview3d.vue`** (renderer linearMode): mirror the prop swap on the
standalone preview path.

## Review Focus

- **Capability prop wiring is the only public-API change for child
components**. `ModelControls` and `ViewerModelControls` lost
`hideMaterialMode` / `isPlyModel` props. Any extension that imported
these components directly will need to migrate, but they're internal
`src/components/load3d/controls/**` files and not part of the documented
extension surface.
- **Empty-`materialModes` semantics**: previously hidden via
`:hide-material-mode`; now hidden via `materialModes.length === 0`.
`SplatModelAdapter` declares `materialModes: []`, so the splat case
keeps the same behavior — the dropdown disappears. PLY adds
`'pointCloud'` to the array, so the dropdown picks up that mode
automatically without the controls needing an `isPlyModel` branch.
- **`captureAdapterFlags` runs after every load completes**, so
switching between mesh and splat in the same viewer instance updates the
chrome correctly. Verified via the new `Load3D.test.ts` /
`Load3dViewerContent.test.ts` cases.
- **Capability gating is inclusive of `canFitToViewer`** in this PR even
though `Load3DControls` has no fit category — the fit-to-viewer floating
button on `Load3D.vue` is what reads it. PLY's `fitToViewer: true` means
the button stays visible for PLY users.

## Coverage

| File | Stmts | Branch | Funcs |
|---|---|---|---|
| `Load3D.vue` (modified) | 53.3% | **95.5%** | 83.3% |
| `Load3DControls.vue` (modified) | 77.5% | **94.8%** | 86.4% |
| `Load3dViewerContent.vue` (modified) | 60.6% | 72.1% | 54.5% |
| `controls/ModelControls.vue` (modified) | 16.3% | 0% | 0% |
| `controls/viewer/ViewerModelControls.vue` (modified) | **100%** |
**100%** | **100%** |
| `composables/useLoad3d.ts` (modified) | 78.7% | 64.5% | 71.4% |
| `composables/useLoad3dViewer.ts` (modified) | 76.0% | 52.1% | 66.7% |

Four new test files (`Load3D.test.ts` / `Load3DControls.test.ts` /
`Load3dViewerContent.test.ts` /
`controls/viewer/ViewerModelControls.test.ts`) cover the new capability
gating directly: each component is rendered with capability flags
toggled on/off and the appropriate sidebar / dropdown / button
visibility is asserted. Capability prop forwarding from `Load3D.vue` →
`Load3DControls.vue` and from `Load3dViewerContent.vue` →
`ViewerModelControls.vue` is exercised end-to-end.

`controls/ModelControls.vue` is the legacy node-side ModelControls — its
existing tests live elsewhere and were not in this PR's scope; the diff
line covered (the `v-if="materialModes.length > 0"` swap) is exercised
by the new `Load3DControls.test.ts` cases that drive a non-empty / empty
`materialModes` through. `Preview3d.vue` (renderer linearMode) has no
test file in the project; the prop swap there is the same shape as the
`Load3D.vue` swap which is covered.

`useLoad3d.ts` / `useLoad3dViewer.ts` percentages are roughly the
pre-existing baseline. The diff lines (the 5 new refs and the
`captureAdapterFlags` helper) are exercised by the existing composable
tests via the mock that now stubs `getCurrentModelCapabilities()`.

73 new component unit tests; 393 total load3d-related tests pass on this
branch.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11711-feat-load3d-bind-UI-capability-gating-to-ModelAdapterCapabilities-3506d73d365081b3af68f30e3f728e24)
by [Unito](https://www.unito.io)
2026-04-28 16:39:06 -04:00
pythongosssss
e48d33e4c0 test: Canvas grid, ctx menu scaling and group padding settings (#11721)
## Summary

Adds tests for canvas snap to grid, context menu scaling and group
padding

## Changes

- **What**: 
- add tests for canvas related settings

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11721-test-Canvas-grid-ctx-menu-scaling-and-group-padding-settings-3506d73d36508141868cfa990d903c33)
by [Unito](https://www.unito.io)
2026-04-28 17:21:55 +00:00
pythongosssss
967f1eb562 test: extract title editor test component (#11605)
## Summary

Extract shared TitleEditor component and update tests to use it

## Changes

- **What**: 
- add title editor helper
- update locations that used `TestIds.node.titleInput`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11605-test-extract-title-editor-test-component-34c6d73d3650811da6b0ec493b190c3f)
by [Unito](https://www.unito.io)
2026-04-28 09:46:25 -04:00
Kelly Yang
8b83559402 refactor: extract useBrushPersistence from useBrushDrawing (#11543)
## Summary

PR B of this https://github.com/Comfy-Org/ComfyUI_frontend/pull/11388
Part of the `useBrushDrawing` decomposition plan (PR B). 
Extracts brush settings persistence logic into a dedicated
`useBrushPersistence` composable, reducing the responsibility surface of
`useBrushDrawing`.

No runtime behavior is changed — this is a pure structural refactor.

## Changes

- **New** `src/composables/maskeditor/useBrushPersistence.ts` —
encapsulates `loadAndApply` (reads brush settings from localStorage and
applies them to the store on init) and `save` (debounced write of
current brush settings to localStorage)
- **New** `src/composables/maskeditor/useBrushPersistence.test.ts` —
unit tests covering load from empty storage, full restore round-trip,
missing `stepSize` fallback, corrupted data resilience, and
save-to-localStorage behavior
- **Updated** `src/composables/maskeditor/useBrushDrawing.ts` — removes
the inlined persistence functions and delegates to `useBrushPersistence`

## Test Locally
1. Adjust brush size and hardness, close MaskEditor, then reopen it —
brush parameters should restore to the previous settings (verifies
`save` + `loadAndApply`) - pass
2. Draw a few strokes and confirm the marks appear correctly (verifies
the `saveBrushSettings` public interface is not broken) - pass


https://github.com/user-attachments/assets/961155d5-6742-4668-a419-51c29b850edf





<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk refactor that moves localStorage read/write logic behind a
new composable and adds unit tests; main risk is accidental behavior
drift in when/what brush settings are persisted/restored.
> 
> **Overview**
> Refactors mask editor brush settings persistence by extracting the
localStorage load/save (including debounced writes and `stepSize`
fallback) out of `useBrushDrawing` into a new `useBrushPersistence`
composable, while keeping the `saveBrushSettings` public API wired
through.
> 
> Adds `useBrushPersistence` unit tests covering empty storage,
round-trip restore, missing field defaults, corrupted JSON handling, and
save semantics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d87d7c8bcf. 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-11543-refactor-extract-useBrushPersistence-from-useBrushDrawing-34a6d73d36508144b7edff759a1e3485)
by [Unito](https://www.unito.io)
2026-04-28 09:15:39 -04:00
Kelly Yang
bc11b5ff5e test: add E2E tests for LoginButton toolbar component (#11562)
## Summary

- Add E2E tests for the `LoginButton` toolbar component (FE-109)
- Add `data-testid="login-button"` to `LoginButton.vue` for stable
targeting
- Register `TestIds.topbar.loginButton` in `selectors.ts`

## Changes

- `browser_tests/tests/loginButton.spec.ts` — 7 tests covering:
visibility toggled by `show_signin_button` server feature flag, ARIA
label, click → sign-in dialog, hover popover content, popover dismissal
- `src/components/topbar/LoginButton.vue` — adds `data-testid`
- `browser_tests/fixtures/selectors.ts` — registers new test ID

## Notes

`LoginButton` is rendered in `WorkflowTabs` when `flags.showSignInButton
?? isDesktop` is true. Since `isDesktop = false` in the OSS test build
(`DISTRIBUTION=localhost`), tests enable the button by setting
`window.app.api.serverFeatureFlags.value.show_signin_button = true` —
the established pattern used throughout the test suite (e.g.
`nodeLibraryEssentials.spec.ts`, `shareWorkflowDialog.spec.ts`).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to adding stable `data-testid` hooks
plus new Playwright E2E coverage, with only a minor adjustment to a
unit-test performance threshold.
> 
> **Overview**
> Adds Playwright E2E coverage for the topbar `LoginButton`, including
feature-flag-driven visibility, ARIA labeling, click-to-open sign-in
dialog, and hover popover behavior (including the *Learn more* link and
dismissal).
> 
> To make the tests stable, `LoginButton.vue` now exposes `data-testid`
attributes for the button and popover elements, and `selectors.ts`
registers new `TestIds.topbar.*` entries.
> 
> Relaxes the `useModelToNodeStore.getCategoryForNodeType` performance
test threshold (from 10ms to 100ms) while clarifying the intent of the
check.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1d4dd0bdca. 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-11562-test-add-E2E-tests-for-LoginButton-toolbar-component-34b6d73d365081f1bf2edc747d69ee52)
by [Unito](https://www.unito.io)
2026-04-28 08:33:42 -04:00
Dante
8c1ea7ae64 test: add unit tests for useNodePricing edge cases (#11673)
## Summary

Extends `useNodePricing.test.ts` with three behavioral gaps the existing
suite did not cover: `getNodePricingConfig` does not leak the compiled
JSONata expression, `pricingRevision` ticks after async evaluation
resolves (with a cache-hit path that does not), and
`formatPricingResult` returns `''` for non-finite numeric inputs across
all four result types.

## Changes

- **What**: Adds 9 Vitest cases across two existing `describe` blocks
(`getNodePricingConfig`, `formatPricingResult`) and one new block
(`reactive revision`). Reuses the existing `priceBadge` and
`createMockNodeWithPriceBadge` helpers.

## Review Focus

- The cache-hit assertion checks that `pricingRevision.value` does not
advance after a second `getNodeDisplayPrice` call with the same
signature, exercising the WeakMap cache hit at `useNodePricing.ts:573`.
- Non-finite coverage spans `type:'usd'`, `type:'range_usd'`,
`type:'list_usd'` (empty + all-non-finite + mixed), and the legacy `{
usd }` shape, matching the four `asFiniteNumber` call sites in
`formatPricingResult`.
- The strip-`_compiled` assertion uses `toHaveProperty` so the test
fails loudly if a future refactor accidentally re-exposes the runtime
JSONata instance to debug consumers.

## Testing

\`\`\`bash
pnpm exec vitest run src/composables/node/useNodePricing.test.ts
pnpm format -- src/composables/node/useNodePricing.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

91 tests pass (82 prior + 9 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11673-test-add-unit-tests-for-useNodePricing-edge-cases-34f6d73d365081dab525cdaa71b348a5)
by [Unito](https://www.unito.io)
2026-04-28 04:15:34 -07:00
Dante
69e68847d9 test: add unit tests for workspaceAuthStore retry/race paths (#11674)
## Summary

Extends `useWorkspaceAuth.test.ts` with the retry, race,
storage-resilience, and Zod-validation paths the existing suite did not
exercise: exponential backoff on `TOKEN_EXCHANGE_FAILED`, immediate
context clearing on permanent errors, stale-refresh abort when the user
switches workspaces mid-flight, and resilience to `sessionStorage` quota
errors.

## Changes

- **What**: Adds 6 Vitest cases across three new `describe` blocks
(`refreshToken retry/race paths`, `persistToSession resilience`, `Zod
validation on token response`). Reuses the existing `mockGetIdToken`,
`mockTeamWorkspacesEnabled`, `vi.useFakeTimers()`, and
`mockTokenResponse` fixtures.

## Review Focus

- The retry test uses `vi.runAllTimersAsync()` so the three backoff
sleeps (1s + 2s + 4s) drain in a single tick instead of slowing the
suite. Both `console.warn` (per-attempt) and `console.error`
(final-failure) are silenced.
- The race test resolves the in-flight refresh fetch with a token tied
to the OLD workspace AFTER `switchWorkspace('workspace-other')` has run,
so the assertion fails loudly if the stale-request guard regresses.
- The sessionStorage spy targets the instance method
(`vi.spyOn(sessionStorage, 'setItem')`); spying
`Storage.prototype.setItem` does not intercept happy-dom's per-instance
method.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/stores/useWorkspaceAuth.test.ts
pnpm format -- src/platform/workspace/stores/useWorkspaceAuth.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

33 tests pass (27 prior + 6 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11674-test-add-unit-tests-for-workspaceAuthStore-retry-race-paths-34f6d73d365081e6a8ecce59a156585e)
by [Unito](https://www.unito.io)
2026-04-28 02:56:38 -07:00
comfydesigner
fad9cf0db7 fix: consolidate --color-coral-red variables into --color-coral (#10374)
Removes the desktop-exclusive `--color-coral-red` CSS variables and
replaces their usage with the shared `--color-coral` palette to reduce
variable duplication in the design system.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10374-fix-consolidate-color-coral-red-variables-into-color-coral-32a6d73d365081a4ac88d0ea96aeea02)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alex <alex@Mac.lan>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-04-28 09:17:48 +00:00
pythongosssss
d532fcf779 test: add tests for canvas related settings (#11604)
## Summary

Adds test coverage for canvas related settings

## Changes

- **What**: 
- tests canvas info visibility, fps, pointer modes, selection

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11604-test-add-tests-for-canvas-related-settings-34c6d73d365081748b0ec79bc6fdc6ca)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 08:52:49 +00:00
pythongosssss
52e73f2697 test: Expand node search box V2 e2e coverage (#10620)
## Summary

Adds additional browser test coverage to the v2 node search

## Changes

- **What**:  
- extend search v2 fixtures
   - add additional tests
   - add data-testid for targeting elements
   - rework tests to work with new menu UI

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10620-test-Expand-node-search-box-V2-e2e-coverage-3306d73d365081b6bad3f73daab1194f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-28 08:52:28 +00:00
Simon Pinfold
c4043637d6 fix: route context menu Download through downloadMultipleAssets (#11700)
*PR Created by the Glary-Bot Agent*

---

## Summary

The asset context menu's Download action called `downloadAsset`
directly, so multi-output jobs only downloaded the preview file instead
of all outputs. Route through `downloadMultipleAssets`, which detects
multi-output jobs and creates a ZIP export in cloud mode and falls back
to the single-download path otherwise.

## Changes

- **What**: Swap `actions.downloadAsset(asset)` for
`actions.downloadMultipleAssets([asset])` in the per-asset context menu
Download command, and extend the existing unit test to assert the
routing.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

For single-output assets the behavior is unchanged:
`downloadMultipleAssets([asset])` falls through to the
individual-download path when `hasMultiOutputJobs` is false and
`assets.length === 1` (see `useMediaAssetActions.ts:106`). Verified
manually — right-clicking a single-output asset and clicking Download
still produces one file download to the correct `/api/view` URL.

## Notes

This is a focused replacement for the stale #10948. Compared to that
branch:
- Drops the unrelated `bootstrapStore` API-key auth changes (scope
creep).
- Drops the new `assets.cloud.spec.ts` Playwright spec — cloud
asset-export E2E coverage was added in #11610
(`browser_tests/tests/sidebar/assets.spec.ts`), so a separate cloud spec
for this routing change would mostly duplicate it.
- Keeps the unit-test change minimal: extends the existing `ContextMenu`
stub with a `model` prop watcher and adds one new test, rather than
rewriting the whole file from `@testing-library/vue` to
`@vue/test-utils`.

## Verification

- `pnpm test:unit` (MediaAssetContextMenu.test.ts and
useMediaAssetActions.test.ts)
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format` / `oxfmt --check`
- `pnpm knip`
- Manual: started the OSS dev server, generated a single-output asset
via the queue API, opened the assets sidebar, right-clicked the asset,
and confirmed the Download menu item triggers a single-file download
(screenshot attached).

## Screenshots

![Asset context menu open showing the Download item alongside Inspect,
Insert, Open/Export workflow, Copy job ID, and
Delete](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4727a22df87c291f5308dc348f591650709465b85acabe6b32a3982700450920/pr-images/1777331763941-b7877d53-7271-4a47-a18a-266842e193b6.png)

![Assets sidebar after clicking Download on a single-output asset;
context menu dismissed, no
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4727a22df87c291f5308dc348f591650709465b85acabe6b32a3982700450920/pr-images/1777331764293-4849e094-a8d2-4553-8cf7-2d050f3cc072.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11700-fix-route-context-menu-Download-through-downloadMultipleAssets-34f6d73d365081eb8135e8b699640d97)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 06:31:45 +00:00
Alexander Brown
9d61b4df06 feat: ECS Phase 0b — ID type aliases (FE-166/475/476/477) (#11699)
## Summary

ECS Phase 0b — type-only ID aliases. Builds on FE-165 (centralized
version counter, base of this PR) and adds a tranche of named ID aliases
plus mechanical adoption at known call sites.

This PR now covers four child tickets:

- **FE-166** — adds `GroupId` (in `LGraphGroup.ts`) and `SlotIndex` (in
`interfaces.ts`); re-exported from the litegraph barrel.
- **FE-475** — mechanical adoption of existing aliases (`NodeId`,
`LinkId`, `RerouteId`, `GroupId`, `SlotIndex`, `ExecutionId`) across
litegraph at the audit-listed sites:
`LGraphState.lastGroupId/lastLinkId/lastRerouteId`,
`LGraphExtra.linkExtensions`, `ISerialisedGroup.id`,
`ISerialisedGraph.last_link_id`, `LinkNetwork.removeReroute`,
`INodeOutputSlot.slot_index`, `LGraphNode.{setOutputDataType,
getInputDataType, getOutputPos}` slot params,
`ExecutableNodeDTO.inputs[].linkId` + execution-id locals, and
`RenderLink/MovingLinkBase.fromSlotIndex` (plus subclasses that
redeclare).
- **FE-476** — adds `SubgraphId = UUID` in `LGraph.ts`; adopted at
`_subgraphs` Map, `findUsedSubgraphIds`, `getDirectSubgraphIds`, and
`ExportedSubgraphInstance.type`. Re-exported from the litegraph barrel.
- **FE-477** — adds app-domain entity aliases at their closest
schema/types files: `WorkflowId`, `AssetId`, `PromptId` (propagated as
existing `JobId`), `TaskId`, `UserId`, `WorkspaceId`,
`WorkspaceInviteId`, `NodePackId`. Adopted at primary use sites (entity
id fields, store state, service signatures).

## Entity reference

### ID aliases at a glance

| Alias | Underlying | Defined in | Identifies |
| --- | --- | --- | --- |
| `NodeId` | `number \| string` | `litegraph/LGraphNode.ts` | A node
within a graph |
| `LinkId` | `number` | `litegraph/LLink.ts` | A connection between two
slots |
| `RerouteId` | `number` | `litegraph/Reroute.ts` | A reroute waypoint
on a link |
| `GroupId` *(new)* | `number` | `litegraph/LGraphGroup.ts` | A visual
group of nodes |
| `SlotIndex` *(new)* | `number` | `litegraph/interfaces.ts` | A slot's
position on a node |
| `ExecutionId` | `string` | `litegraph/types/serialisation.ts` | A node
within a subgraph instance |
| `SubgraphId` *(new)* | `UUID` | `litegraph/LGraph.ts` | A subgraph
definition |
| `WorkflowId` *(new)* | `string` |
`platform/workflow/validation/schemas/workflowSchema.ts` | A saved
workflow document |
| `AssetId` *(new)* | `string` |
`platform/assets/schemas/assetSchema.ts` | A binary asset (model, image,
etc.) |
| `JobId` *(reused as `PromptId`)* | `string` | `schemas/apiSchema.ts` |
A queued prompt execution |
| `TaskId` *(new)* | `string` | `platform/tasks/services/taskService.ts`
| A backend background task |
| `UserId` *(new)* | `string` | `types/authTypes.ts` | An authenticated
user |
| `WorkspaceId` *(new)* | `string` |
`platform/workspace/workspaceTypes.ts` | A workspace |
| `WorkspaceInviteId` *(new)* | `string` |
`platform/workspace/workspaceTypes.ts` | A pending workspace invite |
| `NodePackId` *(new)* | `string` |
`workbench/extensions/manager/types/comfyManagerTypes.ts` | A Comfy
Registry / Manager node pack |

### How the entities relate

```mermaid
flowchart TB
  subgraph LG["🎨 Litegraph entities"]
    direction TB
    Graph["LGraph<br/>id: SubgraphId"]
    Subgraph["Subgraph<br/>id: SubgraphId"]
    Node["LGraphNode<br/>id: NodeId"]
    Link["LLink<br/>id: LinkId"]
    Reroute["Reroute<br/>id: RerouteId"]
    Group["LGraphGroup<br/>id: GroupId"]
    Slot["INodeSlot<br/>slot_index: SlotIndex"]
    Exec["ExecutableNodeDTO<br/>id: ExecutionId"]
  end

  subgraph AP["🌐 App-domain entities"]
    direction TB
    Workflow["ComfyWorkflow<br/>id: WorkflowId"]
    Job["Prompt / Job<br/>id: JobId ≡ PromptId"]
    Task["Task<br/>id: TaskId"]
    Asset["Asset<br/>id: AssetId"]
    Workspace["Workspace<br/>id: WorkspaceId"]
    Invite["WorkspaceInvite<br/>id: WorkspaceInviteId"]
    User["User<br/>id: UserId"]
    Pack["NodePack<br/>id: NodePackId"]
  end

  Subgraph -. extends .-> Graph
  Graph --> Node
  Graph --> Link
  Graph --> Group
  Node --> Slot
  Link --> Reroute
  Link --> Slot
  Node -. instantiates .-> Subgraph
  Exec -. wraps .-> Node

  Workflow -. serializes .-> Graph
  Job --> Workflow
  Job --> Task
  Task --> Asset
  Workspace --> Workflow
  User --> Workspace
  Workspace --> Invite
  Pack -. provides .-> Node
```

Solid arrows are containment / direct references; dashed arrows are *“is
a kind of”* (`extends`) or cross-layer relationships (e.g. a
`ComfyWorkflow` *serializes* an `LGraph`; a `NodePack` *provides* node
definitions).

## Explicit non-goals

- `LGraphState.lastNodeId` is intentionally kept as bare `number`
(auto-increment counter; would widen if aliased to `NodeId = number |
string`).
- No new `SubgraphSlotId` alias — verified subsidiary (subgraph IO slots
are addressed via `SUBGRAPH_INPUT_ID/OUTPUT_ID` sentinel + numeric array
index, not by UUID alone).
- No `WidgetName`, `SlotName`, `WorkspaceMemberId` — verified subsidiary
(only meaningful inside a parent or as a relationship).
- No re-typing of `LGraph.id` / `Subgraph.id` — references adopt
`SubgraphId`, but the inherited UUID typing is left intact (minimal
diff).

## Type-only

All changes are structural-equivalent type aliases. No runtime behavior
changes. No new exports beyond the aliases themselves. No generated code
modified.

## Verification

- `pnpm typecheck` 
- `pnpm knip` 
- Scoped `npx eslint` on changed files 
- Lint-staged hooks (oxfmt, oxlint, eslint, typecheck) passed on every
commit

## Notes for reviewers

This branch was rebased onto `main` after FE-165 (`a441364a5`, PR
#11698) merged independently — the auto-skipped FE-165 commit is no
longer part of this PR. Six commits remain (oldest → newest):

| Commit | Maps to | Summary |
| --- | --- | --- |
| `e8e7ff795` | FE-166 | Add `GroupId` and `SlotIndex` aliases + barrel
re-exports |
| `e0bcb75a0` | FE-476 | Add `SubgraphId = UUID` alias |
| `2c136afb9` | FE-477 + FE-475 bulk | Add app-domain aliases;
mechanical adoption of
`NodeId`/`LinkId`/`RerouteId`/`GroupId`/`SlotIndex`/`ExecutionId`/`SubgraphId`
at audit-listed litegraph sites |
| `06d6e6a8b` | FE-477 | Adopt `TaskId` in asset stores |
| `f943e1c2b` | FE-476 | Adopt `SubgraphId` at remaining UUID reference
sites (`LGraphCanvas` clipboard map + paste, `SubgraphNode.type`) |
| `1739d5241` | review feedback | Tighten alias usage:
`linkExtensions.parentId: RerouteId`, drop redundant `String()` wraps in
`executionStore`, type `assetExportStore` map as `Map<TaskId,
AssetExport>` |

FE-475's mechanical adoption is bundled into `2c136afb9` rather than a
dedicated commit (parallel-agent execution on a shared working tree);
the substitutions themselves are complete — see the diff under
`src/lib/litegraph/src/`. PR will be squash-merged, so commit
granularity is informational.

Fixes FE-166
Fixes FE-475
Fixes FE-476
Fixes FE-477

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 21:36:06 -07:00
Christian Byrne
963a7bf178 refactor: consolidate browser_tests/helpers/ into fixtures/ (#11411)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Eliminates the confusing dual-helpers structure where
`browser_tests/helpers/` and `browser_tests/fixtures/helpers/` coexisted
one tier apart with overlapping purposes
- Routes each file to its natural home based on what it actually *is*:
page objects → `components/`, standalone utils → `utils/`, domain helper
classes stay in `helpers/`
- Adds an ESLint guard (`no-restricted-imports`) to prevent re-creating
`browser_tests/helpers/`

## File Moves

| File | From | To | Reason |
|---|---|---|---|
| `actionbar.ts` | `helpers/` | `fixtures/components/Actionbar.ts` |
Page object class imported by ComfyPage |
| `templates.ts` | `helpers/` | `fixtures/components/Templates.ts` |
Page object class imported by ComfyPage |
| `boundsUtils.ts` | `fixtures/helpers/` | `fixtures/utils/` | Pure
function, not a helper class |
| `mimeTypeUtil.ts` | `fixtures/helpers/` | `fixtures/utils/` | Pure
function, not a helper class |
| `builderTestUtils.ts` | `helpers/` | `fixtures/utils/` | Shared test
setup functions |
| `clipboardSpy.ts` | `helpers/` | `fixtures/utils/` | Page injection
utility |
| `fitToView.ts` | `helpers/` | `fixtures/utils/` | Canvas utility
function |
| `manageGroupNode.ts` | `helpers/` | `fixtures/utils/` | Litegraph
interaction helper |
| `painter.ts` | `helpers/` | `fixtures/utils/` | Test helper functions
|
| `perfReporter.ts` | `helpers/` | `fixtures/utils/` | Test
infrastructure |
| `promotedWidgets.ts` | `helpers/` | `fixtures/utils/` | Query helpers
for specs |

## What Changed Beyond File Moves

- **28 import statements** updated across test specs, fixtures, and
infra files
- **AGENTS.md** — directory tree diagram and architectural separation
descriptions updated
- **README.md** — "Leverage Existing Fixtures and Helpers" section
updated
- **`.claude/skills/perf-fix-with-proof/SKILL.md`** — perfReporter path
reference updated
- **`eslint.config.ts`** — added `@e2e/helpers/*` restricted import
pattern to both spec and non-spec browser_tests rules

## Verification

- `pnpm typecheck` — clean
- `pnpm typecheck:browser` — clean
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm format:check` — all files formatted
- `pnpm knip` — clean
- Pre-commit hooks passed full pipeline (oxfmt, oxlint, eslint,
typecheck, typecheck:browser)

## Config Audit

No changes needed to: `tsconfig.json` (`@e2e/*` alias covers all
subdirs), `playwright.config.ts`, `vite.config.mts`, `knip.config.ts`,
`.oxlintrc.json`, `nx.json`

## Manual Verification Note

This is a pure structural refactoring (file moves + import updates) with
zero behavioral or visual changes. The typecheck and lint passes confirm
all imports resolve correctly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11411-refactor-consolidate-browser_tests-helpers-into-fixtures-3476d73d3650816cb671ef7fa8433f66)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 02:18:31 +00:00
Terry Jia
f001bc9af3 feat: derive Preview3D camera pose from EXTRINSICS/INTRINSICS matrices (#11626)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
splat workflows (and PLY pipelines that go through SHARP / COLMAP) emit
camera pose as extrinsics + intrinsics matrices, and the viewer needs a
way to consume them before that end-to-end story can ship.

## Summary

Adds the ability to drive the Preview3D camera from a pair of
OpenCV-convention extrinsics + intrinsics matrices (as produced by SHARP
/ COLMAP / other SfM pipelines), so backend nodes that emit such
matrices can position the viewer's camera deterministically. Fifth in
the series splitting up
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495. Fully
self-contained — adds new API surface only, no existing call paths are
modified.

## Changes

- **What**:
- `cameraFromMatrices.ts` (new): pure function
`computeCameraFromMatrices(extrinsics, intrinsics)` returning `{
position, target, fovYDegrees }`. No THREE.js dependency.
Shape-validates the inputs (4×4 and 3×3) with a clear error.
- `Load3d.setCameraFromMatrices(extrinsics, intrinsics)`: wires the
utility into Load3d via `setCameraState` + `setFOV`. Preserves the
caller's existing `zoom` and `cameraType`.
- `load3d.ts` (extension): Preview3D's `onExecuted` extracts
`extrinsics`/`intrinsics` from `result[3]`/`result[4]` (when present)
and calls `setCameraFromMatrices`. The output tuple type is widened to
include the two optional `Matrix` fields.

## Review Focus

- **Convention conversion**: OpenCV is Y-down, Z-forward; three.js is
Y-up, Z-back. The function flips Y and Z on both `position` and `target`
(equivalent to a 180° X-axis rotation of the whole world). The same
rotation is applied elsewhere to splats at load time, so a future splat
+ camera-pose pair lines up.
- **FOV math**: vertical FOV (radians) = `2 * atan(cy / fy)`. We expose
it in degrees, matching `cameraManager.setFOV`'s contract.
- **Camera-state preservation**: `setCameraFromMatrices` reads the
current state to keep `zoom` and `cameraType` intact — only `position`,
`target`, and `fov` come from the matrices.
- **Backwards compatibility**: backend nodes that don't return matrices
simply skip the new branch (the `if (extrinsics && intrinsics)` guard).
Existing Preview3D workflows are unaffected.
- 7 unit tests for the matrix math (identity, rotation, translation, FOV
derivation, shape errors, etc.); 1 unit test for the Load3d wiring
(verifies the destructured `position`/`target`/`fovYDegrees` reach
`setCameraState` + `setFOV` with `zoom`/`cameraType` preserved).

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `cameraFromMatrices.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | ~7.6% | 0% | ~14.6% | ~7.7% |
| `load3d.ts` (extension, modified) | 0% | 0% | 0% | 0% |

The `Load3d.ts` numbers are the pre-existing baseline — `Load3d.test.ts`
covers façade methods via prototype injection rather than instantiating
the class (the constructor needs `THREE.WebGLRenderer`, which happy-dom
can't provide). The new `setCameraFromMatrices` method body is exercised
by a new unit test that asserts `setCameraState` receives the
destructured matrix output and `setFOV` receives the computed
`fovYDegrees`, with the caller's `zoom`/`cameraType` preserved.

`load3d.ts` (the extension registration file, 0% on `main` and after
this PR) has no unit-test scaffolding in the project — its `onExecuted`
handler runs only after a workflow execution and is exercised end-to-end
via browser tests. The new `if (extrinsics && intrinsics)
load3d.setCameraFromMatrices(...)` branch sits in that path.

Net: the matrix math itself, which previously didn't exist, is now 100%
covered. The wiring layer relies on the same e2e safety net the
surrounding code has always used.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11626-feat-derive-Preview3D-camera-pose-from-EXTRINSICS-INTRINSICS-matrices-34d6d73d3650817297bdf25a83a4f7a2)
by [Unito](https://www.unito.io)
2026-04-27 21:58:12 -04:00
Kelly Yang
88faaf3d86 test: complete remaining Painter widget E2E tests (#11613)
## Summary

Implemented E2E test coverage for Levels 6-12 of the Painter Widget

## Changes

Adds the following coverage to complete the test plan:

Level 6 - Input image connection (3 tests, @slow):
  - Width/height/bg-color controls hide when input is linked
  - Canvas resizes to match input image dimensions after execution
  - Drawing over input image produces canvas content

Level 7 - Clear on empty canvas is harmless

Level 8 - Unchanged canvas does not re-upload on second serialization

Level 9 - Settings persistence:
  - Tool selection saved to node.properties.painterTool
  - Brush size change saved to node.properties.painterBrushSize

Level 10 - Compact layout collapses to grid-cols-1 when node width <
350px

Level 12 - Rapid drawing accumulates all strokes (checks 3 y-positions)

Supporting changes:
- Add data-testid="painter-controls" to controls grid in
WidgetPainter.vue (needed for compact mode class assertion)
- Add browser_tests/assets/widgets/painter_with_input.json workflow
fixture (LoadImage connected to Painter input slot 0)



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly adds/adjusts Playwright and unit test coverage; the only
runtime change is wrapping pointer-capture calls in `try/catch`, which
is low-risk but touches input-handling paths.
> 
> **Overview**
> Completes and expands Painter widget browser test coverage, including
new scenarios for clearing an empty canvas, preventing redundant uploads
when serializing an unchanged canvas, persisting tool/brush-size
settings to node properties, compact layout behavior, multi-stroke
accumulation checks, and an input-image-connected workflow (new
`painter_with_input.json`) with execution/resizing/draw-over-image
assertions.
> 
> Hardens `usePainter` pointer handling by tolerating
`setPointerCapture`/`releasePointerCapture` failures (e.g., synthetic
events), with corresponding unit tests updated/added to validate the
behavior and serialization expectations.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
056d4a9f0c. 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-11613-test-complete-remaining-Painter-widget-E2E-tests-34c6d73d36508158b620f55aa1981cf5)
by [Unito](https://www.unito.io)
2026-04-27 21:41:45 -04:00
Terry Jia
42ff7b6c62 refactor(load3d): drive viewer behavior from ModelAdapter capabilities (#11660)
> Final architectural step in the PLY / 3D Gaussian Splatting series.
Previous PR introduced `ModelAdapter` with a dormant `capabilities`
field; this PR makes those capabilities load-bearing and replaces the
remaining `instanceof SplatMesh` / `instanceof BufferGeometry` switches
with adapter-driven dispatch. Together with previous one it removes the
last of the format-specific branching from `SceneModelManager` /
`Load3d`. Seventh in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Summary

Drive viewer behavior (fit-to-viewer, default camera pose, world bounds,
GPU dispose, material rebuild) from `ModelAdapterCapabilities` + 3 new
optional adapter methods, instead of `SceneModelManager` reflecting on
model shape. `Load3d` is rewritten to take its 13 managers as injected
`Load3dDeps`; a new `createLoad3d` factory assembles them and threads a
single `AdapterRef` between `LoaderManager` (writer) and
`SceneModelManager` + `Load3d` (readers). Splat orientation +
decoder-race + sizing bugs are fixed as a side effect — splats now
render upright, fill the grid, and don't lock the OrbitControls target
on first frame.

## Changes

- **`ModelAdapter.ts`**: add `AdapterRef = { current: ModelAdapter |
null }` shared handle and 3 optional adapter methods —
`computeBounds(model)`, `disposeModel(model)`, `defaultCameraPose()`.
- **`SplatModelAdapter.ts`**: implement all 3 optional methods; `await
splatMesh.initialized` so first-frame bounds are populated (fixes a
decoder race that collapsed the OrbitControls target onto the camera);
`quaternion.set(1, 0, 0, 0)` to convert sparkjs's OpenCV (Y-down,
Z-forward) to three.js (Y-up, Z-back); flip `fitToViewer` back to `true`
and bump `fitTargetSize` to `20` so splats fill the 20-unit grid instead
of shrinking to 1/4 of it.
- **`PointCloudModelAdapter.ts`**: extract
`buildPointCloudForMaterialMode` so `SceneModelManager` rebuilds via the
same code path the initial load uses; `setPath` so PLYs that reference
relative assets resolve correctly.
- **`LoaderManager.ts`**: accept optional `AdapterRef`; write through it
instead of the internal `_currentAdapter` field. `clearModel()` now runs
while the old adapter is still current so its `disposeModel()` can
release renderer-owned resources.
- **`SceneModelManager.ts`**: accept 4 capability lambdas
(`getCurrentCapabilities` / `getBoundsFromAdapter` /
`disposeModelViaAdapter` / `getDefaultCameraPose`) with
`DEFAULT_MODEL_CAPABILITIES` / null fallbacks. `setupModel`,
`fitToViewer`, and material-mode rebuild are now capability-driven;
`containsSplatMesh` (30 lines of `instanceof` traversal) and
`handlePLYModeSwitch` (90 lines of duplicated PLY rebuild) are gone.
- **`Load3d.ts`**: ctor switches from manager-creation to deps-injected
(`Load3dDeps`); add `getCurrentModelCapabilities()` reader; gate
`setGizmoEnabled` / `setGizmoMode` / `resetGizmoTransform` /
`applyGizmoTransform` on `capabilities.gizmoTransform`; `isSplatModel` /
`isPlyModel` now read `adapterRef.current?.kind` directly.
- **`createLoad3d.ts`** (new): single factory that builds the renderer
(`createRenderer`), assembles all 13 managers in dependency order, and
threads one shared `AdapterRef` through `LoaderManager` and
`SceneModelManager`'s 4 capability lambdas.
- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: switch from `new
Load3d(container, options)` to `createLoad3d(container, options)`. No
other call-site changes.

## Review Focus

- **Capability dispatch parity**: walk each former hardcoded branch in
`SceneModelManager` and confirm it now falls out of the right
capability:
- `containsSplatMesh()` → `!capabilities.fitToViewer` +
`getDefaultCameraPose()`
- `handlePLYModeSwitch()` → `capabilities.requiresMaterialRebuild` +
`buildPointCloudForMaterialMode()`
- `Box3.setFromObject(model)` for sizing → `getBoundsFromAdapter(model)
?? Box3.setFromObject(model)`
- Mesh/Points geometry+material disposal in `clearModel` → still
happens, plus `disposeModelViaAdapter(obj)` for adapter-owned resources
(sparkjs SplatMesh internal GPU state)
- **`AdapterRef` lifecycle**: one ref is created in `createLoad3d`,
passed to `LoaderManager` (writer) and `SceneModelManager` (read via 4
closures). `LoaderManager.loadModel` clears via the *old* adapter first
(so `disposeModel` runs), then null-resets the ref before picking the
new one. Test `keeps the old adapter current while clearModel runs` pins
this ordering.
- **Splat fixes are user-visible, not pure refactor**:
- Orientation: `quaternion.set(1, 0, 0, 0)` matches the sparkjs README
convention. Without it splats render upside-down and mirrored on Z. Same
rotation is applied to the camera-from-matrices output in PR-E so a
future splat + camera-pose pair lines up.
- Decoder race: `await splatMesh.initialized` ensures `getBoundingBox`
returns a non-zero box on the first call. Without it `setupModel`'s
bounds → camera pipeline placed the OrbitControls target on the camera
origin, locking the view.
- Sizing: `fitTargetSize: 20` (vs. the mesh default of 5) means splat
geometry spans the full 20-unit grid footprint instead of ~1/4 of it.
Mesh assets are unaffected.
- **Gizmo gating**: `setGizmoEnabled(true)` early-returns when
`capabilities.gizmoTransform` is false. Internal
`setGizmoEnabled(false)` still runs (so we can always disable).
`setGizmoMode` / `resetGizmoTransform` / `applyGizmoTransform` no-op
when the capability is off.
- **`createLoad3d` is the single ctor entry**: `new Load3d(...)` is no
longer callable from app code (ctor signature changed to `(container,
deps, options)`). All call sites use `createLoad3d`. Test scaffolding
still uses `Object.create(Load3d.prototype)` + property injection where
it needs to bypass renderer creation.
- **Backwards compatibility**: `LoaderManager`'s `adapterRef` and
`SceneModelManager`'s 4 capability lambdas all have defaults
(`createAdapterRef()` and `() => DEFAULT_MODEL_CAPABILITIES` etc.), so
the existing test suites that construct these classes with the old
signatures still compile and pass without modification beyond what's in
this PR.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.7% | 86.7% | **100%** |
| `MeshModelAdapter.ts` (unchanged) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (modified) | **97.9%** | 69.2% | 71.4% |
**97.9%** |
| `SplatModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `SceneModelManager.ts` (modified) | 75.4% | 67.2% | 72.2% | 75.4% |
| `Load3d.ts` (modified) | 29.5% | 30.6% | 26.7% | 30.1% |
| `createLoad3d.ts` (new) | 83.8% | **100%** | 58.3% | 83.8% |
| `useLoad3d.ts` (modified) | 78.2% | 65.1% | 71.4% | 82.2% |
| `useLoad3dViewer.ts` (modified) | 75.2% | 52.1% | 65.9% | 79.4% |

`SplatModelAdapter.ts` jumps to 100% via 6 new tests covering the
orientation set, the `await initialized` decoder wait, `computeBounds`
(world-space transform + null fallback), `disposeModel` (per-SplatMesh
dispose + no-op on non-splat trees), and `defaultCameraPose`.

`createLoad3d.ts` hits 100% branch via a new test file with 12 cases —
`WebGLRenderer` config, `Load3DOptions` forwarding, `AdapterRef`
identity between `LoaderManager` and `SceneModelManager`, and the 4
capability lambdas in both adapter-null and adapter-published states
(each delegates correctly to the adapter's optional methods or falls
back to defaults). The remaining func% reflects the inline
`gizmoTransformChange` callback — not a deliberate skip, just out of
scope for the dispatch-wiring tests.

`SceneModelManager.ts` and `Load3d.ts` numbers are the pre-existing
baseline — the existing `*.test.ts` files cover façade methods via
prototype injection rather than instantiating the classes (`Load3d`
constructor needs `THREE.WebGLRenderer`, which happy-dom can't provide;
`SceneModelManager` covers the new capability paths via its existing
`createManager(overrides)` helper). All new branches (capability gating,
capability-driven `setupModel` / `fitToViewer` / rebuild, adapter-driven
`isSplatModel` / `isPlyModel`) have dedicated tests.

Net diff: **+846 / −370** across 16 files (10 production, 6 test).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11660-refactor-load3d-drive-viewer-behavior-from-ModelAdapter-capabilities-34f6d73d36508130b0ece884add182b9)
by [Unito](https://www.unito.io)
2026-04-27 21:32:21 -04:00
Dante
f404887c96 test: add unit tests for linkFixer (#11668)
## Summary

Adds unit coverage for `linkFixer` serialized graph repair paths and
fixes input-slot repairs being tracked without mutating the serialized
workflow data.

## Changes

- **What**: Adds 10 Vitest cases for valid links, dry-run reporting,
origin/target repair, missing input-slot cleanup, dangling node cleanup,
stale link deletion, and silent mode.
- **What**: Applies serialized input slot mutations in fix mode and
reports `fixed` only after a rerun confirms the graph is clean.
- **What**: Adds the design-system package to knip workspace config so
the pre-push hook recognizes its exported CSS dependency usage.
- **Dependencies**: None.

## Review Focus

The workflow validation path calls `fixBadLinks` with serialized
workflow JSON, so the new tests stay at that boundary and assert the
repaired graph shape directly.

## Test Coverage

- CI snapshot for `src/utils/linkFixer.ts`: unit lines 1.8%.
- Local targeted coverage: unit lines 83.9%, functions 94.11%, branches
75.15%.

## Testing

```bash
pnpm format -- src/utils/linkFixer.ts src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts --coverage
pnpm typecheck
pnpm lint
pnpm knip
```


## screenshoot
<img width="1691" height="927" alt="Screenshot 2026-04-28 at 10 09
26 AM"
src="https://github.com/user-attachments/assets/ff59888f-bde3-48f5-853a-60df69e84492"
/>
2026-04-28 01:11:25 +00:00
Comfy Org PR Bot
b37c66539b 1.44.12 (#11705)
Patch version increment to 1.44.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11705-1-44-12-3506d73d36508121bc39e748c87a6235)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 00:38:05 +00:00
Alexander Brown
95e9a9405b test(subgraph): pin #10849 promoted-widget-value corruption with it.fails (#11697)
*PR Created by the Glary-Bot Agent*

---

## Summary

#11579 restored *categorical* test coverage for subgraph serialization
but didn't reproduce the specific Z-Image-Turbo regression introduced by
#10849 — pre-#10849 templates whose `widgets_values` is leftover noise
get corrupted on load because the new code applies that array
positionally to promoted widget views.

This PR adds two **vitest** cases that pin the user-visible symptom
directly: after loading a misaligned legacy payload, the promoted widget
value should reflect the source default, not the legacy
`widgets_values[i]`.

Both use `it.fails` so the suite stays green while the bug is present
and flips to failing the moment the fix on
`fix/subgraph-promoted-widget-inline-state` lands.

## Tests

1. `falls back to source widget value when proxyWidgets is in legacy
2-tuple shape` — configure() with `proxyWidgets: [['-1', 'widget']]` +
`widgets_values: [999]` should leave the widget at the source default
(42), not 999.
2. `does not corrupt unbound promoted widgets when widgets_values length
mismatches view count` — same shape with longer/wrong-length array.

## Verification

- All 8 cases in the file pass under `it.fails` (CI green).
- Removing `.fails` locally produces the expected failures: `expected
42, received 999` and `expected 42, received 111` — confirming both
tests catch the regression.
- `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxlint` all clean.

## Why `it.fails` and not plain failing tests

The actual fix (`fix/subgraph-promoted-widget-inline-state`) is
unmerged. Landing genuinely-failing tests on main would break CI for
everyone. `it.fails` documents the bug runnably, keeps CI green, and
signals when the fix lands so the marker can be dropped.

## Why these assertions, not "widgets_values must be undefined"

A first draft asserted that `serialize()` should not write
`widgets_values` at all. That conflicts with existing coverage in the
same file (`preserves per-instance widget values after configure`,
`round-trips per-instance widget values`) which deliberately uses
`widgets_values` for round-trip persistence. These rewritten assertions
target the load-time corruption symptom directly without contradicting
the per-instance contract — feedback from Oracle review.

## Coordination

Mirrors the failing tests already on commit `6a982675e` of the unmerged
`fix/subgraph-promoted-widget-inline-state` branch, with the addition of
`.fails` markers and a clarifying comment so they can land on main
first.

Follow-up to #11579.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11697-test-subgraph-pin-10849-promoted-widget-value-corruption-with-it-fails-34f6d73d365081d7a04dcf48ebeceafe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 00:13:48 +00:00
Dante
00974d6339 test: add E2E tests for publish flow wizard (#10770)
## Summary
- Add Playwright E2E test coverage for the ComfyHub publish workflow
dialog
- Create `PublishDialog` page object fixture with programmatic dialog
opening via Vite dynamic imports
- Create `PublishApiHelper` for mocking all publish flow API endpoints
(`/hub/profiles/me`, `/hub/labels`, `/hub/workflows`,
`/userdata/*/publish`, `/assets/from-workflow`,
`/hub/assets/upload-url`)
- Add `data-testid` attributes to 6 publish flow components for stable
E2E locators
- 17 test scenarios across 7 describe blocks covering wizard navigation,
form interactions, profile gate, save prompt, and publish submission

## Test plan
- [ ] Run `pnpm test:browser:local -- --grep "Publish dialog"` against
local dev server
- [ ] Verify wizard navigation through Describe → Examples → Finish
steps
- [ ] Verify profile gate flow (with/without profile)
- [ ] Verify save prompt for unsaved workflows
- [ ] Verify publish success/failure scenarios

Fixes #9079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10770-test-add-E2E-tests-for-publish-flow-wizard-3346d73d3650818094d5fc3a84593402)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante <dante@danteui-MacStudio.local>
Co-authored-by: GitHub Action <action@github.com>
2026-04-28 00:13:43 +00:00
Dante
878ffb70cc test: add unit tests for assetService (#11670)
## Summary

Extends `assetService.test.ts` (which previously only covered
`shouldUseAssetBrowser`) with behavioral tests for the network-bound
methods: metadata fetch error mapping, base64 upload validation,
async-vs-sync upload routing, delete error propagation, model-folder
filtering, update validation, and tag-filtered list defaults.

## Changes

- **What**: Adds 10 Vitest cases across `getAssetMetadata`,
`uploadAssetFromBase64`, `uploadAssetAsync`, `deleteAsset`,
`getAssetModelFolders`, `updateAsset`, and `getAssetsByTag`. Reuses the
existing hoisted `mockDistributionState` / `mockSettingStoreGet` setup
and the existing `vi.mock('@/scripts/api')` boundary; adds local
`buildResponse` and `validAsset` helpers scoped to this file.

## Review Focus

- The localized error path is covered through public methods
(`getAssetMetadata`) rather than reaching for the internal
`getLocalizedErrorMessage`.
- `getAssetModelFolders` test asserts that the request URL omits
`include_public` (the internal call site passes no `includePublic`),
matching the conditional in `handleAssetRequest`.
- `uploadAssetAsync` tests pin the discriminated-union shape (`type:
'async' | 'sync'`) for both 202 and 200 responses.

## Testing

\`\`\`bash
pnpm exec vitest run src/platform/assets/services/assetService.test.ts
pnpm format -- src/platform/assets/services/assetService.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

All 16 tests pass (6 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11670-test-add-unit-tests-for-assetService-34f6d73d36508117b1aafaf463e9c820)
by [Unito](https://www.unito.io)
2026-04-28 08:45:01 +09:00
Alexander Brown
a441364a55 refactor(litegraph): centralize _version counter via incrementVersion() (#11698)
## Summary

Centralize all `LGraph._version` increments behind a single
`incrementVersion()` method to create the seam for a future
`VersionSystem` (ECS Migration Phase 0a).

## Changes

- **What**: Added `LGraph.incrementVersion()` and replaced all 19 direct
`graph._version++` writes across 7 files. Existing null guards at call
sites are preserved. Zero behavioral change — the counter is still only
read by `LGraphCanvas.renderInfo()` for debug display.

## Review Focus

- The new method is mechanical: `incrementVersion(): void {
this._version++ }`. Look for any sites I missed or null-guard
regressions.
- Files updated: `LGraph.ts`, `LGraphNode.ts`, `LGraphCanvas.ts`,
`widgets/BaseWidget.ts`, `subgraph/SubgraphInput.ts`,
`subgraph/SubgraphInputNode.ts`, `subgraph/SubgraphOutput.ts`.

Part of the [ECS Migration
Plan](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md).
Linear: FE-165.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11698-refactor-litegraph-centralize-_version-counter-via-incrementVersion-34f6d73d3650810992f8fa3adbae3f38)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 23:25:41 +00:00
Alexander Brown
8dcadd6fe1 test: restore deleted subgraph serialization E2E tests (#11579)
*PR Created by the Glary-Bot Agent*

---

## Summary

#10759 removed ~12 E2E tests from `subgraphSerialization.spec.ts` during
a reorganization that shifted semantic coverage to Vitest. Several of
the removed tests covered the exact `serialize() → JSON → configure()`
round-trip that #10849's positional `_instanceWidgetValues` path later
regressed on Main — promoted widget values binding to the wrong slots
when loading templates whose `widgets_values` ordering doesn't match
current `proxyWidgets`.

This restores the pre-reorg E2E coverage so future regressions in
promoted-widget serialization are caught at the browser level.

## Restored tests

From `subgraphSerialization.spec.ts` (pre-#10759):

- **Deterministic proxyWidgets Hydrate** (3 tests) — round-trip
stability and compressed `target_slot` resolution.
- **Legacy And Round-Trip Coverage** (5 tests) — includes the most
directly-relevant restorations:
  - `Promoted widgets survive serialize -> loadGraphData round-trip`
  - `Multi-link input representative stays stable through save/reload`
- `Cloning a subgraph node keeps promoted widget entries on original and
clone`
- **Duplicate ID Remapping** (5 tests) — includes `Promoted widget
tuples are stable after full page reload boot path`.

The 4 tests that already existed on Main (added by #10849 and the
Vue-nodes legacy-prefixed block) are kept as-is.

## Adaptations to current APIs

- Imports reworked for the post-`@e2e/*` alias layout.
- Redundant `comfyPage.nextFrame()` calls dropped — `loadWorkflow` /
`loadGraphData` / `serializeAndReload` already wait internally (#11264).
- Alt-drag clone block wrapped in `try/finally` around
`keyboard.up('Alt')` to match the current `subgraphCrud.spec.ts`
pattern.
- `PromotedWidgetEntry` is now exported from
`browser_tests/helpers/promotedWidgets.ts` so the restored
`expectPromotedWidgetsToResolveToInteriorNodes` helper can type its
argument.

## Review follow-ups applied

- Use `expect.poll()` instead of `expect(async () => …).toPass()` for
the single-value snapshot comparison, per `browser_tests/AGENTS.md`.
- Capture and call `dispose()` from
`SubgraphHelper.collectConsoleWarnings()` inside a `try/finally` so the
console listener is unregistered after the test.

## Verification

- `pnpm typecheck:browser` — clean.
- `pnpm exec eslint` + `pnpm exec oxlint` on changed files — 0 warnings,
0 errors.
- `pnpm exec oxfmt` on changed files — applied (no diff).
- Ran 2 key restored tests against local ComfyUI + dev server:
- `Promoted widgets survive serialize -> loadGraphData round-trip` —
PASS (3.9s)
- `Multi-link input representative stays stable through save/reload` —
PASS (2.9s)
- Full-suite runs in this sandbox are blocked by the existing
`comfyPage` fixture's `createUser` path failing on repeat runs against
persistent backend state — unrelated to this PR. CI will exercise the
full suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11579-test-restore-deleted-subgraph-serialization-E2E-tests-34b6d73d365081f29b27c1069476ad17)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 21:31:59 +00:00
Simon Pinfold
3a05a37323 Fix naming strategy for multi-job asset exports (#11610)
## Summary

Use `group_by_job_time` for exports spanning multiple jobs while keeping
single-job exports on `preserve`, and add regression coverage for the
new naming-strategy behavior.

## Changes

- **What**: updated the asset export payload and request typing for the
new naming-strategy values, added unit coverage for single-job vs
multi-job export requests, added `@cloud` sidebar browser coverage for
export payloads, and adjusted the cloud Playwright setup helpers so
setup API calls can hit the backend directly and Firebase auth is seeded
on the app origin
- **Breaking**: none
- **Dependencies**: none

## Review Focus

Please sanity-check the cloud Playwright harness changes in `ComfyPage`
and `CloudAuthHelper`, plus the single-job vs multi-job export
naming-strategy assertions in the new browser tests.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11610-Fix-naming-strategy-for-multi-job-asset-exports-34c6d73d365081a68a88ea38d897578f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-27 20:44:32 +00:00
Dante
6cfa5fdb1f test: add unit tests for assetsStore (#11672)
## Summary

Extends `assetsStore.test.ts` with behavioral coverage for the
optimistic-update flows (`updateAssetMetadata`, `updateAssetTags`), the
per-asset deletion-state tracker (`setAssetDeleting` /
`isAssetDeleting`), the input-name resolution (`getInputName` /
`inputAssetsByFilename`), and the cloud-routing branch of
`updateInputs`.

## Changes

- **What**: Adds 8 Vitest cases across 4 new `describe` blocks
(`updateAssetMetadata optimistic cache`, `updateAssetTags diff-based
dispatch`, `setAssetDeleting / isAssetDeleting`, `getInputName`,
`updateInputs cloud routing`). Extends the shared `assetService` mock
with `updateAsset`, `addAssetTags`, and `removeAssetTags` (none of which
were previously stubbed).

## Review Focus

- Cloud-routed tests flip `mockIsCloud.value` inside a `try/finally` so
the existing default (`false`) is restored even if an assertion throws —
same pattern the existing Cloud describe block uses.
- The optimistic-cache rollback test silences the `console.error`
invoked by the catch branch so the test output stays clean.
- The `updateAssetTags` tests pin both the no-op short-circuit and the
add-only path. Remove-only and combined add+remove are already exercised
indirectly through the existing tag-cache invalidation tests.

## Testing

\`\`\`bash
pnpm exec vitest run src/stores/assetsStore.test.ts
pnpm format -- src/stores/assetsStore.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

47 tests pass (39 prior + 8 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11672-test-add-unit-tests-for-assetsStore-34f6d73d3650819f8e43e7154541baef)
by [Unito](https://www.unito.io)
2026-04-27 20:40:27 +00:00
Benjamin Lu
3ea75e1c48 fix: localize secret date labels (#11524)
## Summary

Localizes secret list date labels through Vue I18n date formatting
instead of the browser default numeric date format, with Storybook
coverage for design review.

## Changes

- **What**: Replaces `toLocaleDateString()` with `useI18n().d(..., {
dateStyle: 'medium' })` for `SecretListItem` dates.
- **What**: Updates `SecretListItem` expectations to match the Vue I18n
date formatter.
- **What**: Adds `SecretListItem` stories for default, never-used,
loading, and disabled states.
- **Dependencies**: None.

## Review Focus

Stacked on #11480 to keep the escaping fix scoped. Please confirm
whether the localized medium date style matches design/product
expectations.

## Screenshots (if applicable)


https://f3ba7229.comfy-storybook.pages.dev/?path=/story/platform-secrets-secretlistitem--default

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11524-fix-localize-secret-date-labels-3496d73d3650814bb70bfb3f870a31cd)
by [Unito](https://www.unito.io)
2026-04-27 13:11:31 -07:00
Alexander Brown
9c0edd0048 fix: prevent duplicate website-e2e CI runs on PRs (#11607)
## Summary

Fix duplicate `CI: Website E2E` workflow runs on pull requests.

## Problem

Two runs were triggered for every PR touching website files:
- `website-e2e (pull_request)` — from the PR event
- `website-e2e (push)` — from the push to a `website/*` branch

The concurrency key used `github.ref`, which evaluates differently for
push (`refs/heads/...`) vs pull_request (`refs/pull/N/merge`), so they
couldn't cancel each other.

## Changes

1. Scope `push` trigger to `main` only (removes `website/*`)
2. Use `github.head_ref || github.ref` in the concurrency group so push
and PR events for the same branch share a group

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11607-fix-prevent-duplicate-website-e2e-CI-runs-on-PRs-34c6d73d3650814c9d24c77b1591e94a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 19:55:05 +00:00
Benjamin Lu
7ee667c1d1 fix: avoid escaped secret date labels (#11480)
Global i18n config has escapeParameter as true. This explicitly turns it
to false. I opened a Linear ticket to reconsider changing this back to
false as default globally.

## Summary

Fix the Secrets panel so created and last-used dates render as plain
text instead of HTML-escaped slash entities.

## Changes

- **What**: Compute the Secrets date labels with `t(..., {
escapeParameter: false })` after formatting the date, so vue-i18n does
not escape `/` into `&#x2F;` for plain-text output.
- **What**: Replace the mocked translation setup in
`SecretListItem.test.ts` with a real `vue-i18n` instance and add a
regression test that asserts the rendered dates do not contain escaped
slash entities.

## Review Focus

This intentionally fixes the i18n interpolation issue shown in the bug
screenshot. It does not change the separate RFC3339Nano parsing behavior
discussed in #11358.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11480-fix-avoid-escaped-secret-date-labels-3486d73d365081c890ecd2a6992d7879)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:46:01 -07:00
Benjamin Lu
2b010ac8b3 fix: dedupe pending checkout attempt construction (#11622)
## Summary

Deduplicates pending subscription checkout attempt construction so
storage and fallback paths share the same payload creation.

## Changes

- **What**: Build the `PendingSubscriptionCheckoutAttempt` once in
`recordPendingSubscriptionCheckoutAttempt()` and reuse it for
unavailable-storage, failed-write, and successful-write paths.
- **Dependencies**: None.

## Review Focus

This is intended as a no-behavior-change cleanup: unavailable storage
still returns an attempt, failed `setItem()` still returns that attempt
without dispatching, and the pending-checkout event only fires after a
successful storage write.

Linear: FE-209

## Screenshots (if applicable)

N/A

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:28:18 -07:00
jaeone94
b4d209b5f6 feat: refresh missing models through pipeline (#11661)
## Summary

Follow-up to the closed earlier attempt in #11646. This PR keeps the
same user-facing goal, but changes the implementation to reuse the
existing missing model pipeline for refresh instead of maintaining a
separate candidate-only recheck path.

Adds a missing model refresh action in the Errors tab by reusing the
existing missing model pipeline, so users can re-check models after
downloading or manually placing files without reloading the workflow.

## Changes

- **What**:
- Adds `app.refreshMissingModels()` as a reusable refresh entry point
for the current root graph.
- Splits node definition reloading into `app.reloadNodeDefs()` so
missing-model refresh can pull fresh `object_info` without showing the
generic combo refresh success flow.
- Reuses the existing missing model pipeline instead of adding a
separate candidate-only checker. The refresh path serializes the current
graph, reuses active workflow model metadata when available, falls back
to current missing-model metadata, and then reruns the same candidate
discovery/enrichment/surfacing flow used during workflow load.
- Adds missing model refresh state and error handling to
`missingModelStore`.
- Adds a Refresh button next to Download all in the missing model card
action bar.
- Moves Download all from the Errors tab header into the missing model
card, so the Download all and Refresh actions render or hide together.
- Changes Download all visibility from “more than one downloadable
model” to “at least one downloadable model.”
- Keeps the action bar hidden when there are no downloadable missing
models; Cloud still does not render this action area.
- Normalizes active workflow `pendingWarnings` updates so resolved
missing model warnings do not get revived by stale empty warning
objects.
- Adds test IDs and coverage for the new action bar, refresh state,
refresh delegation, pending warning sync, and E2E refresh behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The main design choice is intentionally reusing the missing model
pipeline for refresh instead of implementing a smaller candidate-only
recheck.

The earlier candidate-only approach was cheaper, but it created a
separate source of truth for missing-model resolution and made edge
cases harder to reason about. In particular, it could diverge from the
behavior used when a workflow is loaded, and it did not naturally handle
the case where a model becomes missing after the workflow is already
open. This version pays the cost of refreshing node definitions and
rerunning the missing-model scan for the current graph, but keeps the
refresh behavior aligned with workflow load semantics.

Expected behavior by environment:

- OSS browser:
- The action bar appears when at least one missing model has a
downloadable URL and directory.
  - Download all uses the existing browser download path.
- Refresh reloads `object_info`, refreshes node definitions/combo
values, reruns missing-model detection for the current graph, and clears
the error if the selected model is now available.
- OSS desktop:
- The same action bar appears under the same downloadable-model
condition.
  - Download all uses the existing Electron DownloadManager path.
- Refresh uses the same missing-model pipeline as browser, so manually
placed files or desktop-downloaded files can be rechecked without
reloading the workflow.
- Cloud:
- The action bar remains hidden because model download/import is not
supported in this section for Cloud.

A few boundaries are intentional:

- This PR does not add automatic filesystem watching. Browser OSS cannot
reliably observe local model folder changes, so the user-triggered
Refresh button remains the cross-environment mechanism.
- This PR does not redesign the public `refreshComboInNodes` API beyond
extracting `reloadNodeDefs()` for reuse. Further cleanup of toast
behavior or a more explicit object-info reload API can be follow-up
work.
- This PR keeps refresh scoped to missing-model validation; missing
media and missing nodes continue to use their existing flows.

Linear: FE-417

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772


## Validation

- `pnpm format`
- `pnpm lint` (passes; existing unrelated warning remains in
`src/platform/workspace/composables/useWorkspaceBilling.test.ts`)
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`
- `pnpm build`
- `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true
NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build`
- Manual desktop verification through `~/Projects/desktop` after copying
the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`:
  - confirmed the FE bundle is built with `DISTRIBUTION = "desktop"`
- confirmed missing model Download uses the desktop download path
instead of browser download
- confirmed Refresh can clear the missing model error after the model is
available
- Push hook: `pnpm knip --cache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667)
by [Unito](https://www.unito.io)
2026-04-27 18:53:50 +00:00
Christian Byrne
9a70676c61 [chore] Update Comfy Registry API types from comfy-api@c82adf6 (#11689)
## Automated API Type Update

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

- API commit: c82adf6
- Generated on: 2026-04-25T22:16:06Z

These types are automatically generated using openapi-typescript.

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

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-27 18:41:46 +00:00
Christian Byrne
9ce4c18eb1 test: add unit tests for useGLSLRenderer (#11390)
## Summary

Adds 44 unit tests for the `useGLSLRenderer` composable
(`src/renderer/glsl/useGLSLRenderer.ts`), increasing coverage from ~15%
to near-complete line coverage.

## Test Coverage

| Describe Block | Tests | What's Covered |
|---|---|---|
| `init` | 6 | Context creation, FBO setup, `UNPACK_FLIP_Y_WEBGL`, FBO
failure cleanup, post-dispose guard |
| `compileFragment` | 9 | Shader compile success/failure, program link
errors, program re-creation, dispose guard |
| `setResolution` | 4 | Viewport + FBO recreation on resize, no-op on
same size, dispose guard |
| `setFloatUniform` | 2 | Float uniform dispatch, dispose guard |
| `setIntUniform` | 2 | Int uniform dispatch, dispose guard |
| `bindInputImage` | 4 | Texture creation/binding, out-of-range index,
re-bind cleanup, dispose guard |
| `render` | 7 | Draw call, `u_resolution`, fallback textures, final
pass to default FBO, multi-pass ping-pong, dispose/no-program guards |
| `readPixels` | 2 | Returns `ImageData`, throws when uninitialized |
| `toBlob` | 2 | Returns `Blob`, throws when uninitialized |
| `dispose` | 4 | GL resource cleanup, idempotency, `onScopeDispose`,
input texture cleanup |
| `config` | 2 | Default vs custom uniform/input counts |

## Approach

- Mocks `WebGL2RenderingContext`, `OffscreenCanvas`, and `ImageData`
(happy-dom has no WebGL)
- Uses Vue `effectScope` to test `onScopeDispose` cleanup
- Mocks `detectPassCount` from `glslUtils` for multi-pass tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11390-test-add-unit-tests-for-useGLSLRenderer-3476d73d3650815a8787cd9368fd8baf)
by [Unito](https://www.unito.io)
2026-04-27 10:45:37 -04:00
Christian Byrne
56aec1878a test: add unit tests for GPUBrushRenderer (#11388)
## Summary

Add unit tests for `GPUBrushRenderer`, increasing coverage from ~3.5% to
cover constructor initialization, stroke rendering, compositing, preview
blitting, readback, and resource cleanup.

## Changes

- **What**: 27 unit tests for `GPUBrushRenderer` covering all public
methods with comprehensive WebGPU API mocks

## Review Focus

Mock factory approach for WebGPU objects — all GPU globals
(`GPUBufferUsage`, `GPUTextureUsage`, `GPUShaderStage`) are polyfilled
since happy-dom lacks WebGPU support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11388-test-add-unit-tests-for-GPUBrushRenderer-3476d73d3650814ab0e2c0fb8c424faa)
by [Unito](https://www.unito.io)
2026-04-27 10:34:33 -04:00
Dante
502a02213a test: add unit tests for useWorkspaceBilling polling and refresh paths (#11676)
## Summary

Extends `useWorkspaceBilling.test.ts` with five behavioral gaps in the
existing suite: `initialize` does not double-fetch balance when free
tier already has positive balance, `subscribe(planSlug)` forwards
`undefined` for return/cancel URLs, `previewSubscribe` does not refresh
status or balance after success, `pollCancelStatus` falls back to a
default error message when failed status omits `error_message`, and
`pollCancelStatus` halts further scheduled polls when a later poll's API
call rejects.

## Changes

- **What**: Adds 5 Vitest cases across four new `describe` blocks
(`initialize free-tier balance refresh`, `subscribe argument
forwarding`, `previewSubscribe does not refresh state`,
`pollCancelStatus error paths`). Reuses the existing `setupBilling()`
factory and `effectScope` lifecycle.

## Review Focus

- The mid-poll rejection test silences `unhandledRejection` for its
duration: `pollCancelStatus`'s rescheduled poll runs through `void
poll()` inside `setTimeout`, so the catch block's rethrow has no awaiter
and surfaces as unhandled. The test pins the observable behavior (no
further scheduled polls) without claiming `cancelSubscription`
propagates the late error.
- The `subscribe(planSlug)` test pins the call shape with explicit
`undefined` arguments so a future signature change breaks the test
rather than silently passing through.
- The free-tier branch is targeted: previously only the zero-balance
reload was tested; this adds the negative case (positive balance → no
second fetch).

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm format --
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

38 tests pass (33 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11676-test-add-unit-tests-for-useWorkspaceBilling-polling-and-refresh-paths-34f6d73d36508197bc7bc66d54e805e0)
by [Unito](https://www.unito.io)
2026-04-27 10:25:54 -04:00
Dante
f1ea3b02a6 test: add unit tests for useNodeReplacement transfer edge cases (#11677)
## Summary

Extends `useNodeReplacement.test.ts` with five connection-transfer and
graph-mutation edge cases that the existing 23-case suite did not cover:
missing-old-input-slot skip, missing-new-output-index resilience,
set_value on a non-existent widget, set_value with dot-notation new_id,
and the Vue-node refresh path via `nodeGraph.onNodeAdded`.

## Changes

- **What**: Adds 5 Vitest cases in a new `transfer edge cases` describe
block. Reuses the existing `createPlaceholderNode`, `createNewNode`,
`createMockGraph`, `createMockLink`, and `makeMissingNodeType` helpers —
no new test infrastructure introduced.

## Review Focus

- The "missing new output index" test verifies that `replaceWithMapping`
does not throw when `newNode.outputs[newOutputIdx]` is absent, and
asserts the original link's `origin_id` is unchanged so the silent-skip
behavior is pinned (not a swallowed exception).
- The dot-notation `set_value` test pins that the existing dot-notation
guard at `useNodeReplacement.ts:203` covers the `set_value` branch (not
just the `old_id` connection branch already covered at line 187).
- The `onNodeAdded` test asserts the Vue-node sync path that runs after
`replaceWithMapping` bypasses `graph.add()` — a future refactor that
drops the explicit call would silently break the Vue node renderer
otherwise.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm format -- src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

28 tests pass (23 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11677-test-add-unit-tests-for-useNodeReplacement-transfer-edge-cases-34f6d73d3650817aa2ffccdb9fb4a947)
by [Unito](https://www.unito.io)
2026-04-27 10:25:26 -04:00
Christian Byrne
b2bba78ce0 test: add unit tests for usePanAndZoom composable (#11391)
## Summary

Add 29 unit tests for the `usePanAndZoom` composable to improve mask
editor test coverage.

## Changes

- **What**: New test file
`src/composables/maskeditor/usePanAndZoom.test.ts` covering all public
API methods

## Review Focus

Test coverage spans:
- `initializeCanvasPanZoom` — landscape/portrait fit-to-view, panel
width accounting, style application
- `handlePanStart`/`handlePanMove` — panning state, offset updates,
error on missing start
- `zoom` — wheel in/out, clamping (0.2–10.0), missing canvas early
return, cursor update
- `updateCursorPosition` — pan offset application
- `invalidatePanZoom` — missing image warning, store container fallback,
rgbCanvas dimension sync
- Touch handlers — single/two-finger start, double-tap undo, pen
blocking, single-touch pan, pinch zoom, touch end states
- `addPenPointerId`/`removePenPointerId` — add, dedup, remove, no-op

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

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-27 10:24:44 -04:00
Dante
3d14bfb09c fix: render asset fixtures in AssetBrowserModal stories (#11502)
## Why this fix exists

Surfaced as a dead-end during the FE-227 (asset modal scroll breakage in
cloud-prod) root-cause investigation, **not** as a standalone Storybook
complaint.

The natural local repro path for FE-227 is "bump an `AssetBrowserModal`
Storybook story to ~120 assets and watch the layout misbehave." When
that path was attempted, the stories rendered empty modals regardless of
fixture size. A Codex adversarial review confirmed the cause: three
stories bind `:assets="..."` to a prop the component never declared, so
the binding is silently dropped and the modal falls back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook.

The empty-modal failure mode also silently broke design QA / visual
review on this surface: any reviewer opening these stories has been
seeing "No assets found" for as long as the bug has existed.

Filing this as its own PR so:
- FE-227 stays focused on the cloud-prod scroll bug once a DevTools
datapoint confirms the hypothesis.
- The local repro path for FE-227 (and any future asset-modal layout
regression) becomes usable.
- Visual review on `AssetBrowserModal` is restored.

## What changed

Three `AssetBrowserModal` stories bound `:assets="..."` to a
non-existent prop, so the modal silently fell back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook because the model cache only initializes in cloud distribution
builds. Add an optional `assets` prop on `AssetBrowserModal` that, when
provided, bypasses the store fetch. Production callers continue to use
the store; this is a narrowly scoped Storybook/test seam.

- Fixes FE-232

### Why a prop on the component (Option 1) and not a Storybook decorator
(Option 2)

`assetsStore`'s model cache (`getModelState`) is gated by `if
(isCloud)`, returning an empty stub for desktop/localhost distributions.
Storybook's `.storybook/main.ts` does not define `__DISTRIBUTION__`, so
`isCloud === false` and the store has no public API to seed assets.
Public seed methods (`updateModelsForNodeType` / `updateModelsForTag`)
only delegate to `assetService` network calls. Option 2 (decorator-based
seeding) would require either patching Storybook's Vite `define` config
or building a parallel mock store via `resolve.alias` (the `useJobList`
precedent) — significantly more invasive than a +10 / -1 line component
change. The new prop is documented as a Storybook/test seam in JSDoc and
changes nothing for production callers.

### Bonus

`useAssetBrowserDialog.stories.ts:120` had the **same** broken
`:assets="mockAssets"` binding. The new prop transparently repairs it
without a separate change.

## Before

All three stories render an empty modal (`No assets found`) regardless
of the fixture data they pass.

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `before-default.png` → Default story
> - `before-single-asset-type.png` → Single asset type story
> - `before-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
| Default | |
<img width="961" height="821" alt="before-default"
src="https://github.com/user-attachments/assets/4a0af0f5-b712-41e2-adbc-c2b4b921045d"
/>

| Single asset type | |
<img width="961" height="821" alt="before-single-asset-type"
src="https://github.com/user-attachments/assets/073a8fa8-7bbb-4ec9-a226-156b7141d9b5"
/>

| No left panel | <img width="961" height="821"
alt="before-no-left-panel"
src="https://github.com/user-attachments/assets/0d45ff3b-5866-4de9-b7aa-5bd9cb1f3566"
/> |

## After

All three stories now render their intended fixture data (asset cards
visible with mock model names, badges, sort/filter controls populated).

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `after-default.png` → Default story
> - `after-single-asset-type.png` → Single asset type story
> - `after-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
<img width="961" height="821" alt="after-default"
src="https://github.com/user-attachments/assets/a11b2475-bd18-4c30-aece-cf1bdbcc6ac5"
<img width="961" height="821" alt="after-single-asset-type"
src="https://github.com/user-attachments/assets/71e11237-006b-43d9-90de-e9d2d8894e34"
/>
 />

| Default |  |
| Single asset type |   |
| No left panel | <img width="961" height="821"
alt="after-no-left-panel"
src="https://github.com/user-attachments/assets/5123db87-2ab9-4359-8e61-ac0d8da9494c"
/>
  |


## Test plan

- [x] Storybook stories now render fixture data (manually verified all
three via Chrome DevTools MCP)
- [x] `pnpm typecheck` passes on touched files
- [x] `pnpm lint` passes on touched files
- [x] Existing `AssetBrowserModal.test.ts` (14 tests) still passes
- [x] `useAssetBrowserDialog.stories.ts` is also functional (same bug
pattern, repaired by the new prop)
- [ ] No new prop surface added to `AssetBrowserModal` other than the
documented Storybook/test seam (`assets?: AssetItem[]`)
2026-04-27 11:30:59 +00:00
Dante
3340b77908 test: add unit tests for value-control widget family (#11440)
## Summary

Adds 42 unit tests across 5 files covering the value-control widget
family — first batch of a broader effort to raise widget-test coverage.

## Changes

- **What**:
- `WidgetInputNumber.test.ts` (9) — variant selection by widget.type
(int/float/slider/gradientslider), controlWidget wrapping in
WidgetWithControl, modelValue forwarding.
- `WidgetInputNumberGradientSlider.test.ts` (11) — initial value,
min/max/disabled pass-through, default vs custom gradient stops,
precision-derived step, WidgetLayoutField wrapping.
- `WidgetWithControl.test.ts` (5) — renders passed component with
widget/modelValue, initializes ValueControlButton mode from
widget.controlWidget.value, calls controlWidget.update on mode change.
- `ValueControlButton.test.ts` (11) — i18n aria-label per mode, text vs
icon rendering, pointer and keyboard activation, `type="button"` safety.
- `ValueControlPopover.test.ts` (6) — BEFORE/AFTER copy from
settingStore, four option render, v-model updates on selection.

## Review Focus

- Stack follows the existing widget-test pattern (`@testing-library/vue`
+ PrimeVue + `createI18n` where needed, no `@vue/test-utils`).
- `createMockWidget` from `widgetTestUtils.ts` reused; no new helper
extracted (YAGNI — per-file `renderComponent` stays ~10 lines).
- `WidgetWithControl` watcher test asserts first-arg of `update` since
the vue watch callback passes `(newVal, oldVal, onCleanup)`.
- No changes to any widget component source — tests-only PR.

This is one of several focused PRs in a widget-test-coverage sequence;
subsequent PRs cover form-dropdown internals, utility widgets,
media/graph/canvas widgets, and e2e value-type specs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11440-test-add-unit-tests-for-value-control-widget-family-3486d73d3650813891e1fe8d45eaecaf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-27 20:32:31 +09:00
Dante
0ad85087ea test: add unit tests for useMissingModelInteractions (#11675)
## Summary

Extends `useMissingModelInteractions.test.ts` with behavioral coverage
for the previously untested public surface: `getComboOptions` (both
asset-supported and widget-driven branches), `getDownloadStatus`, and
the four `handleImport` outcomes (async-pending, async-completed,
sync-with-mismatch, error).

## Changes

- **What**: Adds 10 Vitest cases across three new `describe` blocks
(`getComboOptions`, `getDownloadStatus`, `handleImport`). Extends the
existing module-level mocks with `mockUploadAssetAsync`,
`mockTrackDownload`, and `mockInvalidateModelsForCategory` so the import
flow can be verified at its boundaries.

## Review Focus

- The `handleImport` block uses a shared `setupImportableState(key)`
helper to seed `urlInputs` + `urlMetadata` and stub `validateSourceUrl`
once per test. Each case then asserts a single boundary effect (taskId
tracked, cache invalidated, mismatch recorded, error stored).
- The `getDownloadStatus` happy path relies on the existing getter-style
`mockDownloadList` so the test's mutation lands in the composable
without re-stubbing the asset download store.
- The `getComboOptions` "asset-supported" test asserts both the call
shape (`mockGetAssets` invoked with the candidate's `nodeType`) and the
output shape, so a future refactor that swaps the lookup key fails
loudly.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm format --
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

44 tests pass (34 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11675-test-add-unit-tests-for-useMissingModelInteractions-34f6d73d36508112909fe8e49cc68010)
by [Unito](https://www.unito.io)
2026-04-27 06:21:49 -04:00
pythongosssss
4c892341e4 feat: Node search UX updates (#9714)
## Summary

Addresses feedback from the initial v2 node search implementation for
improved UI and UX

## Changes

- **What**: 
- add root filter buttons
- remove all extra tree categories leaving only "Most relevant"
- replace input/output selection with popover
- replace price badge with one from node header
- add chevrons and additional styling to category tree
- hide empty categories
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- keyboard navigation
- general tidy/refactor/test

## Screenshots (if applicable)

https://github.com/user-attachments/assets/db798dfa-e248-4b48-bb56-2fa7b6c5f65f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9714-feat-Node-search-UX-updates-31f6d73d365081cebd96c4253ad1ca53)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:47:47 +00:00
Christian Byrne
eb8f8b75b5 fix: correct zh-CN translations across landing, product, and pricing pages (#11655)
*PR Created by the Glary-Bot Agent*

---

## Summary

Applies translation corrections to the Chinese (zh-CN) version of the
website, requested in the `#project-new-website-brand-refresh` Slack
thread by a native-speaker reviewer.

### Changes (in `apps/website/src/i18n/translations.ts` +
`apps/website/src/pages/zh-CN/index.astro`)

- **Landing — blog section badges**: `如何` → `了解`, `运作` → `运行方式`
- **Landing — hero headline**: `专业控制` → `最强可控性` (also updates the zh-CN
page `<title>`)
- **Landing — hero subtitle**: `Comfy 是面向视觉专业人士的 AI
创作引擎,让您掌控每个模型、每个参数和每个输出。` → `Comfy 是面向专业视觉人士的 AI
创作引擎。您可以精确掌控每个模型、每个参数和每个输出。`
- **Landing — showcase subtitle**: `从社区模板开始,或从零构建。` → `从工作流模板开始,或从零构建。`
- **Landing — showcase feature1 title**: `节点式完全控制` → `节点带来的可控性`
- **Landing — use case body**: `由 60,000+ 节点、数千个工作流 和一个比任何公司都更快构建的社区驱动。`
→ `60,000+ 节点,数千条工作流,一个比任何公司速度都更快的社区。`
- **All `查看xx特性` CTAs** (local / cloud / API / enterprise product
cards): `特性` → `属性`
- **All `合作节点` pricing copy**: `合作节点` → `合作伙伴节点` (correct translation
for "partner node")

### Verification

- `pnpm typecheck` (astro check): 0 errors, 0 warnings, 0 hints
- `pnpm build`: completes successfully; rebuilt zh-CN pages contain the
new strings and no longer contain the old ones
- Manual Playwright verification on `/zh-CN/` (hero, showcase badges,
showcase feature card, use-case body, product cards) and
`/zh-CN/cloud/pricing/` (`合作伙伴节点` appears 4×, `合作节点` appears 0×)

### Notes on review feedback

A review pass flagged two of the swaps (`特性→属性` and `从社区模板→从工作流模板`) as
potential style concerns. Both changes are kept as-is because they were
specified verbatim by the native-speaker reviewer who requested this
pass.

## Screenshots

![zh-CN landing hero showing new headline and rewritten
subtitle](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526109-4a011f24-da46-4b6b-ba01-bce9cc8aec63.png)

![zh-CN showcase section showing updated badges, workflow template
subtitle, and node controllability feature
card](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526507-3054b3d7-0950-4c09-8edc-643c913ca1dc.png)

![zh-CN use-case section showing reworded 60,000+ node
copy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526822-772cd907-0a70-441b-ac0c-bef2d1eb0f9f.png)

![zh-CN product cards showing updated
CTAs](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246527170-d1969308-5e6c-4d8f-bfee-24e31b0d0670.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11655-fix-correct-zh-CN-translations-across-landing-product-and-pricing-pages-34e6d73d3650816daddde4a90fb47a01)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 06:18:47 +00:00
Dante
c594e30b84 test: harden useKeyboard test setup with vi.hoisted and try/finally (#11659)
## Summary
- Move `mockCanvasHistory` / `mockStore` into `vi.hoisted()` so the mock
state is hoisted before module imports, matching the pattern in
`useCanvasTransform.test.ts`.
- Wrap the temporary `document.activeElement` override in `try/finally`
so the property is restored even if the assertion throws, preventing
state leak into subsequent tests.

- Fixes #11658

## Test plan
- [x] `pnpm test:unit src/composables/maskeditor/useKeyboard.test.ts` —
17/17 pass
- [x] `pnpm typecheck`
- [x] `pnpm lint` (no new warnings)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11659-test-harden-useKeyboard-test-setup-with-vi-hoisted-and-try-finally-34f6d73d36508139be2ddc3095ea6952)
by [Unito](https://www.unito.io)
2026-04-27 03:26:33 +00:00
Dante
2ade779a81 fix: use getAssetFilename in asset browser to avoid showing hashes (#11492)
## Root cause
Three surfaces rendered `asset.name` directly even though in Cloud
production `asset.name` often equals `asset.asset_hash`:

1. **Asset browser modal** (`AssetCard.vue`) used `getAssetDisplayName`,
which — when `user_metadata.name` also held the hash — fell through to
the raw hash.
2. **Load Image node widget dropdown** (`useWidgetSelectItems.ts`)
rendered output items as `${asset.name} [output]`.
3. Queue-mapped output assets (`mapTaskOutputToAssetItem`) populate the
human-readable filename only on `asset.display_name`, not on
`user_metadata.filename` / `metadata.filename`. So surfaces that rely
only on `getAssetFilename` still fall through to `asset.name` (the
hash).

Filename / title resolution is now split into three helpers with
distinct responsibilities:

- `getAssetFilename` (unchanged) — canonical filename for serialization
/ identifier use (workflow widget values, schema validation,
missing-model matching). Never substitutes a display-only string.
- `getAssetDisplayFilename` (new) — filename-first label for surfaces
that render a filename. Adds `asset.display_name` as a fallback before
`asset.name`. Used by the Load Image output dropdown.
- `getAssetCardTitle` (new) — card title / delete-dialog label. Prefers
`user_metadata.name` / `metadata.name` when distinct from `asset.name`
(preserves user-renamed model titles from `ModelInfoPanel`), and falls
through to `getAssetDisplayFilename` for the Cloud hash case.

The dropdown item's `name` field (workflow payload value) is still
`${asset.name} [output]`, so Cloud can continue to resolve the asset by
hash.

Fixes FE-228

Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

## Red / green verification
| Step | SHA | Purpose |
| --- | --- | --- |
| Red (card — metadata.filename) | `20b32e4f0` | `AssetCard.test.ts`
asserts the rendered title is the human-readable filename when
`asset.name === asset_hash`. |
| Green (card) | `ea889b34c` | `AssetCard.vue` switches from
`getAssetDisplayName` to `getAssetFilename`. |
| Red (widget — metadata.filename) | `318feddec` | Asserts the output
dropdown `label` uses `metadata.filename` when `asset.name` is a hash. |
| Green (widget) | `7b19bde15` | `useWidgetSelectItems.ts` derives
`label` from `getAssetFilename`; `name` (serialized) stays
`\${asset.name} [output]`. |
| Red (widget — display_name path) | `b19716e60` | Failing test for the
queue-mapped shape (`display_name` populated, `user_metadata.filename`
absent). |
| Green (util, reverted) | `533e60d6a` | Initial attempt broadened
`getAssetFilename` to include `display_name`; altered filename semantics
for model-asset consumers. |
| Refactor (scope narrowing) | `7c1085f30` | Reverts the util change;
applies `display_name` fallback locally in the output dropdown only. |
| Red (card — display_name path) | `38a9d4828` | Failing test: AssetCard
must fall back to `display_name` when filename metadata is absent. |
| Green (helper split) | `4ca0f620f` | Introduces
`getAssetDisplayFilename`; swaps AssetCard + widget dropdown to use it.
Adds helper unit tests. |
| Red (card — preserves curated name) | `dc2e9231d` | Failing test: a
user-curated `user_metadata.name` distinct from `asset.name` must win
over the filename; plus non-regression guard that
curated-name-equal-to-hash still falls back to filename. |
| Green (card title helper) | `5decf3a2b` | Adds `getAssetCardTitle`
(curated name when distinct, else `getAssetDisplayFilename`). AssetCard
title + delete-dialog swap to it; widget dropdown stays on
`getAssetDisplayFilename`. |

Side-effect audit (`getAssetFilename` still canonical):
- `createModelNodeFromAsset.ts`, `createAssetWidget.ts`,
`missingModelScan.ts`, `useComboWidget.ts`, `useWidgetSelectItems.ts`
asset-mode `name` — all still resolve through `getAssetFilename`, so
model-asset widget serialization and missing-model matching are
unaffected.

## Screenshots

### As is — asset browser card

<img width="1269" height="899" alt="Screenshot 2026-04-21 at 10 49 41
AM"
src="https://github.com/user-attachments/assets/7cffb585-4e64-4037-8bb1-5dd40215597e"
/>

### To be

<img width="1145" height="533" alt="Screenshot 2026-04-25 at 7 25 17 PM"
src="https://github.com/user-attachments/assets/8f12388a-16df-4892-83b4-c8d1f033f190"
/>


_Verified locally via `pnpm dev:cloud`: asset browser cards preserve
user-curated display names, Cloud-hash cases render the filename, and
the Load Image output dropdown also renders the human-readable filename.
The selected value still serializes the hash path._

## Test plan
- [ ] \`pnpm test:unit -- AssetCard.test\` passes (4 cases:
metadata.filename, display_name fallback, curated-name preservation,
curated-name==hash fallback)
- [ ] \`pnpm test:unit -- assetMetadataUtils.test\` passes (53 cases
incl. new helper coverage)
- [ ] \`pnpm test:unit -- useWidgetSelectItems.test\` passes (26 cases)
- [ ] \`pnpm test:unit -- useMissingModelInteractions.test\` passes
(guards against model-asset regressions)
- [ ] Asset browser modal: user-renamed model shows curated name; Cloud
hash outputs show filename
- [ ] Load Image node → widget dropdown → Outputs tab shows original
filenames
- [ ] Delete-confirm dialog references the same curated name as the card
title
- [ ] Model-asset widgets (Checkpoint / LoRA / etc.) still serialize the
model filename
- [ ] Submitting a workflow that references a Cloud output still
executes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11492-fix-use-getAssetFilename-in-asset-browser-to-avoid-showing-hashes-3496d73d36508148b8a3fb0482fa668e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 03:16:18 +00:00
Dante
25f493bd30 test(assets): add E2E spec for sort options (#11634)
## Summary

Adds 6 @cloud-tagged Playwright tests covering the assets sidebar sort
menu (newest/oldest/longest/fastest). Phase 4 of the assets-test plan.

**Stacked on #11632** — base will retarget to \`main\` once the
foundation PR merges. Independent of #11633 (filter E2E) and can be
reviewed in parallel.

## Changes

- **What**: New file `browser_tests/tests/sidebar/assets-sort.spec.ts`.
Uses the `createJobsWithExecutionTimes()` factory and the
`sortLongestFirst` / `sortFastestFirst` locators added in #11632.
- **Coverage**:
  - Settings menu exposes all four sort options in cloud mode
  - Default order is newest first (descending `create_time`)
  - "Oldest first" reverses the order
  - "Longest first" puts the slowest execution at the top
  - "Fastest first" puts the quickest execution at the top
  - Sort persists across search-input edits
- **Breaking**: none

## Review Focus

- **Misaligned (create_time, duration) axes**: fixture data is
deliberately constructed so newest/oldest and longest/fastest produce
distinct orderings — no test can false-pass by satisfying a different
sort. See the table comment at the top of the spec.
- **`@cloud` tag is required**: sort options are gated behind
`:show-sort-options="isCloud"`, which depends on the compile-time
`__DISTRIBUTION__` flag. Tests run only against the `cloud` Playwright
project.
- **Local verification needed**: maintainer should verify with `pnpm
dev:cloud` + `pnpm test:browser:local --project cloud --grep "sort
options"` before merging — I could not run the cloud dev server
end-to-end in my environment.

Fixes #10779

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11634-test-assets-add-E2E-spec-for-sort-options-34e6d73d365081a79facde5bde2e18c6)
by [Unito](https://www.unito.io)
2026-04-27 12:26:08 +09:00
Comfy Org PR Bot
b8b5e1ec1f 1.44.11 (#11656)
Patch version increment to 1.44.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11656-1-44-11-34f6d73d3650812aa813ee9efb11dced)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-27 02:14:50 +00:00
Terry Jia
59ef69f355 test: add unit tests for useCoordinateTransform mask editor composable (#11640)
## Summary

Add unit tests for `useCoordinateTransform` mask editor composable,
raising coverage from 2.43% to 100% (statements / branches / functions /
lines).

## Changes

- **What**: Add
`src/composables/maskeditor/useCoordinateTransform.test.ts` (14 tests)
covering both `screenToCanvas` and `canvasToScreen`: identity (display
matches bitmap), uniform downscale (bitmap larger than display),
`pointerZone`-vs-`canvasContainer` offset, non-uniform per-axis scaling,
screen↔canvas round-trip, and the three "element missing" branches
(`pointerZone` / `canvasContainer` / `maskCanvas` null) that should warn
and return `{x:0,y:0}`.

## Review Focus

- Mocked `createSharedComposable` to a pass-through so each test gets a
fresh transform reading the latest `mockStore` refs (otherwise the
shared instance captures stale element references between tests).
- DOM rects are stubbed via `vi.spyOn(el, 'getBoundingClientRect')`
rather than constructing fake DOMRects, so `unref(...)` in the
composable still receives a real `HTMLElement` / `HTMLCanvasElement`.
- Round-trip test (`screenToCanvas` → `canvasToScreen`) verifies the two
functions are mathematical inverses under the offset + scale
combination, which is the actual invariant the rest of the editor relies
on.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by public method, explicit `MockStore` type alias, helper
factories `createElementWithRect` / `createCanvasWithRect`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11640-test-add-unit-tests-for-useCoordinateTransform-mask-editor-composable-34e6d73d3650814d95bdef66e36328e8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:41 -04:00
Terry Jia
9ad052467d test: add unit tests for useKeyboard mask editor composable (#11639)
## Summary

Add unit tests for `useKeyboard` mask editor composable, raising
coverage from 0% to 100% (statements/lines/functions, 95.65% branch).

## Changes

- **What**: Add `src/composables/maskeditor/useKeyboard.test.ts` (17
tests) covering key tracking (`isKeyDown`), space-key
blur/preventDefault, undo/redo shortcuts (Ctrl/Meta+Z, Ctrl+Shift+Z,
Ctrl+Y), modifier-key edge cases (Alt suppression, no-modifier no-op,
Ctrl+Shift+Y ignored), `window blur` clearing keys, and listener
teardown via `removeListeners`.

## Review Focus

- Mock surface is intentionally minimal — only `useMaskEditorStore` is
mocked because the composable only reaches
`store.canvasHistory.{undo,redo}`.
- `afterEach(keyboard.removeListeners)` is required: the composable
attaches listeners to `document` / `window`, so without teardown earlier
test instances leak handlers and inflate mock call counts in later
tests.
- Tests dispatch real `KeyboardEvent`s via `document.dispatchEvent`
rather than calling the internal handlers directly, so they exercise the
actual `addEventListener` wiring.
- Test style aligned with existing mask editor tests: `should ...`
naming, `describe` grouped by public method, explicit `MockStore` /
`MockCanvasHistory` type aliases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11639-test-add-unit-tests-for-useKeyboard-mask-editor-composable-34e6d73d36508129b437d0270d9424d8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:19 -04:00
Dante
aa730c8cb5 test(assets): add unit tests and E2E fixture groundwork for sidebar filter & sort (#11632)
## Summary

Lays the test foundation for the two open assets-testing issues — Phase
1 (fixtures + page object) and Phase 2 (unit tests). Phase 3/4 (the
actual E2E specs) follow in stacked PRs.

## Changes

- **What**: 27 new unit tests covering `useMediaAssetFiltering`,
`MediaAssetFilterMenu`, and `MediaAssetSettingsMenu`; reusable
Playwright fixture factories for diverse media kinds and execution-time
specs; new locators + helpers on `AssetsSidebarTab` for the filter menu
and longest/fastest sort options. No production code touched.
- **Breaking**: none

### New files
- `src/platform/assets/composables/useMediaAssetFiltering.test.ts` — 11
cases: single/multi-OR media-type filter, `'3D'` → `'3d'` filename
normalization, exclusion of unsupported kinds, all four sort modes,
`created_at` fallback when `user_metadata.create_time` is absent,
filter+sort composition.
- `src/platform/assets/components/MediaAssetFilterMenu.test.ts` — 6
cases: checkbox rendering, prop-driven `aria-checked`, click toggling
(add/remove/append), keyboard activation (Enter/Space).
- `src/platform/assets/components/MediaAssetSettingsMenu.test.ts` — 10
cases: view-mode v-model, `showSortOptions` and `showGenerationTimeSort`
visibility gates, `v-model:sortBy` round-trip for
newest/oldest/longest/fastest.

### Extended files
- `browser_tests/fixtures/helpers/AssetsHelper.ts` — added
`MediaKindFixture` type, optional `mediaKind` shorthand on
`createMockJob` (sets both filename extension and
`preview_output.mediaType`), plus `createMixedMediaJobs(kinds)` and
`createJobsWithExecutionTimes(specs)` factories for unambiguous
filter/sort assertions.
- `browser_tests/fixtures/components/SidebarTab.ts` — added
`filterButton`, per-type checkbox locators
(`filterImage/Video/Audio/3DCheckbox`), `sortLongestFirst`,
`sortFastestFirst`, plus `openFilterMenu()`, `filterCheckbox(kind)`,
`toggleMediaTypeFilter(kind)`, and `getAssetCardOrder()` helpers.

## Review Focus

- **Naming of `MediaKindFixture` values** — `'images'` is plural to
match existing API conventions emitted by the backend /
`useMediaAssetGalleryStore`; `'video' | 'audio' | '3D'` follow the
singular `MediaKind` type. Open to renaming if a unified shape is
preferred.
- **Filter button locator strategy** — `MediaAssetFilterButton` has no
`aria-label`, so the page object targets it via the
`icon-[lucide--list-filter]` class. Happy to add a `data-testid` or
`aria-label` to the source component if reviewers prefer a more durable
hook (would be a one-line source change in a follow-up).

## Follow-up PRs

- Phase 3 (E2E for media-type filter) → closes #10780
- Phase 4 (E2E for asset sort) → closes #10779

Both are stacked on this branch and can be reviewed/merged in either
order once this lands.

References #10779, #10780.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11632-test-assets-add-unit-tests-and-E2E-fixture-groundwork-for-sidebar-filter-sort-34e6d73d3650815c9900e5fd7cc7eab0)
by [Unito](https://www.unito.io)
2026-04-27 01:46:50 +00:00
Terry Jia
cc1fe65348 test: add unit tests for maskEditorDataStore (#11641)
## Summary

Add unit tests for `maskEditorDataStore` Pinia store, raising coverage
from 0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorDataStore.test.ts` (13 tests)
covering initial state, the three `computed` predicates
(`hasValidInput`, `hasValidOutput`, `isReady` including the `isLoading`
interaction), the `setLoading(loading, error?)` action across its three
branches (no error arg, truthy error arg, empty-string error arg — empty
string is falsy so it must NOT clobber an existing `loadError`), and
`reset()` clearing every field including derived predicates.

## Review Focus

- Uses `createTestingPinia({ stubActions: false })` so action
implementations actually run, matching the pattern used by other store
tests in `src/stores/` (e.g. `dialogStore.test.ts`).
- The `setLoading('', '')` test guards a real branch in the source — `if
(error)` skips assignment for empty strings, so callers can't
accidentally clear a previous error by passing `''`. Worth keeping if
anyone tightens the guard later.
- `reset()` test asserts both raw refs and the three computed values
flip back to `false` / `null`, so a future regression in either
direction is caught.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed property/action, `createImage` / `createCanvas` /
`createOutputData` helpers to keep arrange blocks short.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11641-test-add-unit-tests-for-maskEditorDataStore-34e6d73d36508121b8e5d185178310ab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 21:38:38 -04:00
Terry Jia
0f66f76b87 test: add unit tests for mask editor control components (#11642)
## Summary

Add unit tests for the three mask editor control components
(`DropdownControl`, `SliderControl`, `ToggleControl`), raising each from
partial (20% / 37.5% / 44.4%) to 100% across all coverage dimensions.

## Changes

- **What**: Add three sibling test files under
`src/components/maskeditor/controls/`:
- `DropdownControl.test.ts` (5 tests): label render, normalization of
`string[]` options into `{label,value}`, pass-through of `{label,value}`
options, `modelValue` reflected as the selected option,
`update:modelValue` emitted on change.
- `SliderControl.test.ts` (4 tests): label render,
`min`/`max`/`step`/`modelValue` exposed on `<input type="range">`,
`step` defaults to `1` when omitted, `update:modelValue` emitted as a
`number` (not string) on input.
- `ToggleControl.test.ts` (5 tests): label render, `modelValue`
reflected as `checked`, `update:modelValue` emitted as `true` / `false`
on toggle.

## Review Focus

- Stack matches the project's prevailing Vue test pattern
(`@testing-library/vue` + `@testing-library/user-event`, e.g.
`Badge.test.ts`, `BatchCountEdit.test.ts`).
- `update:modelValue` is asserted via the `'onUpdate:modelValue'`
callback prop rather than `emitted()` — keeps tests focused on
observable behavior and avoids reaching into the wrapper.
- `SliderControl` uses `fireEvent.input` instead of
`userEvent.type/clear`: `<input type="range">` is non-editable for
`userEvent` (`clear() is only supported on editable elements`). The
single eslint-disable for `testing-library/prefer-user-event` is
annotated with the reason.
- `DropdownControl` test guards both branches of the `string` →
`{label,value}` normalization (string array path and pre-normalized
object array path), since that's the only meaningful logic in the file.
- Style aligned with sibling tests: `should ...` naming, per-file
`renderComponent` helper accepting a `props` override and an `onUpdate`
callback parameter.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11642-test-add-unit-tests-for-mask-editor-control-components-34e6d73d3650812aba2ae34a760489e2)
by [Unito](https://www.unito.io)
2026-04-26 21:38:16 -04:00
Terry Jia
bc16865019 test: add unit tests for useToolManager mask editor composable (#11643)
## Summary

Add unit tests for `useToolManager` mask editor composable, raising
coverage from 0% to 100% (statements / functions / lines, 97.53%
branch).

## Changes

- **What**: Add `src/composables/maskeditor/useToolManager.test.ts` (35
tests) covering:
- `switchTool`: store update, layer auto-switch via
`newActiveLayerOnSet`, custom-cursor branch, no-cursor (default
`'none'`) branch, missing-`pointerZone` no-throw guard.
- `setActiveLayer`: rgb-while-mask-only-tool → swap to `PaintPen`,
mask-while-`PaintPen` → swap to `MaskPen`, no-swap path.
- `updateCursor`: same custom-cursor / default-cursor split plus
`brushPreviewGradientVisible = false` post-condition.
- `currentTool` watcher: clears `lastColorSelectPoint` only when leaving
`MaskColorFill`.
- `handlePointerDown`: touch-ignore, pen pointer registration,
middle-button pan, space+left pan, `MaskPen`/`PaintPen` left-button
drawing, `PaintPen` continue-drawing branch (`button !== 0 && buttons
=== 1`), `MaskBucket` flood fill (with coord transform),
`MaskColorFill`, alt+right brush adjustment, right-click drawing for
drawing tools, no-op for non-drawing tools.
- `handlePointerMove`: touch-ignore, cursor position update,
middle-button pan, space+left pan, non-drawing-tool ignore, alt+right
brush adjustment while `isAdjustingBrush`, left/right drag drawing.
- `handlePointerUp`: state cleanup (`isPanning` / `brushVisible` /
`isAdjustingBrush`), pen pointer removal, touch-pointer early bail
before `drawEnd`.

## Review Focus

- Mock store is wrapped in `reactive()` so the `watch(() =>
store.currentTool, ...)` actually fires when tests mutate `currentTool`.
Plain object mocks would silently no-op the watcher branch.
- Each `setup()` runs `useToolManager` inside its own `effectScope`,
stopped in `afterEach`. Without scoping, watchers from previous tests
stay attached to the shared reactive store and accumulate (a single
mutation in test N would call `clearLastColorSelectPoint` N times).
- Mocked `app.extensionManager.setting.get` because `useBrushDrawing`
factory reads two settings synchronously at construction time. The mock
returns deterministic defaults so we don't need `useSettingStore`
plumbing.
- Pointer-event factory builds the minimal shape (`button` / `buttons` /
`pointerType` / `offset*` / `client*` / `altKey` / `pointerId`) — no
jsdom `PointerEvent` constructor noise. `preventDefault` is a `vi.fn()`
because the source calls it unconditionally.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function/watcher, typed `MockStore`, helper
`pointerEvent({ ... })` and `setup()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11643-test-add-unit-tests-for-useToolManager-mask-editor-composable-34e6d73d36508184b017ebd04626b29d)
by [Unito](https://www.unito.io)
2026-04-26 20:49:01 -04:00
Terry Jia
206a367379 test: add unit tests for useMaskEditor composable (#11644)
## Summary

Add unit tests for `useMaskEditor` composable, raising coverage from 0%
to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/composables/maskeditor/useMaskEditor.test.ts` (7
tests) covering `openMaskEditor`:
- Happy path: dialog opened once, `node` forwarded as a prop, header /
content components attached.
- Modal dialog config (`modal` / `maximizable` / `closable` flags)
forwarded to PrimeVue dialog props.
- Acceptance path for nodes with no `imgs` but `previewMediaType ===
'image'`.
- Three guard paths that should log and bail: `node` is null, node with
empty `imgs` and no image preview, node with empty `imgs` and a
non-image preview type (e.g. `'video'`).

## Review Focus

- Mocked `useDialogStore` with a single shared `showDialog` spy — the
only contract under test is "we forwarded these props to the store
action", so instantiating Pinia would just add noise.
- `TopBarHeader.vue` and `MaskEditorContent.vue` are stubbed because
they pull in the full mask-editor render tree; we only assert they're
forwarded as `headerComponent` / `component`, not what they render.
- `console.error` is spied per-test so the bail messages are observable
but don't pollute runner output.
- `nodeWithImage` factory uses a structural `NodeShape` (`{ imgs?,
previewMediaType? }`) rather than `Partial<LGraphNode>` because the real
`LGraphNode` type requires a `LGraphNodeConstructor`-shaped
`constructor` field, which would force every test to construct a full
graph node — irrelevant to the contract being tested.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function (`openMaskEditor`), helper factory.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11644-test-add-unit-tests-for-useMaskEditor-composable-34e6d73d365081e98336db0a92c37ccf)
by [Unito](https://www.unito.io)
2026-04-26 20:48:28 -04:00
Terry Jia
7e8ede376b test: add unit tests for maskEditorStore (#11645)
## Summary

Add unit tests for `maskEditorStore` Pinia store, raising coverage from
0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorStore.test.ts` (30 tests)
covering:
- Brush setters: `setBrushSize` (1–250), `setBrushOpacity` (0–1),
`setBrushHardness` (0–1), `setBrushStepSize` (1–100) — each tested at
lower bound, upper bound, and in-range.
  - `resetBrushToDefault`: documents the exact default brush shape.
- Other clamped setters: `setPaintBucketTolerance` /
`setColorSelectTolerance` / `setMaskTolerance` (0–255), `setFillOpacity`
/ `setSelectionOpacity` (0–100), `setMaskOpacity` (0–1), `setZoomRatio`
(0.1–10).
- `setPanOffset` / `setCursorPoint`: copy-by-value semantics — mutating
the input after the call must not leak into store state.
  - `resetZoom` / `triggerClear`: monotonic counter bumps.
- `maskColor` computed: `Black`, `White`, `Negative` blend modes plus
the `default:` fallback for unknown values.
  - `canUndo` / `canRedo` proxy through to mocked `useCanvasHistory`.
- Canvas → ctx watchers: setting `maskCanvas` / `rgbCanvas` /
`imgCanvas` derives the corresponding `*Ctx` via `getContext('2d', {
willReadFrequently: true })`. Clearing the canvas leaves the previous
ctx in place (parametrized via `it.each` for all three).
- `resetState`: restores all non-DOM state to documented defaults;
explicitly verifies DOM refs (`maskCanvas` / `pointerZone` / `image`)
are NOT cleared so the editor can reuse mounted elements after a reset.

## Review Focus

- `useCanvasHistory` is mocked via `vi.hoisted` so each test gets the
same exposed `canUndo` / `canRedo` refs while the store's internal
`canvasHistory` reference is untouched. Without this, the store would
call into the real history with `null` canvas refs.
- `setPanOffset` / `setCursorPoint` tests mutate the input *after* the
call — that's the actual behavioral contract (defensive copy via
spread), not a default-value check.
- `resetState` test sets *every* field to a non-default before calling,
so the test fails if `resetState` ever forgets to reset a field. Final
assertions are positive (matches default), not weak negative checks.
- The "DOM refs preserved on reset" assertion is the
surprising-on-purpose part: a future refactor that adds
`maskCanvas.value = null` to `resetState` would break the editor's
ability to reuse mounted canvases after clearing internal state.
- `it.each` for the three canvas/ctx pairs covers the watcher's null
branch without three near-duplicate tests.
- `makeCanvas` overrides `canvas.getContext` directly rather than using
`vi.spyOn` because `HTMLCanvasElement.getContext` has overloads (2d /
webgl / webgpu / bitmaprenderer) and TypeScript picks the GPU overload
by default for spy return type inference.
- Style aligned with sibling `maskEditorDataStore.test.ts`:
`createTestingPinia({ stubActions: false })`, `should ...` naming,
`describe` grouped by exposed property/action, no default-only
change-detector tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11645-test-add-unit-tests-for-maskEditorStore-34e6d73d3650818e9855cd9f9f13e62a)
by [Unito](https://www.unito.io)
2026-04-26 20:47:58 -04:00
Terry Jia
7bfbd0d7f3 test: add unit tests for mask editor settings panels (#11647)
## Summary

Add unit tests for the five mask editor settings panel components,
raising each from 0–35% to ~98–100% across coverage dimensions.

## Changes

- **What**: Add 5 sibling test files under `src/components/maskeditor/`
(47 tests total):
- `PaintBucketSettingsPanel.test.ts` (4): both sliders bind to / write
through to `setPaintBucketTolerance` / `setFillOpacity`. **100%**.
- `ColorSelectSettingsPanel.test.ts` (8): all 7 controls (4 sliders, 2
toggles, 1 dropdown) bind to and write through to the right store
fields/setters; method dropdown casts to `ColorComparisonMethod`.
**100%**.
- `BrushSettingsPanel.test.ts` (13): shape buttons (Arc/Rect), reset to
default, four numeric inputs (size/opacity/hardness/step), logarithmic
size slider including the cached-raw-value path
(`Math.round(Math.pow(250, x))`), color input v-model, color input ref
forwarded to / cleared from store on mount/unmount.
- `ImageLayerSettingsPanel.test.ts` (17): mask opacity slider + canvas
style sync, blend-mode select with all 3 enum values + default fallback,
three layer-visibility checkboxes (with and without canvas refs),
activate-layer button forwarding to `toolManager.setActiveLayer`,
disabled-when-active and "show paint button only when Eraser" branches,
base image preview src binding.
- `SettingsPanelContainer.test.ts` (5): tool → component routing
(`MaskBucket` → bucket panel, `MaskColorFill` → color panel, anything
else → brush panel).

## Review Focus

- Stack matches sibling control tests (`@testing-library/vue` +
`userEvent` + stub child controls). Each panel's child `SliderControl` /
`ToggleControl` / `DropdownControl` is replaced by a minimal `<button>`
stub that emits `update:modelValue` on click, so panel logic is
decoupled from control internals (already covered by their own tests).
- Two panels (`Brush`, `ImageLayer`) need real reactivity
(`brushSettings.size` changes through a setter must propagate to the
slider's modelValue computed; `currentTool` / `activeLayer` mutations
between tests). They use `reactive()` from Vue at top level + a `let
mockStore` reset in `beforeEach`. Plain object mocks would either
silently no-op or leak state between tests.
- The two reactive panels enable file-level `eslint-disable
testing-library/no-container, testing-library/no-node-access` because
the unlabeled DOM (shape divs, unlabeled `<input type="number">`/`<input
type="color">`/`<input type="checkbox">`/`<select>`) genuinely can't be
queried via `screen.getByRole/Label`. Each disable has a comment
explaining why.
- `BrushSettingsPanel` has a non-trivial cached-value branch in
`brushSizeSliderValue` computed: when `rawSliderValue` and `brushSize`
are in sync, the getter returns the raw float instead of recomputing
`log(size)/log(250)`. Test stubs `setBrushSize` to actually write back
so the next read takes the cached path.
- `ImageLayerSettingsPanel` has a `display: 'block' | 'none'` style
binding. happy-dom doesn't populate `el.style.display` from inline style
strings reliably, so the test asserts via `el.getAttribute('style')`
instead.
- `SettingsPanelContainer` uses inline component stubs that render
unique text — assertion is just `textContent.toContain('brush-panel')`
etc. No need for component-instance probing.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature (button group, slider, etc.), `vi.hoisted` for mock
state where reactivity isn't required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11647-test-add-unit-tests-for-mask-editor-settings-panels-34e6d73d36508117911bc0850ce085e1)
by [Unito](https://www.unito.io)
2026-04-26 20:27:26 -04:00
Terry Jia
32b266f3e9 test: add unit tests for SidePanel, MaskEditorButton, and BrushCursor (#11648)
## Summary

Add unit tests for three small mask editor Vue components (`SidePanel`,
`MaskEditorButton`, `BrushCursor`), all reaching **100%** across
coverage dimensions.

## Changes

- **What**: Add 3 sibling test files (17 tests total):
- `SidePanel.test.ts` (3): renders both `SettingsPanelContainer` and
`ImageLayerSettingsPanel`; forwards `toolManager` prop through to
`ImageLayerSettingsPanel`; works with the prop omitted.
- `MaskEditorButton.test.ts` (3): renders with localized `aria-label`
when `isSingleImageNode` is true; hidden via `v-show` (style `display:
none`) when false; click executes `Comfy.MaskEditor.OpenMaskEditor`
command.
- `BrushCursor.test.ts` (11): opacity 1 / 0 from `brushVisible`; size =
2 × effective brush size × zoom (with hardness scaling); border-radius
50% / 0% for Arc / Rect; position from `cursorPoint + panOffset -
radius`, with optional `containerRef.getBoundingClientRect` offset;
gradient preview hidden / shown; flat fill at `effectiveHardness === 1`.

## Review Focus

- `SidePanel` test stubs both child panels to bare `<div data-testid>`
so the test verifies wiring (prop forwarding, child rendering) without
being affected by the children's i18n / store dependencies.
- `MaskEditorButton` mocks `useCommandStore.execute` and
`useSelectionState.isSingleImageNode` (as a Vue ref). Real i18n via
`createI18n` + `globalInjection: true` — the template uses `$t(...)`
which requires global injection in composition mode.
- For the v-show hidden case, `getByLabelText('...', { selector:
'button' })` works where `getByRole('button', { hidden: true })` doesn't
reliably resolve through the `<Button>` wrapper component.
- `BrushCursor` uses `reactive()` mock store and queries the brush /
gradient elements by id (`#maskEditor_brush`,
`#maskEditor_brushPreviewGradient`). File-level eslint-disable for
`testing-library/no-node-access` because the component's anchor elements
are styled divs without ARIA roles or labels.
- The `radial-gradient` (hardness < 1) branch of `gradientBackground` is
intentionally **not** asserted via rendered DOM: the computed returns a
multi-line template literal that happy-dom's CSS parser drops entirely.
The math (`getEffectiveBrushSize` / `getEffectiveHardness`) is covered
by `brushUtils.test.ts`. v8 reports 100% branch coverage because Vue
evaluates the computed regardless of whether the resulting style string
is parsed by the DOM.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for command-store / selection-state
mocks, real i18n via `createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11648-test-add-unit-tests-for-SidePanel-MaskEditorButton-and-BrushCursor-34e6d73d365081e38c4afee32ddf2b0b)
by [Unito](https://www.unito.io)
2026-04-26 20:08:03 -04:00
Terry Jia
2d4ca9c387 test: add unit tests for PointerZone and ToolPanel (#11649)
## Summary

Add unit tests for `PointerZone` and `ToolPanel` mask editor components,
raising each from 0% to **91.89% / 100%** respectively.

## Changes

- **What**: Add 2 sibling test files (24 tests total):
- `PointerZone.test.ts` (13): mount exposes root element to
`store.pointerZone`; pointer events (down/move/up) forward to
`toolManager.handlePointer*`; `pointerleave` hides brush + clears
cursor; `pointerenter` calls `toolManager.updateCursor`; touch events
(start/move/end) forward to `panZoom.handleTouch*`; wheel zooms then
updates cursor with wheel coords; `isPanning` watcher sets cursor to
"grabbing" then back via `updateCursor`; contextmenu's default is
prevented.
- `ToolPanel.test.ts` (11): one container rendered per `allTools`; icon
HTML for each tool; current tool highlighted with
`maskEditor_toolPanelContainerSelected` class while others are not;
clicking a container calls `toolManager.switchTool` with the matching
enum value; zoom indicator rounds `displayZoomRatio * 100`; dimensions
text reflects `image.width × image.height` (or empty when no image);
clicking the zoom indicator calls `store.resetZoom`.

## Review Focus

- `PointerZone` mocks `useToolManager` / `usePanAndZoom` to plain
function bags via factory helpers. The component is mostly forwarding,
so the test's value is in *event mapping* (which event triggers which
handler), not handler internals.
- happy-dom doesn't propagate `clientX` / `clientY` through the
`WheelEvent` constructor; the wheel test sets them via
`Object.defineProperty` after construction. Without this,
`updateCursorPosition` reads `undefined`.
- `PointerZone` ends at 91.89% statements / 70% branch — uncovered lines
are the `onMounted` early-return when `pointerZoneRef.value` is
`undefined` (always set in tests) and the watcher's same guard. Both
unreachable in normal use, intentionally not covered.
- `ToolPanel` mocks `iconsHtml` to `<svg data-testid="icon-${tool}" />`
markers, letting tests assert per-tool rendering via `getByTestId`. Real
i18n via `createI18n` for the reset-zoom tooltip text.
- `getToolContainers` queries by class because tool buttons are
unlabeled `<div>`s with click handlers (no role / aria); a file-level
`eslint-disable testing-library/no-node-access` covers this and the
dimensions-span placeholder query.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `reactive()` mock store + `let mockStore` reset in
`beforeEach`, real i18n where keys are user-visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11649-test-add-unit-tests-for-PointerZone-and-ToolPanel-34e6d73d3650817daf85c0d33d16899d)
by [Unito](https://www.unito.io)
2026-04-26 20:07:35 -04:00
Dante
177224452e test(assets): add E2E tests for media type filter (#10784)
## Summary

Add Playwright E2E tests for media type filter in assets sidebar.

## Changes

- Add `filterButton`, `filterCheckbox(label)`, `openFilterMenu()` to
`AssetsSidebarTab` fixture
- New `Assets sidebar - media type filter` describe block with 3 tests:
- Filter menu shows all 4 media type checkboxes (Image, Video, Audio,
3D)
  - Unchecking image filter hides image assets
  - Re-enabling filter restores hidden assets
- Mock jobs use distinct file extensions (.png, .mp4, .mp3) since
filtering is extension-based

## Review Focus

Tests tagged `@cloud` — filter button is gated behind `isCloud` in
`MediaAssetFilterBar.vue`.

Fixes #10780

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10784-test-assets-add-E2E-tests-for-media-type-filter-3356d73d3650810a8ecdd102e9f5b47e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:15:03 +09:00
Terry Jia
6bf75b4cf0 refactor(load3d): introduce ModelAdapter abstraction for the loader switch (#11627)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
the per-format loader logic needs to live behind a stable seam before
splat-specific fixes (orientation, async-decoder waits, GPU dispose,
custom bounds) and capability-driven UX gating can be added without
touching `LoaderManager`'s switch every time.

## Summary

Pure refactor. Extracts the per-extension switch inside `LoaderManager`
into three `ModelAdapter` implementations and wires the manager to
dispatch through them. **No behavior change** — same loader code paths,
same outputs, same fallbacks. Sixth in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**:
- `ModelAdapter.ts` (new): defines the `ModelAdapter` interface (`kind`,
`extensions`, `capabilities`, `load`), a `ModelLoadContext` that exposes
only the `SceneModelManager` surface adapters need (`setOriginalModel`,
`registerOriginalMaterial`, `standardMaterial`, `materialMode`), and a
shared `fetchModelData(path, filename)` helper.
- `MeshModelAdapter.ts` (new): owns `stl`, `fbx`, `obj`, `gltf`, `glb`.
Each branch is a 1:1 lift of the corresponding `case` from
`LoaderManager.loadModelInternal` on `main`.
- `PointCloudModelAdapter.ts` (new): owns `ply`. Includes the existing
`FastPLYLoader` / `PLYLoader` fallback and the `pointCloud` vs mesh
branching logic.
- `SplatModelAdapter.ts` (new): owns `spz`, `splat`, `ksplat`. Wraps the
`SplatMesh` in a `Group` exactly like the previous `loadSplat` did.
- `LoaderManager.ts`: now owns just an adapter array (default = the
three above) and a small dispatch path. `pickAdapter` matches by
extension and routes PLY → splat when the `Comfy.Load3D.PLYEngine`
setting is `sparkjs` (preserving the previous routing).
`getCurrentAdapter()` is the new public reader used by `Load3d`.
- `Load3d.isSplatModel` / `isPlyModel` now query
`loaderManager.getCurrentAdapter()?.kind` instead of doing
tree-introspection (`containsSplatMesh`) or `instanceof
THREE.BufferGeometry` checks. Same return values, decoupled from the
model shape.
- `LoaderManagerInterface` no longer exposes the per-format loader
fields (`gltfLoader`, `objLoader`, etc.); those are now
adapter-internal.
- `SceneModelManager` is **unchanged** in this PR. Its existing
`containsSplatMesh()` traversal and PLY material-mode rebuild stay put;
a follow-up PR refactors them once capability gating is in place.

## Review Focus

- **Loader equivalence**: the body of every `case` in `main`'s
`LoaderManager.loadModelInternal` is now in the corresponding adapter's
`load()` method. Easiest way to verify: diff `main`'s
`LoaderManager.loadModelInternal` against the four `load()` bodies and
confirm each branch's behavior (file fetch + parse + material wiring +
group wrapping) is byte-identical.
- **Dispatch parity**: `pickAdapter` produces the same routing as `main`
— extension match first, then the PLYEngine === 'sparkjs' override
hoisted up from inside the old `loadPLY`.
- **Capability fields are dormant**: the `ModelAdapterCapabilities`
record (`fitToViewer`, `materialModes`, `fitTargetSize`, …) is declared
on every adapter but **not consumed anywhere in this PR**.
SceneModelManager / Load3d / Load3DControls still read no capability
data. The follow-up PR turns these on.
- **`setOriginalModel` / `registerOriginalMaterial`**: adapters now go
through the `ModelLoadContext` getter rather than reaching into
`modelManager` directly. The context's `standardMaterial` and
`materialMode` are exposed via getters so a late-bound `materialMode` is
read at the actual call site, not snapshotted at context creation.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** |
| `MeshModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (new) | 97.22% | 61.11% | 75% | 97.22% |
| `SplatModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.17% | 86.66% | **100%**
|
| `Load3d.ts` (modified) | 6.63% | 0% | 13.68% | 6.7% |

All four new files are at or near 100% via dedicated unit tests for each
adapter (load happy path, error propagation, extension declarations,
capability shape). `LoaderManager.test.ts` exercises the dispatch logic
— extension matching, the `ply → splat` sparkjs override, the stale-load
discard, the load-context proxying — across 34 cases. The two changed
`Load3d.ts` methods (`isSplatModel`, `isPlyModel`) get dedicated tests
verifying they read the current adapter's `kind` and fall back to
`false` when none is loaded.

`Load3d.ts`'s overall 6.7% number is the pre-existing baseline — the
existing `Load3d.test.ts` covers façade methods via prototype injection
rather than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). PR-F's surface in
`Load3d.ts` is two method bodies, both covered by the new adapter-driven
kind queries test.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11627-refactor-load3d-introduce-ModelAdapter-abstraction-for-the-loader-switch-34d6d73d3650811b8a1ccc55b45100f2)
by [Unito](https://www.unito.io)
2026-04-26 18:32:51 -04:00
Christian Byrne
7a13340989 chore: add 301 redirects for old Framer case study URLs (#11654)
## Summary

Add 301 redirects for old Framer case study URLs to new `/customers/`
pages.

## Changes

- Add `redirects` config to `apps/website/astro.config.ts` mapping two
old Framer enterprise case study URLs to their new Astro customer pages

## Testing

### Automated

- Website build succeeds with redirect pages generated
- Lint, typecheck, and format checks pass

### E2E Verification Steps

1. Deploy to preview
2. Visit
`/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping`
— should 301 redirect to `/customers/moment-factory/`
3. Visit
`/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui`
— should 301 redirect to `/customers/series-entertainment/`

Fixes #11583

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11654-chore-add-301-redirects-for-old-Framer-case-study-URLs-34e6d73d36508187a386eed3e25cf1b2)
by [Unito](https://www.unito.io)
2026-04-26 15:13:32 -07:00
Terry Jia
1b07e82ff7 fix: resolve mesh widget thumbnails via asset preview API (#11538)
## Summary
The Load3d select-model widget was passing the raw .glb URL as the item
preview_url, which browsers can't render as an image, producing the
broken-image icon on cloud/local asset-enabled servers.

Resolve thumbnails lazily from the asset API using the preview_id link
(matching Media3DTop's behavior), look up by basename to stay consistent
with the write path in useLoad3d, and fall back to a 3D-box placeholder
when no preview exists yet.

## Screenshots
before
<img width="1112" height="1333" alt="image"
src="https://github.com/user-attachments/assets/a8fa88ad-ab82-4951-be03-d28111322e30"
/>

after
with asset-enable on BE

https://github.com/user-attachments/assets/34b416af-5729-4ad0-bf17-722461ffc659

without asset-enable on BE
<img width="1026" height="1201" alt="image"
src="https://github.com/user-attachments/assets/71fd463f-ca77-4d63-85ed-01261d032d53"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11538-fix-resolve-mesh-widget-thumbnails-via-asset-preview-API-34a6d73d365081d2aefac044dab0dfc3)
by [Unito](https://www.unito.io)
2026-04-26 18:08:30 -04:00
Terry Jia
9f4c54eb24 refactor: extract Load3d right-click guard to load3dContextMenuGuard (#11625)
## Summary

Pull the right-click vs right-drag detection out of `Load3d` into a
sibling helper. Mechanical refactor — no behavior change. Third of four
small PRs splitting up the [`remove-ply-3dgs-nodes-squashed`
mega-commit.](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.)

## Changes

- **What**: New `load3dContextMenuGuard.ts` exports
`attachContextMenuGuard(target, onMenu, { isDisabled, dragThreshold })`.
It installs `mousedown` / `mousemove` / `contextmenu` listeners against
a single `AbortController` and returns one dispose function.
- `Load3d` now calls `attachContextMenuGuard(this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event), { isDisabled: () =>
this.isViewerMode })` and stores the returned disposer in a single
field. Drops four private fields (`rightMouseStart`, `rightMouseMoved`,
`dragThreshold`, `contextMenuAbortController`) plus the now-redundant
`showNodeContextMenu` private method.
- The 5px drag threshold and `isViewerMode` gating are preserved.

## Review Focus

- The three event handlers (`mousedown`, `mousemove`, `contextmenu`)
inside the new helper match the old inline implementations one-for-one —
same `e.button === 2` / `e.buttons === 2` checks, same call to
`exceedsClickThreshold`, same `preventDefault` + `stopPropagation`
ordering.
- `isDisabled: () => this.isViewerMode` replaces the inline `if
(this.isViewerMode) return` early-out — same gate, just lifted to a
callback.
- A single `AbortController.abort()` (in the returned disposer) replaces
the old four-field teardown in `Load3d.remove()`.
- 9 unit tests cover the helper: click vs drag distinction at the
threshold, drag-then-click reset, `isDisabled` short-circuit, and the
disposer detaching all three listeners.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `load3dContextMenuGuard.ts` (new) | **100%** | **93.33%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | 7.12% | 0% | 13.97% | 7.18% |

The single uncovered branch on `load3dContextMenuGuard.ts` (line 22) is
the default-parameter fallback for `dragThreshold` when the caller omits
it — `Load3d` always passes `{ isDisabled, dragThreshold: 5 }` through
`attachContextMenuGuard`'s second-arg destructure, so the default never
fires under the production call path. Adding a test that omits
`dragThreshold` would push it to 100%; left as-is to avoid a
change-detector test for a default value.

The `Load3d.ts` numbers are the pre-existing baseline on `main` —
`Load3d.test.ts` covers façade methods via prototype injection rather
than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). The
`initContextMenu` rewrite and the `remove()` teardown change both sit in
those same uninstantiated paths and rely on browser e2e for end-to-end
coverage, the same as before. Net: the click-vs-drag logic that
previously had no unit test is now ≥93% covered through the extracted
helper.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11625-refactor-extract-Load3d-right-click-guard-to-load3dContextMenuGuard-34d6d73d36508162aecef46553a3f50d)
by [Unito](https://www.unito.io)
2026-04-26 17:55:09 -04:00
Terry Jia
6f6fc88b0f test: add unit tests for MaskEditorContent main container (#11651)
## Summary

Add unit tests for `MaskEditorContent` (the mask editor's main
orchestration container), raising coverage from 0% to **94.11% / 83.72%
/ 83.33% / 94.11%** (statements / branches / functions / lines).

## Changes

- **What**: Add `src/components/maskeditor/MaskEditorContent.test.ts`
(12 tests) covering:
- **Mount**: keyboard listeners attached, ResizeObserver observes the
container, all 5 canvas refs assigned to the store before init runs.
- **Init flow**: `loader.loadFromNode` → `imageLoader.loadImages` →
`panZoom.initializeCanvasPanZoom` → `canvasHistory.saveInitialState` →
`brushDrawing.initGPUResources` → `initPreviewCanvas` chain runs in
order; child UI (`ToolPanel` / `PointerZone` / `SidePanel` /
`BrushCursor`) only renders after init succeeds; GPU preview canvas
resolution matches the mask canvas.
- **Init errors**: rejection from `loader.loadFromNode` or
`panZoom.initializeCanvasPanZoom` is caught, logged, and triggers
`dialogStore.closeDialog()`.
- **ResizeObserver**: callback invokes `panZoom.invalidatePanZoom()`
(captured the constructor argument to call it manually).
  - **Drag**: `Ctrl+drag` is preventDefault'd; plain drag is not.
- **Unmount**: cleanup runs `brushDrawing.saveBrushSettings`,
`keyboard.removeListeners`, `canvasHistory.clearStates`,
`store.resetState`, `dataStore.reset`.

## Review Focus

- Heavy mock surface (10 modules): the 3 stores, 5 composables, plus 4
child Vue components and `LoadingOverlay`. All mocks are `vi.hoisted`
module-level. `mockStore` is `reactive()` because the source mutates
`activeLayer` (visible in template binding), `maskCanvas`, etc.; the
rest are plain function bags.
- Child components are stubbed to bare `<div data-testid>` so init
reveal can be asserted via `screen.findByTestId(...)` without engaging
their real implementations (each has its own test file).
- `MockResizeObserver` captures the constructor callback in module-level
`lastResizeCallback`. The "invalidate on resize" test invokes it
manually with empty args — that's enough to exercise the source's `if
(panZoom) { await panZoom.invalidatePanZoom() }` branch since the
callback only consumes `panZoom` from closure.
- happy-dom doesn't propagate `ctrlKey` through the `DragEvent`
constructor, so the drag tests set it via `Object.defineProperty(event,
'ctrlKey', { value })` (same pattern used in `PointerZone.test.ts` for
wheel `clientX/Y`).
- 94.11% line coverage — the two uncovered blocks (`containerRef`
missing, canvas refs missing) are early-return error paths unreachable
when Vue successfully mounts; not worth constructing a fixture to
trigger.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` mocks reset via `beforeEach`,
`screen.findByTestId` for async render assertions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11651-test-add-unit-tests-for-MaskEditorContent-main-container-34e6d73d365081b38af2e057cb7daf9e)
by [Unito](https://www.unito.io)
2026-04-26 17:52:29 -04:00
Terry Jia
492bec28c8 test: add unit tests for TopBarHeader (#11650)
## Summary

Add unit tests for `TopBarHeader` mask editor dialog component, raising
coverage from 0% to **100%** across statements, branches, functions, and
lines.

## Changes

- **What**: Add `src/components/maskeditor/dialog/TopBarHeader.test.ts`
(17 tests) covering:
  - Localized title rendering.
  - Undo / Redo buttons forward to `store.canvasHistory.{undo,redo}`.
- Four transform buttons (rotate left / right, mirror horizontal /
vertical) call the matching `canvasTransform` action — parametrized via
`it.each`.
- All four transform error paths: rejected promise is caught, swallowed,
and logged with the right `[TopBarHeader] ... failed:` prefix.
- Invert calls `canvasTools.invertMask`; Clear calls both
`canvasTools.clearMask` and `store.triggerClear`.
- Save: hides brush, awaits `saver.save()`, closes the dialog on
success; switches button text to "Saving" while in-flight; restores
brush + button label and logs on save failure.
  - Cancel: closes the dialog with the `global-mask-editor` key.

## Review Focus

- All five composable / store dependencies are mocked at module level
via `vi.hoisted`: `useMaskEditorStore`, `useDialogStore`,
`useCanvasTools`, `useCanvasTransform`, `useMaskEditorSaver`. Only the
store needs `reactive()` (`brushVisible` flips during save flow); the
rest are plain function bags.
- `Button.vue` is stubbed to a thin `<button :disabled>` so role queries
(`getByRole('button', { name: ... })`) resolve cleanly without dragging
in the real UI button's classes / variants.
- Real i18n via `createI18n`. Icon buttons rely on `:title` for their
accessible name; text buttons rely on slot text — both are reachable
through `getByRole('button', { name: ... })`.
- The "Saving" text test uses an unresolved promise to keep the
in-flight state observable; `waitFor` + `void user.click(...)` lets us
assert without awaiting the click. The dangling promise resolves at the
end so vitest doesn't complain.
- `it.each` parametrizes the four transform success paths and
(separately) the four error paths, keeping the file tight.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for cross-test mocks, real i18n with
`createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11650-test-add-unit-tests-for-TopBarHeader-34e6d73d365081adab66e460cf56accb)
by [Unito](https://www.unito.io)
2026-04-26 17:51:44 -04:00
Comfy Org PR Bot
13b660a15b 1.44.10 (#11620)
Patch version increment to 1.44.10

**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: Alexander Brown <drjkl@comfy.org>
2026-04-26 05:36:11 +00:00
Terry Jia
b232831441 fix: stop duplicate node creation when dropping image on Vue nodes (#11541)
## Summary
handleDrop checked `handled === true` to gate stopPropagation, but
onDragDrop from useNodeDragAndDrop is async and always returns a
Promise, so the check never matched. The drop then bubbled to the
document handler in app.ts and spawned a new LoadImage node in addition
to the one that accepted the drop.

Updated onDragDrop to take an optional claimEvent flag — when true, it
calls preventDefault()/stopPropagation() synchronously inside the
handler, only after the sync acceptance check passes (valid files /
same-origin URI), and before any await.
The Vue node now just calls await node.onDragDrop(event, true).
Rejected payloads (cross-origin URI, files filtered out, no valid files)
skip the claim and bubble to the document fallback as before. The
remaining edge case is async URI fetch failures, which we can't
sync-detect without speculatively claiming.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/d79a5101-370b-4873-8365-5f9ce188731b



after


https://github.com/user-attachments/assets/8b787474-eab9-4060-8146-c4d8bb24ff9f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11541-fix-stop-duplicate-node-creation-when-dropping-image-on-Vue-nodes-34a6d73d36508113b153e31768602933)
by [Unito](https://www.unito.io)
2026-04-25 20:51:39 -04:00
Dante
996e362ba6 test: add unit tests for form-dropdown internals (#11441)
## Summary

Adds 32 unit tests across 3 files covering the internals of the
FormDropdown family (input, filter, menu item). Part of a
widget-test-coverage sequence.

## Changes

- **What**:
- `FormDropdownInput.test.ts` (14) — placeholder vs selected-items
display, label preference, multi-item join, select-click emit,
file-input rendering/accept/multiple/disabled/upload event.
- `FormDropdownMenuFilter.test.ts` (8) — option rendering, v-model
update on click, single-option disabled state, import-button gating by
\`useModelUpload.isUploadButtonEnabled\` (mocked), \`showUploadDialog\`
invocation.
- `FormDropdownMenuItem.test.ts` (10) — label vs name preference,
img/video rendering by injected \`AssetKindKey\`, placeholder gradient,
list-small layout, click emits index, mediaLoad event, selection
indicator.

## Review Focus

- \`useModelUpload\` mocked at the module boundary with a dynamic import
of \`vue\` inside \`vi.mock\` (needed because \`vi.hoisted\` runs before
imports).
- \`AssetKindKey\` provided via \`global.provide\` using the
\`ComputedRef<AssetKind>\` shape.
- \`v-tooltip\` registered as a no-op directive to avoid render errors
in happy-dom.
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11441-test-add-unit-tests-for-form-dropdown-internals-3486d73d3650813cb4a1c6568280ef1a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 00:36:25 +00:00
Terry Jia
b0fa179b87 refactor: extract Load3d render loop to load3dRenderLoop (#11623)
## Summary

Pull the `requestAnimationFrame` loop and its activity-gated tick body
out of `Load3d` into a small `startRenderLoop({ tick, isActive })`
helper. Pure mechanical refactor — no behavior change. First of four
small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dRenderLoop.ts` exports `startRenderLoop` (returns
a `{ stop }` handle). `Load3d.startAnimation()` now constructs a loop
through it; `Load3d.remove()` calls `stop()` instead of
`cancelAnimationFrame`. Field `animationFrameId: number | null` becomes
`renderLoop: RenderLoopHandle | null`.

## Review Focus

- The tick body inside `startAnimation()` is byte-identical to the
previous inline body — only the rAF scheduling has moved.
- `isActive()` is now invoked through a `() => this.isActive()` closure
instead of a direct call inside the inline `animate` function, so the
activity check still fires once per frame and reads the same fields.
- The new helper has 4 unit tests covering: ticks while active, skip
while inactive, stop halts ticks, stop is idempotent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11623-refactor-extract-Load3d-render-loop-to-load3dRenderLoop-34d6d73d3650815c9c4ec7713e912e37)
by [Unito](https://www.unito.io)
2026-04-25 20:03:01 -04:00
Terry Jia
ba6dd2a09c refactor(load3d): extract viewport math to load3dViewport (#11624)
## Summary

Pull two pure helpers out of `Load3d` into a sibling module. Mechanical
refactor — no behavior change. Second of four small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dViewport.ts` exports two pure functions:
- `computeLetterboxedViewport({ width, height }, targetAspectRatio)`
returns `{ offsetX, offsetY, width, height }` — the aspect-ratio fitting
math that was duplicated inline in three places (`renderMainScene`,
`setBackgroundImage`, `handleResize`).
- `isLoad3dActive(flags)` consumes the activity-flag struct used by the
rAF tick gate; `Load3d.isActive()` now delegates to it.

## Review Focus

- The three call sites in `Load3d.ts` produce byte-identical viewport
rectangles for any `(containerWidth, containerHeight, targetAspect)`
triple — same branch on aspect-ratio comparison, same offset derivation.
Simplest way to verify: diff the math.
- `isLoad3dActive` ORs the same six flags as before in the same order.
Old implementation read `this.STATUS_MOUSE_ON_NODE` etc. directly; new
one reads them through a flags object built at the call site.
- 13 unit tests cover the helpers: the "wider" / "taller" / "matching"
aspect cases for letterboxing, and each individual flag flipping
`isLoad3dActive` on by itself.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11624-refactor-load3d-extract-viewport-math-to-load3dViewport-34d6d73d365081ab9af5fc445bf5bd5a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 17:48:44 -04:00
Kelly Yang
4a9001f675 test: add E2E tests for image crop widget Levels 4-7 (#11571)
## Summary

Adds 12 Playwright E2E tests for the `ImageCropV2` widget covering
aspect ratio selection, lock/unlock behavior, constrained resize, and
BoundingBox numeric input — all of which had zero test coverage.

## Changes

**Level 4 — Aspect Ratio Selection** (`with source image after
execution`)
- Selecting 16:9 preset adjusts crop height proportionally via
`applyLockedRatio`
- Selecting Custom unlocks the ratio and restores all 8 resize handles

**Level 5 — Lock/Unlock** (`without source image` + `with source image
after execution`)
- Selecting a preset auto-enables the lock (aria-label changes to
"Unlock aspect ratio")
- Unlocking after a preset reverts the dropdown display to "Custom"
- Full lock→unlock round-trip verifies handle count (4 → 8) and
aria-label on both transitions

**Level 6 — Constrained Resize** (`with source image after execution`)
- NW corner drag grows origin (x, y decrease) and dimensions while
maintaining ratio
- SE corner drag beyond image edge clamps to boundary
- NW corner drag beyond (0, 0) clamps x/y to image boundary
- Inward SE corner drag enforces `MIN_CROP_SIZE` (16px minimum)

**Level 7 — BoundingBox Numeric Input** (`with source image after
execution`)
- X increment button increments crop x by 1
- Width increment button increments crop width by 1
- BoundingBox inputs reflect updated position after a drag

No source code was modified.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus adding
`data-testid` attributes to BoundingBox inputs, with no behavioral logic
changes expected.
> 
> **Overview**
> **Expands E2E coverage for the `ImageCropV2` widget** by adding new
Playwright tests for ratio preset selection, lock/unlock behavior,
constrained resizing (including boundary clamping and min size), and
BoundingBox numeric input updates.
> 
> **Improves testability of BoundingBox controls** by adding
`data-testid` attributes to the `WidgetBoundingBox.vue`
`ScrubableNumberInput` fields (`x`, `y`, `width`, `height`) so E2E tests
can target increment/input elements reliably.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b008f42942. 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-11571-test-add-E2E-tests-for-image-crop-widget-Levels-4-7-34b6d73d365081c79118ca9ae08f291c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 00:17:12 -04:00
Dante
9cf035879f test: add WidgetCurve unit tests (#11469)
## Summary

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

## Changes

- **What**: Adds WidgetCurve unit tests covering point forwarding,
interpolation updates, disabled-state behavior, and upstream value
handling.

## Review Focus

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

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11469-test-add-WidgetCurve-unit-tests-3486d73d365081c2a68bc8403fa0265f)
by [Unito](https://www.unito.io)
2026-04-24 17:47:54 -07:00
Alexander Brown
d0e9984a73 feat: update BYOKeySection images to enterprise node WebPs (#11614)
Update BYOKeySection card images from placeholder API logos to dedicated
enterprise node WebP images hosted on media.comfy.org.

## Changes
- Replace `logo-purple.webp` and `logo-yellow.webp` with
`enterprise_node_1.webp` and `enterprise_node_2.webp`
- New images are near-lossless WebP (~70% smaller than source PNGs)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11614-feat-update-BYOKeySection-images-to-enterprise-node-WebPs-34c6d73d365081239d92c649eb563b7e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 20:03:21 +00:00
pythongosssss
125c11b3d0 fix: translate blueprint label (#11573)
## Summary

Fixes hardcoded "Blueprint" text and adds e2e coverage to test
visibility, resolving #11473

## Changes

- **What**: 
- add translation
- add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11573-fix-translate-blueprint-label-34b6d73d365081009215e06be6aa1fa0)
by [Unito](https://www.unito.io)
2026-04-24 19:27:47 +00:00
Alexander Brown
725ed120e8 fix: disable parallax on mobile to prevent enterprise section overlap (#11609)
## Summary

Disable parallax on mobile in the enterprise DataOwnershipSection to
prevent images from overlapping the next section.

## Changes

- **What**: Add `mediaQuery` option to `useParallax` composable, using
GSAP's `matchMedia()` to create/revert animations responsively.
DataOwnershipSection now only applies parallax at the `lg` (1024px+)
breakpoint.

## Review Focus

GSAP `matchMedia` automatically reverts animations (resetting
transforms) when the query stops matching, so no manual cleanup is
needed on resize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11609-fix-disable-parallax-on-mobile-to-prevent-enterprise-section-overlap-34c6d73d365081a48d55e4cf880e3bab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:13:00 -07:00
Alexander Brown
453a0edd1e chore: refresh Ashby careers snapshot (#11611)
## Changes

Refreshes the Ashby careers snapshot (`ashby-roles.snapshot.json`).

The "Business" department has been renamed to "Operations" on Ashby's
side. The same 3 roles (Senior Technical Recruiter, BizOps Strategist,
Founding Customer Success Manager) now appear under "Operations".

## Testing

- Snapshot was generated via `pnpm --filter @comfyorg/website
ashby:refresh-snapshot` which validates the API response through Zod
schemas before writing.
- Lint-staged checks (typecheck, eslint, oxlint, oxfmt) passed on
commit.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11611-chore-refresh-Ashby-careers-snapshot-34c6d73d3650813ea289c3c0371f882b)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:11:13 -07:00
Christian Byrne
5a8ded7959 Website: pull careers page listing from ashby API (#11590)
Careers

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-24 11:51:43 -07:00
Alexander Brown
bb23b9352c fix: update enterprise hero SVG to match updated design (#11608)
## Changes

Update the enterprise hero SVG in `HeroSection.vue` to match the updated
design export.

### Key changes

- **viewBox**: `600 -50 1000 1100` → `0 0 1600 1046` with `clip-path`
wrapper and background rects for proper clipping
- **Block pieces**: stroke/stroke-width moved from parent `<g>` to each
individual `<path>`, with duplicated paths for layered rendering
- **Block cluster**: wrapped in `.block-cluster` group with its own CSS
transform-origin
- **Ripple delay classes**: renamed `ripple-delay-*` → `delay-*`
- **Fade overlay**: added `pointer-events: none` to prevent blocking
interactions

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 10:35:24 -07:00
Alexander Brown
25f0b41f63 Remember what was forgotten (#11603)
## Summary

Every page has a story to tell.

## Changes

- **What**: Something was missing. Now it isn't.

## Review Focus

Look closely at what was lost.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11603-Remember-what-was-forgotten-34c6d73d36508184b3cef39d0be4a3bd)
by [Unito](https://www.unito.io)
2026-04-24 10:28:32 -07:00
Alexander Brown
e7673fcca7 update: API page links to keys and docs (#11606)
## Summary

Update API page CTA buttons to link directly to the API keys page and
API docs instead of generic platform/cloud/docs links.

## Changes

- **What**: Point "Get API Keys" buttons in HeroSection and StepsSection
to `platform.comfy.org/profile/api-keys`; point "Read the Docs" button
to `docsApi` route; add `apiKeys` entry to `externalLinks` config.

## Review Focus

Straightforward link target updates — verify the new URLs are correct.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11606-update-API-page-links-to-keys-and-docs-34c6d73d365081268bb5dd55c33646c8)
by [Unito](https://www.unito.io)
2026-04-24 10:28:03 -07:00
457 changed files with 37385 additions and 4652 deletions

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -46,3 +46,9 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -82,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -140,7 +140,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
on:
push:
branches: [main, website/*]
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
@@ -17,7 +17,7 @@ on:
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
@@ -45,6 +45,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-red-600);
background-color: var(--color-coral-700);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
background-color: var(--color-coral-600);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
background-color: var(--color-coral-500);
}
.task-div .p-card {

View File

@@ -2,6 +2,7 @@ dist/
.astro/
test-results/
playwright-report/
results.json
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

148
apps/website/README.md Normal file
View File

@@ -0,0 +1,148 @@
# @comfyorg/website
Marketing/brand website built with Astro + Vue.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
API at build time. Data flow:
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
Astro build.
2. `src/utils/ashby.ts` calls
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
validates the envelope and each posting with Zod
(`src/utils/ashby.schema.ts`), and maps to the domain type in
`src/data/roles.ts`.
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
`src/data/ashby-roles.snapshot.json`.
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
builds.
### Required environment variables
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
| ------------------------------ | --------------------------- | --------------------------------- |
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
### CI wiring (manual step — required)
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits **once**:
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
build step and run the unit tests before it:
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
```
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
two env vars to the top-level `env:` block so `vercel build` (both
`deploy-preview` and `deploy-production` jobs) sees them:
```yaml
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
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
```
The secret must also be added to the Vercel project environment
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
that `vercel build` in the preview job has access to it.
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by `pr-vercel-website-preview.yaml`.
### Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after
onboarding/offboarding roles):
```bash
WEBSITE_ASHBY_API_KEY=WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
```html
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
```
The localized `/zh-CN/contact` page uses the same portal and script with form
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
renders the documented embed container.
## Scripts
- `pnpm dev` — Astro dev server
- `pnpm build` — production build to `dist/`
- `pnpm typecheck``astro check`
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot

View File

@@ -7,6 +7,12 @@ export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Careers page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/careers')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers — Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Roles', level: 2 })
).toBeVisible()
})
test('renders at least one role from the snapshot', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
await expect(roles.first()).toBeVisible()
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 — Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -9,10 +9,13 @@
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots"
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
@@ -23,7 +26,8 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:"
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
@@ -32,7 +36,9 @@
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
@@ -89,6 +95,22 @@
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [

View File

@@ -0,0 +1,33 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchRolesForBuild } from '../src/utils/ashby'
const snapshotPath = fileURLToPath(
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchRolesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
const totalRoles = outcome.snapshot.departments.reduce(
(n, d) => n + d.roles.length,
0
)
process.stdout.write(
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
)

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const investors = [
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.story.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.story.headingBefore', locale)
}}<span class="text-primary-comfy-yellow">{{
t('about.story.headingHighlight', locale)
}}</span
>{{ t('about.story.headingAfter', locale) }}
</h2>
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
{{ t('about.story.body', locale) }}
</p>
</div>
<!-- Investor card -->
<div
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
>
<div class="inline-flex items-center">
<!-- OUR badge (shorter) -->
<div class="relative z-10 flex h-9 items-center">
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
>
OUR
</span>
</div>
<!-- Union connector (overlaps both badges to eliminate seams) -->
<img
src="/icons/node-union-2size-reverse.svg"
alt=""
class="relative z-20 -mx-px h-12 w-auto"
/>
<!-- INVESTORS badge (taller) -->
<div class="relative z-10 flex h-12 items-center">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
>
INVESTORS
</span>
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
</div>
</div>
<p
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
>
{{ t('about.story.investorsBody', locale) }}
</p>
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
<div
v-for="investor in investors"
:key="investor.name"
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
>
<img
:src="investor.icon"
:alt="investor.name"
class="max-h-8 w-auto"
/>
</div>
</div>
</div>
<!-- Quote card -->
<div
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
>
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
{{ t('about.quote.text', locale) }}
</p>
<p
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
>
{{ t('about.quote.attribution', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -1,121 +1,42 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { locale = 'en', departments = [] } = defineProps<{
locale?: Locale
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
interface Role {
title: string
department: string
location: string
id: string
}
interface Department {
name: string
key: string
roles: Role[]
}
const departments: Department[] = [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
id: 'abc787b9-ad85-421c-8218-debd23bea096'
},
{
title: 'Software Engineer',
department: 'Engineering',
location: 'San Francisco',
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
},
{
title: 'Product Manager',
department: 'Engineering',
location: 'London, UK',
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
},
{
title: 'Tech Lead Manager, Frontend',
department: 'Engineering',
location: 'San Francisco',
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
}
]
},
{
name: 'DESIGN',
key: 'design',
roles: [
{
title: 'Creative Director',
department: 'Design',
location: 'San Francisco',
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
},
{
title: 'Graphic Designer',
department: 'Design',
location: 'London, UK',
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
},
{
title: 'Freelance Motion Designer',
department: 'Design',
location: 'Remote',
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
}
]
},
{
name: 'MARKETING',
key: 'marketing',
roles: [
{
title: 'Lifecycle Growth Marketer',
department: 'Marketing',
location: 'San Francisco',
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
},
{
title: 'Graphic Designer',
department: 'Marketing',
location: 'London, UK',
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
}
]
}
]
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...departments.map((d) => ({ label: d.name, value: d.key }))
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? departments
: departments.filter((d) => d.key === activeCategory.value)
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
</script>
<template>
<section class="px-6 py-20 md:px-20 md:py-32">
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<!-- Left sidebar -->
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
@@ -126,6 +47,7 @@ const filteredDepartments = computed(() =>
{{ t('careers.roles.heading', locale) }}
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
class="mt-4"
@@ -133,8 +55,15 @@ const filteredDepartments = computed(() =>
</div>
</div>
<!-- Role listings -->
<div class="min-w-0 flex-1">
<p
v-if="!hasRoles"
class="text-primary-warm-gray text-base md:text-lg"
data-testid="careers-roles-empty"
>
{{ t('careers.roles.empty', locale) }}
</p>
<div
v-for="dept in filteredDepartments"
:key="dept.key"
@@ -147,10 +76,11 @@ const filteredDepartments = computed(() =>
<a
v-for="role in dept.roles"
:key="role.id"
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
:href="role.applyUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div class="min-w-0">
<span

View File

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

View File

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

View File

@@ -223,7 +223,7 @@ onUnmounted(() => {
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
<BrandButton
:href="externalLinks.platform"
:href="externalLinks.apiKeys"
size="lg"
class="text-center lg:min-w-60 lg:p-4"
>

View File

@@ -13,13 +13,13 @@ const steps = [
number: '01',
titleKey: 'api.steps.step1.title' as const,
descriptionKey: 'api.steps.step1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
number: '02',
titleKey: 'api.steps.step2.title' as const,
descriptionKey: 'api.steps.step2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
},
{
number: '03',
@@ -61,7 +61,7 @@ const steps = [
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<BrandButton
:href="externalLinks.cloud"
:href="externalLinks.apiKeys"
variant="solid"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"
@@ -69,7 +69,7 @@ const steps = [
{{ t('api.hero.getApiKeys', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.docs"
:href="externalLinks.docsApi"
variant="outline"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"

View File

@@ -10,12 +10,12 @@ const cards = [
{
titleKey: 'enterprise.byoKey.card1.title' as const,
descriptionKey: 'enterprise.byoKey.card1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
titleKey: 'enterprise.byoKey.card2.title' as const,
descriptionKey: 'enterprise.byoKey.card2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
}
]
</script>

View File

@@ -16,9 +16,11 @@ const midRightRef = ref<HTMLElement>()
const bottomLeftRef = ref<HTMLElement>()
const bottomRightRef = ref<HTMLElement>()
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
</script>
<template>

View File

@@ -44,162 +44,303 @@ onMounted(() => {
<svg
ref="svgRef"
class="block size-full"
viewBox="600 -50 1000 1100"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<!-- Ripple rings -->
<path
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
height="800"
transform="translate(712 112)"
fill="#211927"
/>
<!-- Exploding block cluster -->
<g stroke="#4D3762" stroke-width="2">
<!-- Ripple rings -->
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
class="ripple-path delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
class="ripple-path delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
class="ripple-path delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
<!-- Exploding block cluster -->
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
style="pointer-events: none"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
/>
<defs>
<linearGradient
id="enterpriseHeroFade"
@@ -212,6 +353,9 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>
</svg>
</div>
@@ -255,13 +399,13 @@ onMounted(() => {
animation: ripple-effect 4s linear infinite;
}
.ripple-delay-1 {
.delay-1 {
animation-delay: -1s;
}
.ripple-delay-2 {
.delay-2 {
animation-delay: -2s;
}
.ripple-delay-3 {
.delay-3 {
animation-delay: -3s;
}
@@ -281,6 +425,11 @@ onMounted(() => {
}
}
.block-cluster {
transform-origin: center;
transform-box: fill-box;
}
.block-piece {
transform-origin: center;
transform-box: fill-box;

View File

@@ -19,6 +19,8 @@ interface ParallaxOptions {
start?: string
/** ScrollTrigger end value (default: 'bottom top') */
end?: string
/** Media query string — animation only runs when matched (responsive) */
mediaQuery?: string
}
export function useParallax(
@@ -26,24 +28,27 @@ export function useParallax(
options: ParallaxOptions = {}
) {
const { fromY = 0, y = 200 } = options
let ctx: gsap.Context | undefined
let ctx: gsap.Context | gsap.MatchMedia | undefined
onMounted(() => {
if (prefersReducedMotion()) return
const triggerEl = options.trigger?.value
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length || prefersReducedMotion()) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
const createAnimations = () => {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
ctx = gsap.context(() => {
els.forEach((el) => {
gsap.fromTo(
el,
@@ -51,7 +56,15 @@ export function useParallax(
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
)
})
})
}
if (options.mediaQuery) {
const mm = gsap.matchMedia()
mm.add(options.mediaQuery, createAnimations)
ctx = mm
} else {
ctx = gsap.context(createAnimations)
}
})
onUnmounted(() => {

View File

@@ -27,6 +27,7 @@ export function getRoutes(locale: Locale = 'en'): Routes {
}
export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
discord: 'https://discord.com/invite/comfyorg',

View File

@@ -0,0 +1,169 @@
{
"fetchedAt": "2026-04-24T18:59:03.989Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
},
{
"id": "7bb02634a24763bc",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
}
]
},
{
"name": "ENGINEERING",
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
},
{
"id": "d01d69fba7743905",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
},
{
"id": "f36f60cfd5bb5910",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
},
{
"id": "9d8ec4c65e20b19e",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
},
{
"id": "be94b193d1f4d482",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
},
{
"id": "ab48f5db6bd1783c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
},
{
"id": "c5dff4ee628bdcd1",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
},
{
"id": "4302a7aaa87e16e3",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
}
]
},
{
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
},
{
"id": "130d7218d7895bdb",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
{
"name": "OPERATIONS",
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Senior Technical Recruiter",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
export interface Role {
id: string
title: string
department: string
location: string
applyUrl: string
}
export interface Department {
name: string
key: string
roles: Role[]
}
export interface RolesSnapshot {
fetchedAt: string
departments: Department[]
}

View File

@@ -4,12 +4,12 @@ const translations = {
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
'zh-CN': '视觉 AI 的\n专业控制'
'zh-CN': '视觉 AI 的\n最强可控性'
},
'hero.subtitle': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
},
// ProductShowcaseSection
@@ -20,11 +20,11 @@ const translations = {
},
'showcase.subtitle2': {
en: 'Start from a community template or build from scratch.',
'zh-CN': '从社区模板开始,或从零构建。'
'zh-CN': '从工作流模板开始,或从零构建。'
},
'showcase.feature1.title': {
en: 'Full Control with Nodes',
'zh-CN': '节点式完全控制'
'zh-CN': '节点带来的可控性'
},
'showcase.feature1.description': {
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
@@ -49,8 +49,8 @@ const translations = {
'zh-CN':
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
},
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运' },
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
// UseCaseSection
'useCase.label': {
@@ -83,8 +83,7 @@ const translations = {
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
},
'useCase.cta': {
en: 'EXPLORE WORKFLOWS',
@@ -164,7 +163,7 @@ const translations = {
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版性'
'zh-CN': '查看本地版性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -176,7 +175,7 @@ const translations = {
},
'products.cloud.cta': {
en: 'SEE CLOUD FEATURES',
'zh-CN': '查看云端性'
'zh-CN': '查看云端性'
},
'products.api.title': {
en: 'Comfy\nAPI',
@@ -188,7 +187,7 @@ const translations = {
},
'products.api.cta': {
en: 'SEE API FEATURES',
'zh-CN': '查看 API 性'
'zh-CN': '查看 API 性'
},
'products.enterprise.title': {
en: 'Comfy\nEnterprise',
@@ -200,7 +199,7 @@ const translations = {
},
'products.enterprise.cta': {
en: 'SEE ENTERPRISE FEATURES',
'zh-CN': '查看企业版性'
'zh-CN': '查看企业版性'
},
// CaseStudySpotlightSection
@@ -1215,7 +1214,7 @@ const translations = {
'pricing.included.feature4.description': {
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
'zh-CN':
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro。'
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro。'
},
'pricing.included.feature5.title': {
en: 'Add more credits anytime',
@@ -1245,12 +1244,12 @@ const translations = {
},
'pricing.included.feature8.title': {
en: 'Partner Nodes',
'zh-CN': '合作节点'
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',
@@ -1505,6 +1504,10 @@ const translations = {
// CareersRolesSection
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
'careers.roles.empty': {
en: 'No open roles right now. Check back soon.',
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
},
// CareersFAQSection
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },
@@ -3295,82 +3298,13 @@ const translations = {
en: 'Find your answer here',
'zh-CN': '在这里找到答案'
},
'contact.form.firstName': {
en: 'First name',
'zh-CN': ''
'contact.form.embedLoadErrorPrefix': {
en: 'Unable to load the contact form. Email us at',
'zh-CN': '联系表单无法加载。请发送邮件至'
},
'contact.form.lastName': {
en: 'Last Name',
'zh-CN': ''
},
'contact.form.company': {
en: 'Company',
'zh-CN': '公司'
},
'contact.form.phone': {
en: 'Phone Number (optional)',
'zh-CN': '电话号码(可选)'
},
'contact.form.packageQuestion': {
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
'zh-CN':
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K、个人套餐或团队套餐'
},
'contact.form.packageIndividual': {
en: 'INDIVIDUAL',
'zh-CN': '个人'
},
'contact.form.packageTeams': {
en: 'TEAMS',
'zh-CN': '团队'
},
'contact.form.packageEnterprise': {
en: 'ENTERPRISE',
'zh-CN': '企业'
},
'contact.form.usingComfy': {
en: 'Are you /your team currently using Comfy?',
'zh-CN': '您/您的团队目前是否在使用 Comfy'
},
'contact.form.usingYesProduction': {
en: 'Yes, in production',
'zh-CN': '是,在生产环境中'
},
'contact.form.usingYesTesting': {
en: 'Yes, testing / experimenting',
'zh-CN': '是,测试/实验中'
},
'contact.form.usingNotYet': {
en: 'Not yet, evaluating',
'zh-CN': '尚未使用,评估中'
},
'contact.form.usingOtherTools': {
en: 'Not using Comfy yet, but using other GenAI tools',
'zh-CN': '尚未使用 Comfy但在使用其他 GenAI 工具'
},
'contact.form.lookingFor': {
en: 'What are you looking for?',
'zh-CN': '您在寻找什么?'
},
'contact.form.lookingForPlaceholder': {
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
},
'contact.form.submit': {
en: 'SUBMIT',
'zh-CN': '提交'
},
'contact.form.firstNamePlaceholder': {
en: 'Jane',
'zh-CN': 'Jane'
},
'contact.form.lastNamePlaceholder': {
en: 'Smith',
'zh-CN': 'Smith'
},
'contact.form.companyPlaceholder': {
en: 'jane@acme.org',
'zh-CN': 'jane@acme.org'
'contact.form.embedLoadErrorSuffix': {
en: "and we'll route your request.",
'zh-CN': '我们会为您处理请求。'
},
'customers.story.whatsNext': {

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/about/HeroSection.vue'
import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../components/about/CareersSection.vue'
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />
<ValuesSection client:visible />
<CareersSection />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../components/careers/RolesSection.vue'
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../components/common/FAQSection.vue'
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
>
<HeroSection />
<RolesSection client:visible />
<RolesSection departments={departments} client:visible />
<WhyJoinSection client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/about/HeroSection.vue'
import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../../components/about/CareersSection.vue'
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />
<ValuesSection locale="zh-CN" client:visible />
<CareersSection locale="zh-CN" />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../../components/careers/RolesSection.vue'
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../../components/common/FAQSection.vue'
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" client:visible />
<RolesSection locale="zh-CN" departments={departments} client:visible />
<WhyJoinSection locale="zh-CN" client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -0,0 +1,130 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './ashby'
import type { RolesSnapshot } from '../data/roles'
import { reportAshbyOutcome, resetAshbyReporterForTests } from './ashby.ci'
function baseSnapshot(): RolesSnapshot {
return {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedRoles:
droppedCount === 0
? []
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
snapshot: {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
}
describe('reportAshbyOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetAshbyReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'ashby-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportAshbyOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportAshbyOutcome(freshOutcome(1))
reportAshbyOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby: dropped 1 invalid')
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby authentication failed')
})
it('emits ::warning for missing-env stale outcomes', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -0,0 +1,113 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
let hasReported = false
export function resetAshbyReporterForTests(): void {
hasReported = false
}
export function reportAshbyOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
const drops = outcome.droppedRoles
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
.join('%0A')
return [
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Ashby fetch failed and no snapshot is available::Cannot build careers page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website ashby:refresh-snapshot\` locally with a valid WEBSITE_ASHBY_API_KEY.%0A 2. Commit apps/website/src/data/ashby-roles.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
}
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 💼 Careers (Ashby)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Ashby)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Ashby fetch failed)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,17 @@
import { z } from 'zod'
export const AshbyJobPostingSchema = z.object({
title: z.string().min(1),
department: z.string().optional(),
location: z.string().optional(),
isListed: z.boolean(),
jobUrl: z.string().url(),
applyUrl: z.string().url().optional()
})
export const AshbyJobBoardResponseSchema = z.object({
apiVersion: z.literal('1'),
jobs: z.array(z.unknown())
})
export type AshbyJobPosting = z.infer<typeof AshbyJobPostingSchema>

View File

@@ -0,0 +1,328 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AshbyJobPosting } from './ashby.schema'
import type { RolesSnapshot } from '../data/roles'
import { fetchRolesForBuild, resetAshbyFetcherForTests } from './ashby'
const BASE_URL = 'https://ashby.test'
const BOARD = 'comfy-org'
const KEY = 'abc-123-secret'
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
return {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
isListed: true,
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer/apply',
...overrides
}
}
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(roleCount = 2): RolesSnapshot {
const roles = Array.from({ length: roleCount }, (_, i) => ({
id: `snapshot-role-${i}`,
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
departments: [{ name: 'ENGINEERING', key: 'engineering', roles }]
}
}
function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
const file = join(dir, 'ashby-roles.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchRolesForBuild', () => {
const savedApiKey = process.env.WEBSITE_ASHBY_API_KEY
const savedBoardName = process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
beforeEach(() => {
resetAshbyFetcherForTests()
delete process.env.WEBSITE_ASHBY_API_KEY
delete process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
})
it('returns fresh when the API succeeds', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})
it('drops invalid roles individually and keeps the valid ones', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments).toEqual([])
expect(outcome.droppedCount).toBe(0)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('normalizes missing department and location to safe defaults', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const job = validJob()
delete (job as Record<string, unknown>).department
delete (job as Record<string, unknown>).location
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const [department] = outcome.snapshot.departments
expect(department?.name).toBe('OTHER')
expect(department?.roles[0]?.location).toBe('Remote')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('filters out roles with isListed=false', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const titles = outcome.snapshot.departments.flatMap((d) =>
d.roles.map((r) => r.title)
)
expect(titles).not.toContain('Hidden')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns stale with missing env when the snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const opts = {
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
}
const [a, b] = await Promise.all([
fetchRolesForBuild(opts),
fetchRolesForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('never writes to the snapshot file on success', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const before = new URL(snapshotUrl.href)
const fs = await import('node:fs')
const initial = fs.readFileSync(before).toString()
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
const after = fs.readFileSync(before).toString()
expect(after).toBe(initial)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on 4xx auth failures for 403', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,299 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { AshbyJobPosting } from './ashby.schema'
import type { Department, Role, RolesSnapshot } from '../data/roles'
import bundledSnapshot from '../data/ashby-roles.snapshot.json' with { type: 'json' }
import {
AshbyJobBoardResponseSchema,
AshbyJobPostingSchema
} from './ashby.schema'
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
title: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: RolesSnapshot
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchRolesOptions {
apiKey?: string
boardName?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
export function resetAshbyFetcherForTests(): void {
inflight = undefined
}
export function fetchRolesForBuild(
options: FetchRolesOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchRolesForBuild(options)
return inflight
}
async function doFetchRolesForBuild(
options: FetchRolesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
const boardName =
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
if (!apiKey || !boardName) {
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
options.snapshotUrl
)
}
const result = await tryFetchAndParse(apiKey, boardName, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
departments: result.departments
},
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
departments: Department[]
droppedRoles: DroppedRole[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
boardName: string,
options: FetchRolesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/posting-api/job-board/${encodeURIComponent(
boardName
)}?includeCompensation=false`
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, authHeader, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = AshbyJobBoardResponseSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
return parseRoles(envelope.data.jobs)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
authHeader: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Authorization: authHeader,
Accept: 'application/json; version=1'
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
function parseRoles(jobs: readonly unknown[]): FetchOk {
const valid: AshbyJobPosting[] = []
const droppedRoles: DroppedRole[] = []
for (const raw of jobs) {
const parsed = AshbyJobPostingSchema.safeParse(raw)
if (!parsed.success) {
droppedRoles.push({
title: extractTitle(raw),
reason: parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')
})
continue
}
if (!parsed.data.isListed) continue
valid.push(parsed.data)
}
return { kind: 'ok', departments: groupByDepartment(valid), droppedRoles }
}
function extractTitle(raw: unknown): string {
if (
raw !== null &&
typeof raw === 'object' &&
'title' in raw &&
typeof (raw as { title: unknown }).title === 'string'
) {
return (raw as { title: string }).title
}
return ''
}
const DEFAULT_DEPARTMENT = 'Other'
const DEFAULT_LOCATION = 'Remote'
function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
const byKey = new Map<string, Department>()
for (const job of jobs) {
const displayDepartment = normalizeDepartment(job.department)
const name = displayDepartment.toUpperCase()
const key = slugify(name)
const existing = byKey.get(key)
const role = toDomainRole(job, displayDepartment)
if (existing) {
existing.roles.push(role)
} else {
byKey.set(key, { name, key, roles: [role] })
}
}
return [...byKey.values()].sort((a, b) => a.name.localeCompare(b.name))
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const applyUrl = job.applyUrl ?? job.jobUrl
return {
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
applyUrl
}
}
function normalizeDepartment(raw: string | undefined): string {
const trimmed = (raw ?? '').trim()
return trimmed.length > 0 ? trimmed : DEFAULT_DEPARTMENT
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<RolesSnapshot | null> {
if (!snapshotUrl) {
return isRolesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isRolesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function isRolesSnapshot(value: unknown): value is RolesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
return (
typeof candidate.fetchedAt === 'string' &&
Array.isArray(candidate.departments)
)
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

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

View File

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

View File

@@ -8,8 +8,10 @@
"include": [
"src/**/*",
"e2e/**/*",
"scripts/**/*",
"astro.config.ts",
"playwright.config.ts"
"playwright.config.ts",
"vitest.config.ts"
],
"exclude": ["src/**/*.stories.ts"],
"references": [{ "path": "./tsconfig.stories.json" }]

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.ts'],
globals: false
}
})

View File

@@ -15,11 +15,15 @@ browser_tests/
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object components (locators, user interactions)
│ ├── components/ - Page object classes (locators, user interactions)
│ │ ├── Actionbar.ts
│ │ ├── ContextMenu.ts
│ │ ├── ManageGroupNode.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ ── Topbar.ts
│ │ ── Templates.ts
│ │ ├── Topbar.ts
│ │ └── ...
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
@@ -28,17 +32,36 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Pure utility functions (no page dependency)
├── helpers/ - Test-specific utilities
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
│ ├── builderTestUtils.ts
│ ├── clipboardSpy.ts
│ ├── fitToView.ts
│ ├── perfReporter.ts
│ └── ...
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
### Placement Rule
When adding a new file, use this decision tree:
```mermaid
flowchart TD
A[New file in browser_tests/fixtures/] --> B{Has any code?}
B -- No, JSON/data only --> D[fixtures/data/]
B -- Yes --> C{Is it a class?}
C -- No, exported functions --> U[fixtures/utils/]
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
E -- Yes --> P[fixtures/components/]
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
```
## Page Object Locator Style

View File

@@ -51,6 +51,7 @@ DISABLE_VUE_PLUGINS=true
# Test against dev server (recommended) or backend directly
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
# Path to ComfyUI for backing up user data/settings before tests
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
@@ -139,12 +140,9 @@ Always check for existing helpers and fixtures before implementing new ones:
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
- **ComfyMouse**: Helper for precise mouse operations on the canvas
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
- `actionbar.ts`: Interact with the action bar
- `manageGroupNode.ts`: Group node management operations
- `templates.ts`: Template workflows operations
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.

View File

@@ -0,0 +1,74 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [50, 50],
"size": [400, 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": "Painter",
"pos": [450, 50],
"size": [450, 550],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,4 +1,4 @@
import type { Mouse } from '@playwright/test'
import type { Locator, Mouse } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
@@ -72,6 +72,22 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.nextFrame()
}
async resizeByDragging(
element: Locator,
{ x, y }: { x?: number; y?: number }
) {
const elementBox = await element.boundingBox()
if (!elementBox) throw new Error('element should have layout')
const cx = elementBox.x + elementBox.width / 2
const cy = elementBox.y + elementBox.height / 2
await this.dragAndDrop(
{ x: cx, y: cy },
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
)
}
//#region Pass-through
async click(...args: Parameters<Mouse['click']>) {
return await this.mouse.click(...args)

View File

@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/helpers/actionbar'
import { ComfyTemplates } from '@e2e/helpers/templates'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
@@ -22,6 +22,7 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import {
AssetsSidebarTab,
ModelLibrarySidebarTab,
@@ -54,11 +55,13 @@ class ComfyPropertiesPanel {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
}
}
@@ -137,6 +140,7 @@ class ComfyMenu {
export class ComfyPage {
public readonly url: string
public readonly apiUrl: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
public readonly selectionToolbox: Locator
@@ -159,6 +163,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly titleEditor: TitleEditor
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
@@ -195,6 +200,7 @@ export class ComfyPage {
public readonly request: APIRequestContext
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
this.canvas = page.locator('#graph-canvas')
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
@@ -204,13 +210,14 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.titleEditor = new TitleEditor(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
@@ -236,7 +243,7 @@ export class ComfyPage {
}
async setupUser(username: string) {
const res = await this.request.get(`${this.url}/api/users`)
const res = await this.request.get(`${this.apiUrl}/api/users`)
if (res.status() !== 200)
throw new Error(`Failed to retrieve users: ${await res.text()}`)
@@ -250,7 +257,7 @@ export class ComfyPage {
}
async createUser(username: string) {
const resp = await this.request.post(`${this.url}/api/users`, {
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
data: { username }
})
@@ -262,7 +269,7 @@ export class ComfyPage {
async setupSettings(settings: Record<string, unknown>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
`${this.apiUrl}/api/devtools/set_settings`,
{
data: settings
}

View File

@@ -1,29 +1,112 @@
import type { Locator, Page } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const { searchBoxV2 } = TestIds
export type { RootCategoryId }
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
readonly filterChips: Locator
readonly noResults: Locator
readonly nodeIdBadge: Locator
readonly sidebarToggle: Locator
readonly sidebarBackdrop: Locator
readonly filterChipsScroll: Locator
constructor(readonly page: Page) {
constructor(private comfyPage: ComfyPage) {
const page = comfyPage.page
this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]')
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle)
this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop)
this.filterChipsScroll = this.dialog.getByTestId(
searchBoxV2.filterChipsScroll
)
}
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
categoryButton(categoryId: string): Locator {
return this.dialog.getByTestId(`category-${categoryId}`)
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
}
filterBarButton(name: string): Locator {
return this.dialog.getByRole('button', { name })
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
rootCategoryButton(id: RootCategoryId): Locator {
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
}
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
/** Top filter-bar input/output type popover trigger. */
typeFilterButton(key: 'input' | 'output'): Locator {
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
}
async applyTypeFilter(
key: 'input' | 'output',
typeName: string
): Promise<void> {
const trigger = this.typeFilterButton(key)
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'visible' })
await this.filterSearch.fill(typeName)
await this.filterOptions.filter({ hasText: typeName }).first().click()
// The popover does not auto-close on selection — toggle the trigger.
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'hidden' })
}
async removeFilterChip(index = 0): Promise<void> {
await this.filterChips
.nth(index)
.getByTestId(searchBoxV2.chipDelete)
.click()
}
async toggle(): Promise<void> {
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
}
async open(): Promise<void> {
if (await this.input.isVisible()) return
await this.toggle()
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
await this.comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
}
async setup(): Promise<void> {
await this.ensureV2Search()
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
}
}

View File

@@ -4,11 +4,13 @@ import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
}
@@ -39,7 +41,10 @@ export class ContextMenu {
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible
const litegraphContextVisible = await this.litegraphContextMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible || litegraphContextVisible
}
async assertHasItems(items: string[]): Promise<void> {
@@ -71,7 +76,8 @@ export class ContextMenu {
async waitForHidden(): Promise<void> {
await Promise.all([
this.primeVueMenu.waitFor({ state: 'hidden' }),
this.litegraphMenu.waitFor({ state: 'hidden' })
this.litegraphMenu.waitFor({ state: 'hidden' }),
this.litegraphContextMenu.waitFor({ state: 'hidden' })
])
}
}

View File

@@ -0,0 +1,72 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
import { TestIds } from '@e2e/fixtures/selectors'
export class PublishDialog extends BaseDialog {
readonly nav: Locator
readonly footer: Locator
readonly savePrompt: Locator
readonly describeStep: Locator
readonly finishStep: Locator
readonly profilePrompt: Locator
readonly gateFlow: Locator
readonly nameInput: Locator
readonly descriptionTextarea: Locator
readonly tagsInput: Locator
readonly backButton: Locator
readonly nextButton: Locator
readonly publishButton: Locator
constructor(page: Page) {
super(page, TestIds.publish.dialog)
this.nav = this.root.getByTestId(TestIds.publish.nav)
this.footer = this.root.getByTestId(TestIds.publish.footer)
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
this.descriptionTextarea = this.describeStep.locator('textarea')
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
this.backButton = this.footer.getByRole('button', { name: 'Back' })
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
this.publishButton = this.footer.getByRole('button', {
name: 'Publish to ComfyHub'
})
}
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
// dev and production, rather than clicking through the UI.
async open(): Promise<void> {
await this.page.evaluate(async () => {
await window.app!.extensionManager.dialog.showPublishDialog()
})
await this.waitForVisible()
}
tagSuggestion(name: string): Locator {
return this.describeStep.getByText(name, { exact: true })
}
navStep(label: string): Locator {
return this.nav.getByRole('button', { name: label })
}
currentNavStep(): Locator {
return this.nav.locator('[aria-current="step"]')
}
async goNext(): Promise<void> {
await this.nextButton.click()
}
async goBack(): Promise<void> {
await this.backButton.click()
}
async goToStep(label: string): Promise<void> {
await this.navStep(label).click()
}
}

View File

@@ -250,6 +250,26 @@ export class ModelLibrarySidebarTab extends SidebarTab {
}
}
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
function getMediaFilterLabel(
filter: MediaFilterKind | MediaFilterLabel
): MediaFilterLabel {
switch (filter) {
case 'image':
return 'Image'
case 'video':
return 'Video'
case 'audio':
return 'Audio'
case '3d':
return '3D'
default:
return filter
}
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly generatedTab: Locator
@@ -261,6 +281,13 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
public readonly filterButton: Locator
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
public readonly filterImageCheckbox: Locator
public readonly filterVideoCheckbox: Locator
public readonly filterAudioCheckbox: Locator
public readonly filter3DCheckbox: Locator
// --- View mode ---
public readonly listViewOption: Locator
@@ -269,6 +296,8 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
public readonly sortLongestFirst: Locator
public readonly sortFastestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
@@ -299,10 +328,17 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.filterButton = page.getByRole('button', { name: 'Filter by' })
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.sortLongestFirst = page.getByText('Generation time (longest first)')
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
this.assetCards = page
.getByRole('button')
.and(page.locator('[data-selected]'))
@@ -334,6 +370,12 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
return this.page.getByRole('checkbox', {
name: getMediaFilterLabel(filter)
})
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
}
@@ -383,6 +425,29 @@ export class AssetsSidebarTab extends SidebarTab {
.waitFor({ state: 'visible', timeout: 3000 })
}
async openFilterMenu() {
await this.dismissToasts()
await this.filterButton.click()
await this.filterCheckbox('Image').waitFor({
state: 'visible',
timeout: 3000
})
}
async toggleMediaTypeFilter(
filter: MediaFilterKind | MediaFilterLabel
): Promise<void> {
const checkbox = this.filterCheckbox(filter)
const before = await checkbox.getAttribute('aria-checked')
await checkbox.click()
const expected = before === 'true' ? 'false' : 'true'
await expect(checkbox).toHaveAttribute('aria-checked', expected)
}
async getAssetCardOrder(): Promise<string[]> {
return await this.assetCards.allInnerTexts()
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })

View File

@@ -10,6 +10,8 @@ export class SubgraphBreadcrumbPanel {
readonly activeItem: Locator
readonly missingNodesIcon: Locator
readonly blueprintTag: Locator
readonly rootItem: Locator
readonly rootBlueprintTag: Locator
constructor(public readonly page: Page) {
this.root = page.getByTestId(TestIds.breadcrumb.subgraph)
@@ -23,10 +25,10 @@ export class SubgraphBreadcrumbPanel {
TestIds.breadcrumb.missingNodesIcon
)
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
}
rootItem(): Locator {
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootBlueprintTag = this.rootItem.getByTestId(
TestIds.breadcrumb.blueprintTag
)
}
subgraphItem(subgraphId: string): Locator {

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The node/group title-editing input. Rendered in three scopes: the canvas
* overlay (page-wide), the properties panel, and the Vue node itself.
*/
export class TitleEditor {
public readonly input: Locator
constructor(scope: Page | Locator) {
this.input = scope.getByTestId(TestIds.node.titleInput)
}
async setTitle(title: string): Promise<void> {
await this.input.fill(title)
await this.input.press('Enter')
}
async cancel(): Promise<void> {
await this.input.press('Escape')
}
async expectVisible(): Promise<void> {
await expect(this.input).toBeVisible()
}
async expectHidden(): Promise<void> {
await expect(this.input).toBeHidden()
}
}

View File

@@ -1,32 +1,72 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type {
CreateAssetExportData,
CreateAssetExportResponse,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobsListRoutePattern = '**/api/jobs?*'
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
const assetExportRoutePattern = '**/api/assets/export'
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
/**
* Media kinds supported by the assets sidebar filter UI. The string values
* match what the backend stores on `preview_output.mediaType` (`images` is
* intentionally plural to match existing API conventions; the others are
* singular as emitted by `useMediaAssetGalleryStore`).
*
* The sidebar filter ultimately matches on the filename extension, so the
* fixture also picks an extension-appropriate filename for each kind.
*/
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
images: 'png',
video: 'mp4',
audio: 'wav',
'3D': 'glb'
}
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
overrides: Partial<RawJobListItem> & {
id: string
/**
* Optional shorthand to set both `preview_output.mediaType` and an
* extension-appropriate filename. Ignored when `preview_output` is also
* supplied via `overrides`.
*/
mediaKind?: MediaKindFixture
}
): RawJobListItem {
const { mediaKind, ...rest } = overrides
const now = Date.now()
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
const mediaType = mediaKind ?? 'images'
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${overrides.id}.png`,
filename: `output_${rest.id}.${extension}`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
mediaType
},
outputs_count: 1,
priority: 0,
...overrides
...rest
}
}
@@ -54,6 +94,46 @@ export function createMockJobs(
)
}
/**
* Create one job per requested media kind, in the order supplied. Jobs share
* a stable timestamp ordering (newer first) so callers can rely on the result
* order when mediaType filters are inactive.
*/
export function createMixedMediaJobs(
kinds: MediaKindFixture[]
): RawJobListItem[] {
const now = Date.now()
return kinds.map((kind, i) =>
createMockJob({
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
mediaKind: kind,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000
})
)
}
/**
* Create jobs with explicit `(create_time, execution duration)` pairs so that
* sort assertions for newest/oldest and longest/fastest are unambiguous.
*
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
* equals `durationMs`. The first spec becomes id `job-001`, etc.
*/
export function createJobsWithExecutionTimes(
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
): RawJobListItem[] {
return specs.map((spec, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: spec.createTime,
execution_start_time: spec.createTime,
execution_end_time: spec.createTime + spec.durationMs
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
@@ -88,12 +168,23 @@ function getExecutionDuration(job: RawJobListItem): number {
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
null
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private cloudAssetsResponse: ListAssetsResponse | null = null
private assetExportRequests: CreateAssetExportData['body'][] = []
private assetExportResponse: CreateAssetExportResponse | null = null
private importedFiles: string[] = []
private readonly jobDetailRouteHandlers = new Map<
string,
(route: Route) => Promise<void>
>()
constructor(private readonly page: Page) {}
@@ -170,6 +261,82 @@ export class AssetsHelper {
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
this.cloudAssetsResponse = response
if (this.cloudAssetsRouteHandler) {
return
}
this.cloudAssetsRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.cloudAssetsResponse)
})
}
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
}
async mockEmptyCloudAssets(): Promise<void> {
await this.mockCloudAssets({
assets: [],
total: 0,
has_more: false
})
}
async captureAssetExportRequests(
response: CreateAssetExportResponse = {
task_id: 'asset-export-task',
status: 'created'
}
): Promise<CreateAssetExportData['body'][]> {
this.assetExportRequests = []
this.assetExportResponse = response
if (this.assetExportRouteHandler) {
return this.assetExportRequests
}
this.assetExportRouteHandler = async (route: Route) => {
this.assetExportRequests.push(
route.request().postDataJSON() as CreateAssetExportData['body']
)
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(this.assetExportResponse)
})
}
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
return this.assetExportRequests
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
if (existingHandler) {
await this.page.unroute(pattern, existingHandler)
}
const handler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(detail)
})
}
this.jobDetailRouteHandlers.set(pattern, handler)
await this.page.route(pattern, handler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
@@ -225,6 +392,9 @@ export class AssetsHelper {
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.cloudAssetsResponse = null
this.assetExportRequests = []
this.assetExportResponse = null
this.importedFiles = []
if (this.jobsRouteHandler) {
@@ -232,6 +402,22 @@ export class AssetsHelper {
this.jobsRouteHandler = null
}
if (this.cloudAssetsRouteHandler) {
await this.page.unroute(
assetsListRoutePattern,
this.cloudAssetsRouteHandler
)
this.cloudAssetsRouteHandler = null
}
if (this.assetExportRouteHandler) {
await this.page.unroute(
assetExportRoutePattern,
this.assetExportRouteHandler
)
this.assetExportRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
@@ -247,5 +433,10 @@ export class AssetsHelper {
)
this.deleteHistoryRouteHandler = null
}
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
await this.page.unroute(pattern, handler)
}
this.jobDetailRouteHandlers.clear()
}
}

View File

@@ -74,7 +74,7 @@ export class CanvasHelper {
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
@@ -150,6 +150,28 @@ export class CanvasHelper {
await nextFrame(this.page)
}
async getOffset(): Promise<[number, number]> {
return this.page.evaluate(
() => [...window.app!.canvas.ds.offset] as [number, number]
)
}
async getNodeTitleHeight(): Promise<number> {
return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT)
}
/**
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
* coordinates.
*/
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
await this.page.keyboard.down('Control')
await this.page.keyboard.down('Shift')
await this.dragAndDrop(from, to)
await this.page.keyboard.up('Shift')
await this.page.keyboard.up('Control')
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
@@ -242,11 +264,39 @@ export class CanvasHelper {
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
async disconnectEdge(
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
): Promise<void> {
const { modifiers = [] } = options
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
}
async middleClick(position: Position): Promise<void> {
await this.mouseClickAt(position, { button: 'middle' })
}
async dblclickGroupTitle(title: string): Promise<void> {
const clientPos = await this.page.evaluate((targetTitle) => {
const groups = window.app!.canvas.graph?.groups ?? []
const group = groups.find(
(g: { title: string }) => g.title === targetTitle
)
if (!group) return null
const cx = group.pos[0] + group.size[0] / 2
const cy = group.pos[1] + group.titleHeight / 2
return window.app!.canvasPosToClientPos([cx, cy])
}, title)
if (!clientPos) throw new Error(`Group "${title}" not found`)
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
await nextFrame(this.page)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {

View File

@@ -4,7 +4,7 @@ import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
export class ClipboardHelper {
constructor(

View File

@@ -13,7 +13,11 @@ import type { Page } from '@playwright/test'
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
constructor(private readonly page: Page) {}
private readonly appUrl: string
constructor(private readonly page: Page) {
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
@@ -34,7 +38,7 @@ export class CloudAuthHelper {
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto('http://localhost:8188/api/users')
await this.page.goto(`${this.appUrl}/api/users`)
await this.page.evaluate(() => {
const MOCK_USER_DATA = {

View File

@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'

View File

@@ -1,6 +1,10 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
@@ -39,6 +43,48 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and position is provided, a synthetic MouseEvent is created as the
* dragEvent.
* @param position - When ghost is true, client coordinates for the ghost
* placement dragEvent. Otherwise, world coordinates assigned to node.pos.
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
position?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, pos]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && pos) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: pos.x,
clientY: pos.y
})
} else if (pos) {
node.pos = [pos.x, pos.y]
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, position ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Remove all nodes from the graph and clean. */
async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)

View File

@@ -0,0 +1,231 @@
import type { Page, Route } from '@playwright/test'
import type {
AssetInfo,
HubAssetUploadUrlResponse,
HubLabelInfo,
HubLabelListResponse,
HubProfile,
WorkflowPublishInfo
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
const DEFAULT_PROFILE: HubProfile = {
username: 'testuser',
display_name: 'Test User',
description: 'A test creator',
avatar_url: undefined
}
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
{ name: 'anime', display_name: 'anime', type: 'tag' },
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
]
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
workflow_id: 'test-workflow-id-456',
share_id: 'test-share-id-123',
publish_time: new Date().toISOString(),
listed: true,
assets: []
}
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
upload_url: 'https://mock-s3.example.com/upload',
public_url: 'https://mock-s3.example.com/asset.png',
token: 'mock-upload-token'
}
export class PublishApiHelper {
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(private readonly page: Page) {}
async mockProfile(profile: HubProfile | null): Promise<void> {
await this.addRoute('**/hub/profiles/me', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (profile === null) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(profile)
})
}
})
}
async mockTagLabels(
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
): Promise<void> {
const response: HubLabelListResponse = { labels }
await this.addRoute('**/hub/labels**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishStatus(
status: 'unpublished' | WorkflowPublishInfo
): Promise<void> {
await this.addRoute('**/userdata/*/publish', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (status === 'unpublished') {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(status)
})
}
})
}
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
const response: ShareableAssetsResponse = { assets }
await this.addRoute('**/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflow(
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflowError(
statusCode = 500,
message = 'Failed to publish workflow'
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: statusCode,
contentType: 'application/json',
body: JSON.stringify({ message })
})
})
}
async mockUploadUrl(
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
): Promise<void> {
await this.addRoute('**/hub/assets/upload-url', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async setupDefaultMocks(options?: {
hasProfile?: boolean
hasPrivateAssets?: boolean
}): Promise<void> {
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
await this.mockTagLabels()
await this.mockPublishStatus('unpublished')
await this.mockShareableAssets(
hasPrivateAssets
? [
{
id: 'asset-1',
name: 'my_model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: true
}
]
: []
)
await this.mockPublishWorkflow()
await this.mockUploadUrl()
}
async cleanup(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
}
private async addRoute(
pattern: string,
handler: (route: Route) => Promise<void>
): Promise<void> {
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
private async removeRoutes(pattern: string): Promise<void> {
const handlers = this.routeHandlers.filter(
(route) => route.pattern === pattern
)
for (const { handler } of handlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = this.routeHandlers.filter(
(route) => route.pattern !== pattern
)
}
}
export const publishFixture = comfyPageFixture.extend<{
publishApi: PublishApiHelper
publishDialog: PublishDialog
}>({
publishApi: async ({ comfyPage }, use) => {
const helper = new PublishApiHelper(comfyPage.page)
await use(helper)
await helper.cleanup()
},
publishDialog: async ({ comfyPage }, use) => {
await use(new PublishDialog(comfyPage.page))
}
})

View File

@@ -59,6 +59,9 @@ export const TestIds = {
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
@@ -83,7 +86,11 @@ export const TestIds = {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
subscribeButton: 'topbar-subscribe-button',
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -203,12 +210,25 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
publish: {
dialog: 'publish-dialog',
savePrompt: 'publish-save-prompt',
describeStep: 'publish-describe-step',
finishStep: 'publish-finish-step',
footer: 'publish-footer',
profilePrompt: 'publish-profile-prompt',
nav: 'publish-nav',
gateFlow: 'publish-gate-flow',
nameInput: 'publish-name-input',
tagsInput: 'publish-tags-input'
},
loading: {
overlay: 'loading-overlay'
},
@@ -234,6 +254,20 @@ export const TestIds = {
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
},
searchBoxV2: {
resultItem: 'result-item',
filterOption: 'filter-option',
filterChip: 'filter-chip',
chipDelete: 'chip-delete',
noResults: 'no-results',
nodeIdBadge: 'node-id-badge',
sidebarToggle: 'toggle-category-sidebar',
sidebarBackdrop: 'sidebar-backdrop',
filterChipsScroll: 'filter-chips-scroll',
category: (id: string) => `category-${id}`,
rootCategory: (id: string) => `search-category-${id}`,
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
}
} as const

View File

@@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
interface BuilderSetupResult {
inputNodeTitle: string

View File

@@ -1,7 +1,8 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
@@ -169,6 +170,36 @@ class NodeSlotReference {
[this.type, this.node.id, this.index] as const
)
}
async getLink(): Promise<SerialisableLLink | null> {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const graph = window.app!.canvas.graph!
const node = graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const linkId =
type === 'input'
? node.inputs[index].link
: (node.outputs[index].links ?? [])[0]
if (linkId == null) return null
const link =
graph.links instanceof Map
? graph.links.get(linkId)
: graph.links[linkId]
if (!link) return null
return {
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
target_id: link.target_id,
target_slot: link.target_slot,
type: link.type,
parentId: link.parentId
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
@@ -326,6 +357,23 @@ export class NodeReference {
const nodeSize = await this.getSize()
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
}
async dragBy(
delta: Position,
options?: {
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const titlePos = await this.getTitlePosition()
const target = { x: titlePos.x + delta.x, y: titlePos.y + delta.y }
const modifiers = options?.modifiers ?? []
const keyboard = this.comfyPage.page.keyboard
for (const mod of modifiers) await keyboard.down(mod)
try {
await this.comfyPage.canvasOps.dragAndDrop(titlePos, target)
} finally {
for (const mod of modifiers) await keyboard.up(mod)
}
}
async isPinned() {
return !!(await this.getFlags()).pinned
}

View File

@@ -56,7 +56,13 @@ export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let entries
try {
entries = readdirSync('test-results', { withFileTypes: true })
} catch {
return
}
if (!entries.length) return
let tempFiles: string[]
try {

View File

@@ -1,6 +1,6 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type PromotedWidgetEntry = [string, string]
export type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (

View File

@@ -1,12 +1,13 @@
import type { Locator } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
public readonly title: Locator
public readonly titleInput: Locator
public readonly titleEditor: TitleEditor
public readonly body: Locator
public readonly pinIndicator: Locator
public readonly collapseButton: Locator
@@ -16,7 +17,7 @@ export class VueNodeFixture {
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
this.title = locator.getByTestId('node-title')
this.titleInput = locator.getByTestId('node-title-input')
this.titleEditor = new TitleEditor(locator)
this.body = locator.locator('[data-testid^="node-body-"]')
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
this.collapseButton = locator.getByTestId('node-collapse-button')
@@ -30,17 +31,8 @@ export class VueNodeFixture {
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.fill(value)
await input.press('Enter')
}
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.press('Escape')
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(value)
}
async toggleCollapse(): Promise<void> {

View File

@@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig'
import { writePerfReport } from '@e2e/helpers/perfReporter'
import { writePerfReport } from '@e2e/fixtures/utils/perfReporter'
import { restorePath } from '@e2e/utils/backupUtils'
dotenvConfig()

View File

@@ -0,0 +1,140 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const ICON_CLASS = 'icon-[lucide--star]'
const BUTTON_LABEL = 'Test Action'
const BUTTON_TOOLTIP = 'Test action tooltip'
async function registerTestButton(
page: Page,
opts: {
name?: string
icon?: string
label?: string
tooltip?: string
} = {}
): Promise<void> {
await page.evaluate(
({ name, icon, label, tooltip }) => {
window.app!.registerExtension({
name,
actionBarButtons: [{ icon, label, tooltip, onClick: () => {} }]
})
},
{
name: opts.name ?? 'TestActionBarButton',
icon: opts.icon ?? ICON_CLASS,
label: opts.label ?? BUTTON_LABEL,
tooltip: opts.tooltip ?? BUTTON_TOOLTIP
}
)
}
test.describe('ActionBar Buttons', { tag: ['@ui'] }, () => {
test.describe('Empty state', () => {
test('container is hidden when no extension registers buttons', async ({
comfyPage
}) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.actionBarButtons)
).toBeHidden()
})
})
test.describe('Button rendering', () => {
test('registered button is visible with correct label', async ({
comfyPage
}) => {
await registerTestButton(comfyPage.page)
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(container).toBeVisible()
await expect(
container.getByRole('button', { name: BUTTON_TOOLTIP })
).toBeVisible()
await expect(container.getByText(BUTTON_LABEL)).toBeVisible()
})
test('button icon is rendered', async ({ comfyPage }) => {
await registerTestButton(comfyPage.page)
const icon = comfyPage.page
.getByTestId(TestIds.topbar.actionBarButtons)
.getByRole('button', { name: BUTTON_TOOLTIP })
.locator('i')
await expect(icon).toHaveClass(ICON_CLASS)
})
test('multiple registered buttons all appear', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestActionBarButtons',
actionBarButtons: [
{
icon: 'icon-[lucide--star]',
label: 'First',
tooltip: 'First action',
onClick: () => {}
},
{
icon: 'icon-[lucide--heart]',
label: 'Second',
tooltip: 'Second action',
onClick: () => {}
}
]
})
})
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(
container.getByRole('button', { name: 'First action' })
).toBeVisible()
await expect(
container.getByRole('button', { name: 'Second action' })
).toBeVisible()
})
})
test.describe('Click handler', () => {
test('clicking a button fires its onClick handler', async ({
comfyPage
}) => {
const onClickFired = comfyPage.page.evaluate(
({ icon, label, tooltip }) =>
new Promise<boolean>((resolve) => {
window.app!.registerExtension({
name: 'TestActionBarButton',
actionBarButtons: [
{ icon, label, tooltip, onClick: () => resolve(true) }
]
})
}),
{ icon: ICON_CLASS, label: BUTTON_LABEL, tooltip: BUTTON_TOOLTIP }
)
const button = comfyPage.page
.getByTestId(TestIds.topbar.actionBarButtons)
.getByRole('button', { name: BUTTON_TOOLTIP })
await button.click()
await expect(onClickFired).resolves.toBe(true)
})
})
test.describe('Mobile layout', { tag: ['@mobile'] }, () => {
test('button label is hidden on mobile viewport', async ({ comfyPage }) => {
await registerTestButton(comfyPage.page)
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(container).toBeVisible()
await expect(container.getByText(BUTTON_LABEL)).toBeHidden()
})
})
})

View File

@@ -2,7 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
test.describe('App mode arrange step', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -3,8 +3,8 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
const RESIZE_NODE_ID = '1'

View File

@@ -5,7 +5,7 @@ import {
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '@e2e/helpers/builderTestUtils'
} from '@e2e/fixtures/utils/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/helpers/clipboardSpy'
} from '@e2e/fixtures/utils/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)

View File

@@ -8,7 +8,7 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/helpers/builderTestUtils'
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']

View File

@@ -8,8 +8,8 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
} from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
/**
* After a first save, open save-as again from the chevron,

View File

@@ -0,0 +1,175 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
nodeBounds: Size,
padding: number,
titleHeight: number
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
const DRAG_DELTA = { x: 550, y: 330 } as const
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
})
await note.centerOnNode()
return note
}
test('shift+drag rounds final node position to multiples of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 100 = (600, 300)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(600)
expect(after[1]).toBe(300)
})
test('grid size determines the snap multiple', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 50)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 50 = (550, 350)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(550)
expect(after[1]).toBe(350)
})
test('drag without shift bypasses snap regardless of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
const before = await note.getProperty<[number, number]>('pos')
await note.dragBy(DRAG_DELTA)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(before[0] + DRAG_DELTA.x)
expect(after[1]).toBe(before[1] + DRAG_DELTA.y)
})
})
test.describe('Comfy.GroupSelectedNodes.Padding', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
)
await comfyPage.command.executeCommand('Comfy.Canvas.SelectAll')
await comfyPage.command.executeCommand('Comfy.Graph.GroupSelectedNodes')
return comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return { width: group.size[0], height: group.size[1] }
})
}
test('padding=0 makes the group exactly enclose the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 0)
expect(group).toEqual(expectedGroupSize(nodeBounds, 0, titleHeight))
})
test('padding=50 grows the group by 100 around the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 50)
expect(group).toEqual(expectedGroupSize(nodeBounds, 50, titleHeight))
})
})
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const fileCombo = await loadImage.getWidget(0)
await fileCombo.click()
}
test('combo widget popup is scaled when setting is enabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('LiteGraph.ContextMenu.Scaling', true)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS(
'transform',
`matrix(${ZOOM_SCALE}, 0, 0, ${ZOOM_SCALE}, 0, 0)`
)
})
test('combo widget popup is not scaled when setting is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.ContextMenu.Scaling',
false
)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS('transform', 'none')
})
})
})

View File

@@ -0,0 +1,400 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow is expected to contain exactly two CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const p1 = await clipNodes[0].getPosition()
const p2 = await clipNodes[1].getPosition()
const margin = 64
const from = await comfyPage.canvasOps.toAbsolute({
x: Math.min(p1.x, p2.x) - margin,
y: Math.min(p1.y, p2.y) - margin
})
const to = await comfyPage.canvasOps.toAbsolute({
x: Math.max(p1.x, p2.x) + margin,
y: Math.max(p1.y, p2.y) + margin
})
return { from, to }
}
test.describe('Canvas settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Graph.CanvasInfo', () => {
test(
'toggles the bottom-left HUD',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const box = await comfyPage.canvas.boundingBox()
expect(box, 'Canvas bounding box must be available').not.toBeNull()
// HUD is drawn ~80px tall along the bottom edge of the canvas; grab a
// comfortable 180px × 160px strip to catch it across viewports.
const HUD_WIDTH = 180
const HUD_HEIGHT = 160
const hudClip = {
x: box!.x,
y: box!.y + box!.height - HUD_HEIGHT,
width: HUD_WIDTH,
height: HUD_HEIGHT
}
await test.step('Capture HUD region with setting off', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
await comfyPage.canvasOps.resetView()
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
await test.step('Capture HUD region with setting on', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}
)
})
test.describe('Comfy.Graph.CtrlShiftZoom', () => {
const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 }
const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 }
test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.not.toBeCloseTo(initialScale, 2)
})
test('Ctrl+Shift+drag does not zoom when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2)
})
})
test.describe('Comfy.Graph.LiveSelection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
})
test('selects nodes mid-drag when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
})
test('defers selection to drag end when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
await comfyPage.page.mouse.up()
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
const WHEEL_POS = { x: 400, y: 400 }
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'zoom'
)
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo(
initialScale,
3
)
})
test('wheel pans when set to panning', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
)
const initialScale = await comfyPage.canvasOps.getScale()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(1)
})
})
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
test('override to panning makes empty left-drag pan the canvas', async ({
comfyPage
}) => {
await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
})
await comfyPage.canvasOps.resetView()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.canvasOps.dragAndDrop(
{ x: 200, y: 300 },
{ x: 400, y: 500 }
)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(50)
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
})
test('override to select turns empty left-drag into a selection rectangle', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.canvasOps.dragAndDrop(from, to)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Pointer settings', () => {
/**
* Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
const holdDragAt = async (
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) => {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
await sleep(opts.holdMs)
await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
}
test('DoubleClickTime controls whether two clicks open the title editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow must have CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const titlePos = await clipNodes[0].getTitlePosition()
const CLICK_GAP_MS = 200
await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
100
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectHidden()
})
await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
1000
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectVisible()
})
})
test('ClickBufferTime governs the click-vs-drag time threshold', async ({
comfyPage
}) => {
// Keep drift generous so only elapsed time distinguishes click vs drag.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 2
const HOLD_MS = 250
await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.ClickBufferTime',
2000
)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
test('ClickDrift governs the click-vs-drag distance threshold', async ({
comfyPage
}) => {
// Keep buffer generous so only drift distance matters.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 8
await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
})
test.describe('LiteGraph.Canvas.MaximumFps', () => {
// Behavioural FPS counting via rAF is not reliable under Playwright
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0)
await expect.poll(() => getFrameGap(comfyPage)).toBe(0)
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -12,25 +13,35 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
const test = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
}
await page.route(pattern, assetsRouteHandler)
await use()
await page.unroute(pattern)
await page.unroute(pattern, assetsRouteHandler)
},
{ auto: true }
]
@@ -42,23 +53,36 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
})
test('should use first cloud asset when server default is not in assets', async ({
cloudAssetRequests,
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
// Wait for the checkpoint asset query to complete and the existing widget
// to upgrade into asset mode before creating a fresh node. The current
// default node may keep a previously resolved value; what matters is that
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
)
.toBe(true)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
comfyPage.page.evaluate((waitingForWidgetType) => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
return (
node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type ?? waitingForWidgetType
)
}, WAITING_FOR_WIDGET_TYPE),
{ timeout: 10_000 }
)
.toBe('asset')
@@ -81,15 +105,22 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
await expect
.poll(
() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
comfyPage.page.evaluate(
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return waitingForWidgetType
const val = String(widget?.value ?? '')
return val === 'Select model' ? waitingForWidgetValue : val
},
{
id: nodeId,
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
}
),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)

View File

@@ -0,0 +1,306 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
const PUBLISH_FEATURE_FLAGS = {
comfyhub_upload_enabled: true,
comfyhub_profile_gate_enabled: true
} as const
async function saveAndOpenPublishDialog(
comfyPage: ComfyPage,
dialog: PublishDialog,
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
await comfyPage.confirmDialog.click('overwrite')
} catch {
// No overwrite dialog — workflow name was unique.
}
await dialog.open()
}
test.describe('Publish dialog - wizard navigation', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
})
test('opens on the Describe step by default', async ({ publishDialog }) => {
await expect(publishDialog.describeStep).toBeVisible()
await expect(publishDialog.nameInput).toBeVisible()
await expect(publishDialog.descriptionTextarea).toBeVisible()
})
test('pre-fills workflow name from active workflow', async ({
publishDialog
}) => {
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
})
test('Next button navigates to Examples step', async ({ publishDialog }) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
// Examples step should show thumbnail toggle and upload area
await expect(
publishDialog.root.getByText('Select a thumbnail')
).toBeVisible()
})
test('Back button returns to Describe step from Examples', async ({
publishDialog
}) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
await publishDialog.goBack()
await expect(publishDialog.describeStep).toBeVisible()
})
test('navigates through all steps to Finish', async ({ publishDialog }) => {
await publishDialog.goNext() // → Examples
await publishDialog.goNext() // → Finish
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeVisible()
})
test('clicking nav step navigates directly', async ({ publishDialog }) => {
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.goToStep('Describe your workflow')
await expect(publishDialog.describeStep).toBeVisible()
})
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(publishDialog.root).toBeHidden()
})
})
test.describe('Publish dialog - Describe step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
})
test('allows editing the workflow name', async ({ publishDialog }) => {
await publishDialog.nameInput.clear()
await publishDialog.nameInput.fill('My Custom Workflow')
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
})
test('allows editing the description', async ({ publishDialog }) => {
await publishDialog.descriptionTextarea.fill(
'A great workflow for anime art'
)
await expect(publishDialog.descriptionTextarea).toHaveValue(
'A great workflow for anime art'
)
})
test('displays tag suggestions from mocked API', async ({
publishDialog
}) => {
await expect(publishDialog.root.getByText('anime')).toBeVisible()
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
})
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
// the active list during E2E. Needs investigation of the parent state
// binding.
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
await publishDialog.root.getByText('anime').click()
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
})
})
test.describe('Publish dialog - Examples step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
await publishDialog.goNext() // Navigate to Examples step
})
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByText('Image', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Video', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Image comparison', { exact: true })
).toBeVisible()
})
test('shows example image upload tile', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByRole('button', { name: 'Upload example image' })
).toBeVisible()
})
})
test.describe('Publish dialog - Finish step with profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
await publishDialog.goToStep('Finish publishing')
})
test('shows profile card with username', async ({ publishDialog }) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
})
test('publish button is enabled when no private assets', async ({
publishDialog
}) => {
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - Finish step with private assets', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({
hasProfile: true,
hasPrivateAssets: true
})
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
await publishDialog.goToStep('Finish publishing')
})
test('publish button is disabled until assets acknowledged', async ({
publishDialog
}) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeDisabled()
const checkbox = publishDialog.finishStep.getByRole('checkbox')
await checkbox.check()
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - no profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: false })
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-noprofile-wf'
)
await publishDialog.goToStep('Finish publishing')
})
test('shows profile creation prompt when user has no profile', async ({
publishDialog
}) => {
await expect(publishDialog.profilePrompt).toBeVisible()
await expect(
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
).toBeVisible()
})
test('clicking create profile CTA shows profile creation form', async ({
publishDialog
}) => {
await publishDialog.root
.getByRole('button', { name: 'Create a profile' })
.click()
await expect(publishDialog.gateFlow).toBeVisible()
})
})
test.describe('Publish dialog - unsaved workflow', () => {
test.beforeEach(async ({ comfyPage, publishApi }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
// Don't save workflow — open dialog on the default temporary workflow
})
test('shows save prompt for temporary workflow', async ({
comfyPage,
publishDialog
}) => {
// Create a new workflow to ensure it's temporary
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await publishDialog.open()
await expect(publishDialog.savePrompt).toBeVisible()
await expect(
publishDialog.root.getByText(
'You must save your workflow before publishing'
)
).toBeVisible()
// Nav should be hidden when save is required
await expect(publishDialog.nav).toBeHidden()
})
})
test.describe('Publish dialog - submission', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
})
test('successful publish closes dialog', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
})
test('failed publish shows error toast', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
// Override publish mock with error response
await publishApi.mockPublishWorkflowError(500, 'Internal error')
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-submit-fail-wf'
)
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
// Error toast should appear
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
timeout: 10_000
})
// Dialog should remain open
await expect(publishDialog.root).toBeVisible()
})
})

View File

@@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
} from '@e2e/fixtures/utils/clipboardSpy'
async function triggerConfigureError(
comfyPage: ComfyPage,

View File

@@ -0,0 +1,208 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
const VAE_DECODE_SAMPLES_INPUT_SLOT = 0
const DEFAULT_GROUP_TITLE = 'Group'
test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
test.describe('Comfy.LinkRelease.Action', () => {
test('"search box" opens node search on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
test('"context menu" opens litegraph connection menu on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'context menu'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
})
test('"no action" suppresses both search box and context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeHidden()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
})
})
test.describe('Comfy.LinkRelease.ActionShift', () => {
test('shift+drag dispatches to ActionShift (not Action)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.canvasOps.disconnectEdge({ modifiers: ['Shift'] })
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
})
test.describe('Comfy.Node.DoubleClickTitleToEdit', () => {
test('enabled → double-click on node title opens editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
await comfyPage.titleEditor.expectVisible()
})
test('disabled → double-click on node title stays hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
false
)
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
await comfyPage.titleEditor.expectHidden()
})
})
test.describe('Comfy.Group.DoubleClickTitleToEdit', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
})
test('enabled → double-click on group title opens editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Group.DoubleClickTitleToEdit',
true
)
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
await comfyPage.titleEditor.expectVisible()
})
test('disabled → double-click on group title stays hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Group.DoubleClickTitleToEdit',
false
)
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
await comfyPage.titleEditor.expectHidden()
})
})
test.describe('Comfy.Node.BypassAllLinksOnDelete', () => {
test('enabled → deleting KSampler bridges EmptyLatentImage → VAEDecode.samples', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.BypassAllLinksOnDelete',
true
)
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const [emptyLatent] =
await comfyPage.nodeOps.getNodeRefsByType('EmptyLatentImage')
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
const vaeSamplesInput = await vaeDecode.getInput(
VAE_DECODE_SAMPLES_INPUT_SLOT
)
await test.step('precondition: KSampler feeds VAEDecode.samples', async () => {
expect(
(await vaeSamplesInput.getLink())?.origin_id,
'VAEDecode.samples should originate from KSampler before delete'
).toBe(kSampler.id)
})
await kSampler.delete()
await expect
.poll(async () => (await vaeSamplesInput.getLink())?.origin_id ?? null)
.toBe(emptyLatent.id)
})
test('disabled → deleting KSampler drops VAEDecode.samples', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.BypassAllLinksOnDelete',
false
)
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
const vaeSamplesInput = await vaeDecode.getInput(
VAE_DECODE_SAMPLES_INPUT_SLOT
)
await kSampler.delete()
await expect.poll(() => vaeSamplesInput.getLink()).toBeNull()
})
})
test.describe('Comfy.Node.MiddleClickRerouteNode', () => {
async function countReroutes(comfyPage: ComfyPage): Promise<number> {
return (await comfyPage.nodeOps.getNodeRefsByType('Reroute')).length
}
test('enabled → middle-click on an output slot creates a Reroute', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
true
)
const before = await countReroutes(comfyPage)
await comfyPage.canvasOps.middleClick(
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
)
await expect.poll(() => countReroutes(comfyPage)).toBe(before + 1)
})
test('disabled → middle-click on an output slot does nothing', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
false
)
const before = await countReroutes(comfyPage)
await comfyPage.canvasOps.middleClick(
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
)
await comfyPage.nextFrame()
expect(await countReroutes(comfyPage)).toBe(before)
})
})
})

View File

@@ -0,0 +1,106 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Enable the show_signin_button server feature flag so LoginButton renders
* in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`).
* The flag is reset automatically on each fresh page load in beforeEach.
*/
async function enableLoginButtonFlag(page: Page): Promise<void> {
await page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: true
}
})
}
test.describe('Login Button', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
test.describe('Visibility', () => {
test('button is hidden when show_signin_button flag is off', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: false
}
})
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeHidden()
})
test('button is visible when show_signin_button flag is enabled', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeVisible()
})
})
test.describe('ARIA', () => {
test('button has correct aria-label', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await expect(button).toHaveAttribute('aria-label', /.+/)
})
})
test.describe('Click behaviour', () => {
test('clicking the button opens the sign-in dialog', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const dialog = new SignInDialog(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click()
await expect(dialog.root).toBeVisible()
})
})
test.describe('Hover popover', () => {
test('hovering shows an informational popover', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
})
test('popover contains a Learn more link', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
const learnMoreLink = comfyPage.page.getByTestId(
TestIds.topbar.loginButtonPopoverLearnMore
)
await expect(learnMoreLink).toBeVisible()
await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/)
})
test('popover hides after mouse leaves the button area', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await button.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
await comfyPage.canvas.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeHidden()
})
})
})

View File

@@ -22,18 +22,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -80,7 +76,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -153,5 +148,124 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
test(
'subgraph blueprint added from search box enters ghost mode',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
// Convert a node to a subgraph and publish it as a blueprint
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
expect(subgraphNodes).toHaveLength(1)
const subgraphNode = subgraphNodes[0]
const blueprintName = `ghost-test-${Date.now()}`
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
// Open v2 search box and search for the published blueprint
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill(blueprintName)
await expect(searchBoxV2.results.first()).toBeVisible()
// Click the result to add the node (v2 search box uses ghost mode)
await searchBoxV2.results.first().click()
await comfyPage.nextFrame()
// A new node should exist on the graph in ghost mode
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
const ghostNodeId = await comfyPage.page.evaluate(() => {
return window.app!.canvas.state.ghostNodeId
})
expect(ghostNodeId).not.toBeNull()
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
expect(ghostState).not.toBeNull()
expect(ghostState!.ghost).toBe(true)
// Wait for search box to close, then click to confirm placement
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page.mouse.click(
Math.round(viewport.width / 2),
Math.round(viewport.height / 2)
)
await comfyPage.nextFrame()
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
expect(afterPlace).not.toBeNull()
expect(afterPlace!.ghost).toBe(false)
}
)
})
}

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'

View File

@@ -5,32 +5,19 @@ import {
test.describe('Node search box V2', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
await comfyPage.searchBoxV2.setup()
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
@@ -40,33 +27,28 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Default results should be visible without typing
await searchBoxV2.open()
// Default results should be visible without typing.
await expect(searchBoxV2.results.first()).toBeVisible()
// Enter should add the first (selected) result
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test.describe('Category navigation', () => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.reload(comfyPage)
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('favorites').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -77,13 +59,10 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
})
@@ -91,26 +70,23 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Click "Input" filter chip in the filter bar
await searchBoxV2.filterBarButton('Input').click()
await test.step('Open Input filter popover', async () => {
await searchBoxV2.typeFilterButton('input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
})
// Filter options should appear
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await test.step('Select MODEL type', async () => {
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
})
// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Filter chip should appear and results should be filtered
await expect(
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
).toContainText('MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
@@ -120,32 +96,180 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
// First result selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('First result is selected by default', async () => {
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// ArrowDown moves selection
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
await test.step('ArrowDown moves selection to next result', async () => {
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
})
// ArrowUp moves back
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('ArrowUp moves selection back', async () => {
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// Enter selects and adds node
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await test.step('Enter selects and adds the node', async () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
test.describe('Category sidebar', () => {
test('Sidebar toggle hides and shows the category sidebar', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingCategory = searchBoxV2.categoryButton('sampling')
await expect(samplingCategory).toBeVisible()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(samplingCategory).toBeHidden()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await expect(samplingCategory).toBeVisible()
})
test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
// Narrow viewport so the chips overflow the filter bar
await comfyPage.page.setViewportSize({ width: 360, height: 800 })
await searchBoxV2.open()
const scrollEl = searchBoxV2.filterChipsScroll
const dims = await scrollEl.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth
}))
expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth)
await scrollEl.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// The toggle lives outside the scroll container, so even when the
// chips scroll hundreds of px it must remain visible in the viewport.
await expect(searchBoxV2.sidebarToggle).toBeInViewport()
})
test('@mobile Sidebar is collapsed by default on mobile', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
})
test('@mobile Clicking outside the sidebar closes it', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeVisible()
await expect(searchBoxV2.sidebarBackdrop).toBeVisible()
// The backdrop spans the full content area, but the sidebar (z-20)
// covers its left ~208px (w-52). Click past that to land on the
// backdrop rather than the sidebar.
await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } })
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
await expect(searchBoxV2.sidebarBackdrop).toBeHidden()
})
test('@mobile Focusing the search input closes the sidebar', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await searchBoxV2.input.focus()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
})
test('Sidebar state across mobile/desktop resizes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const switchToDesktop = () =>
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
const switchToMobile = () =>
comfyPage.page.setViewportSize({ width: 360, height: 800 })
const expectExpanded = (value: 'true' | 'false') =>
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
value
)
await switchToDesktop()
await searchBoxV2.open()
await expectExpanded('true')
await switchToMobile()
await expectExpanded('false')
await searchBoxV2.sidebarToggle.click()
await switchToDesktop()
await expectExpanded('true')
await searchBoxV2.sidebarToggle.click()
await switchToMobile()
await expectExpanded('false')
await switchToDesktop()
await expectExpanded('false')
})
})
})

View File

@@ -2,27 +2,17 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
await comfyPage.searchBoxV2.setup()
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.openByDoubleClickCanvas()
await expect(searchBoxV2.dialog).toBeVisible()
})
@@ -32,43 +22,40 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
for (const closeKey of ['Enter', 'Escape'] as const) {
test(`Reopening search after ${closeKey} has no persisted state`, async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press(closeKey)
await expect(searchBoxV2.input).toBeHidden()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
})
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
}
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -76,7 +63,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(samplingResults)
@@ -87,58 +73,328 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Record initial result text for comparison
// Search first to keep the result set under the 64-item cap.
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
const unfilteredCount = await searchBoxV2.results.count()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
await test.step('Apply Input/MODEL filter', async () => {
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).toBeHidden()
await expect(searchBoxV2.results.first()).toBeVisible()
await test.step('Remove the filter chip', async () => {
await searchBoxV2.removeFilterChip()
await expect(searchBoxV2.filterChips).toHaveCount(0)
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
})
})
})
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const NODE_TYPE = 'CLIPTextEncode'
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const idsBefore = new Set(refsBefore.map((n) => n.id))
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('CLIP Text Encode')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
// A new CLIPTextEncode node should have been added.
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByType(NODE_TYPE)
.then((refs) => refs.length)
)
.toBe(refsBefore.length + 1)
// Verify the auto-connect: the newly-added node's CLIP input must be
// connected (proves the release wasn't just dropped).
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
const clipInput = await newNode!.getInput(0)
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.results.first()).toBeVisible()
const singleFilterCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Sampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(unfilteredCount)
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const comfyCount = await searchBoxV2.results.count()
// Under root filter, categories are prefixed (e.g. comfy/sampling).
await searchBoxV2.categoryButton('comfy/sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingBtn = searchBoxV2.categoryButton('sampling')
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await test.step('Expanding sampling reveals its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeVisible()
})
await test.step('Collapsing sampling hides its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeHidden()
})
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const parentCount = await searchBoxV2.results.count()
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.results.first()).toBeVisible()
const defaultCount = await searchBoxV2.results.count()
await searchBoxV2.categoryButton('sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(defaultCount)
await searchBoxV2.categoryButton('most-relevant').click()
await expect(searchBoxV2.results).toHaveCount(defaultCount)
})
test(
'Blueprint root chip filters to published blueprints',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByTitle('New Subgraph')
.then((refs) => refs.length)
)
.toBe(1)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
await subgraphNodes[0].click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const blueprintsChip = searchBoxV2.rootCategoryButton(
RootCategory.Blueprint
)
await expect(blueprintsChip).toBeVisible()
await blueprintsChip.click()
// Blueprints persist across tests on the same worker; filter by the
// unique name we just published rather than asserting the full list.
await expect(
searchBoxV2.results.filter({ hasText: blueprintName })
).toHaveCount(1)
}
)
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
await expect(searchBoxV2.results.first()).toBeVisible()
const count1 = await getCount()
await searchBoxV2.input.fill('Sa')
await expect.poll(getCount).toBeLessThan(count1)
const count2 = await getCount()
await searchBoxV2.input.fill('Sampler')
await expect.poll(getCount).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const chipTexts = await searchBoxV2.filterChips.allTextContents()
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
})
test('Follow-cursor disabled places node without ghost mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
false
)
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).toBeHidden()
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await expect(
comfyPage.page.locator('[data-node-id][data-ghost]')
).toHaveCount(0)
})
})
})

View File

@@ -7,7 +7,7 @@ import {
drawStroke,
hasCanvasContent,
triggerSerialization
} from '@e2e/helpers/painter'
} from '@e2e/fixtures/utils/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transform1)
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
await expect(cursor).toBeHidden()
})
@@ -187,7 +187,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
await comfyPage.page.mouse.move(
box.x + box.width + 20,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
@@ -408,6 +411,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
// default 512 + slider step 64 = 576
.toBe(576)
})
@@ -493,6 +497,29 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBe(false)
}
)
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should start empty'
})
.toBe(false)
await painterWidget
.getByTestId('painter-clear-button')
.dispatchEvent('click')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should still be empty after clearing empty canvas'
})
.toBe(false)
})
})
test.describe('Serialization', () => {
@@ -560,36 +587,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
@@ -604,18 +601,318 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
test.describe('Serialization — unchanged canvas', () => {
test(
'Unchanged canvas does not re-upload on second serialization',
{ tag: '@slow' },
async ({ comfyPage }) => {
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockResponse)
})
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'first serialization should upload once').toBe(1)
await triggerSerialization(comfyPage.page)
expect(
uploadCount,
'second serialization without new drawing should not re-upload'
).toBe(1)
}
)
})
test.describe('Settings persistence', () => {
test('Tool selection is saved to node properties', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
| string
| undefined
}),
{ message: 'painterTool property should update to eraser' }
)
.toBe('eraser')
})
test('Brush size change is saved to node properties', async ({
comfyPage
}) => {
const sizeRow = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
.getByTestId('painter-size-row')
const sizeSlider = sizeRow.getByRole('slider')
await expect(
sizeRow.getByTestId('painter-size-value'),
'brush size should start at default 20'
).toHaveText('20')
await sizeSlider.focus()
for (let i = 0; i < 10; i++) {
await sizeSlider.press('ArrowRight')
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties
?.painterBrushSize as number | undefined
}),
{ message: 'painterBrushSize property should update to 30' }
)
.toBe(30)
})
})
test('Controls collapse to single column in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
).toBeVisible()
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
if (node) {
node.size = [200, 400]
window.app!.canvas.setDirty(true, true)
}
})
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({
comfyPage
}) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
}, yFraction)
await expect
.poll(() => hasContentAtRow(0.25), {
message: 'top stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.5), {
message: 'middle stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.75), {
message: 'bottom stroke should be present'
})
.toBe(true)
})
})
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },
() => {
test.setTimeout(60_000)
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
})
test('Width, height, and bg_color controls hide when input is connected', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(
painterWidget.getByTestId('painter-width-row'),
'width row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-height-row'),
'height row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-bg-color-row'),
'background color row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-dimension-text'),
'dimension text should be visible when input is connected'
).toBeVisible()
})
test('Canvas resizes to match input image dimensions after execution', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{
message: 'input image should be fully decoded',
timeout: 30_000
}
)
.toBe(true)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas width should match input image natural width'
})
.toBe(nw)
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
message: 'canvas height should match input image natural height'
})
.toBe(nh)
})
test('Drawing over input image produces content on canvas', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{ message: 'input image should be fully decoded', timeout: 30_000 }
)
.toBe(true)
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas should resize to match input image width',
timeout: 15_000
})
.toBe(nw)
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
// intercepts coordinate-based hit testing from page.mouse
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
const startX = box.x + box.width * 0.3
const endX = box.x + box.width * 0.7
const midY = box.y + box.height * 0.5
const pointerOpts = {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
isPrimary: true
}
await canvas.dispatchEvent('pointerdown', {
...pointerOpts,
clientX: startX,
clientY: midY
})
for (let i = 1; i <= 10; i++) {
await canvas.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + (endX - startX) * (i / 10),
clientY: midY
})
}
await canvas.dispatchEvent('pointerup', {
...pointerOpts,
clientX: endX,
clientY: midY
})
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'drawing over input image should produce canvas content'
})
.toBe(true)
})
}
)

View File

@@ -1,7 +1,10 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter'
import {
logMeasurement,
recordMeasurement
} from '@e2e/fixtures/utils/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {

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