Compare commits

...

48 Commits

Author SHA1 Message Date
Comfy Org PR Bot
7c2321cc23 1.44.17 (#11938)
Patch version increment to 1.44.17

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11938-1-44-17-3576d73d365081e89010e68cbf1c2625)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-05 07:56:34 +00:00
Kelly Yang
a877ccde94 Test/edit attention unit tests (#11301)
## Summary

A follow-up PR of
https://github.com/Comfy-Org/ComfyUI_frontend/issues/11107.


## Changes

Add unit test to `editAttention.ts` 
- [x] `Extract pure functions to module level`: **Moved**
`incrementWeight`, `findNearestEnclosure`, and `addWeightToParentheses`
out of the `init()` closure and **promoted** them to module-level
functions with `export` to allow for independent testing.
- [x] `Add unit tests for incrementWeight`: **Added** 6 tests covering
edge cases such as normal increment/decrement, NaN input, negative
weights, and floating-point precision.
- [x] `Add unit tests for findNearestEnclosure`: **Added** 7 tests
covering edge cases including simple brackets, no brackets, cursor
outside, nested brackets (inner/outer), empty strings, and missing
closing brackets.
- [x] `Add unit tests for addWeightToParentheses`: **Added** 6 tests
covering scenarios like adding a default 1.0 weight, retaining existing
weights, no changes when brackets are absent, scientific notation
weights, negative weights, and multi-word tokens.
- [x] `Mock app module`: **Used** `vi.mock('@/scripts/app')` to
intercept side effects from `app.registerExtension`, **preventing** the
triggering of ComfyUI extension registration logic during module import.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adjusts token selection/weight-detection logic used during
Ctrl/Cmd+Arrow editing, which could subtly change how prompts are
rewritten in edge cases (nested parens, scientific notation, time-like
text). Adds tests that should reduce regression risk but behavior
changes still warrant verification in the UI.
> 
> **Overview**
> Adds a new `vitest` unit test suite for `editAttention` by mocking
`app.registerExtension` side effects and validating `incrementWeight`,
`findNearestEnclosure`, and `addWeightToParentheses` across common and
edge cases.
> 
> Refactors those helpers to exported module-level functions and
tightens parsing/selection behavior: `findNearestEnclosure` now handles
the cursor being on an opening `(`, `addWeightToParentheses` improves
trailing weight detection (supports scientific notation/negatives and
avoids misclassifying time-like `12:30`), and the weight-rewrite regex
now matches exponent forms.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
df20340b49. 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-11301-Test-edit-attention-unit-tests-3446d73d365081f29e8dfceefc06f5d3)
by [Unito](https://www.unito.io)
2026-05-05 06:48:26 +00:00
Dante
e3883f4a2c test: add unit tests for layoutStore setter and query paths (#11747)
## Summary

Adds 11 tests for \`src/renderer/core/layout/store/layoutStore.ts\`
covering paths previously uncovered by the existing 17-test suite.
Targets the customRef setter machinery, reactive queries, and
link-layout updates that are reachable through the public API.

## Test Coverage

\`getNodeLayoutRef\` setter:
- Setter on a fresh ref triggers \`createNode\`.
- Position-only change triggers \`moveNode\`.
- Size-only change triggers \`resizeNode\`.
- zIndex-only change triggers \`setNodeZIndex\`.
- Setting to \`null\` triggers \`deleteNode\`.

Queries:
- \`getNodesInBounds\` returns reactive node IDs intersecting the
bounds.
- \`queryNodeAtPoint\` returns the top-zIndex node containing the point.
- \`queryNodeAtPoint\` returns \`null\` when no node contains the point.

Link layouts:
- \`updateLinkLayout\` short-circuits when bounds and centerPos
unchanged but still updates the path.
- \`updateLinkLayout\` replaces stored layout when bounds change.
- \`deleteLinkLayout\` removes the link and its segment layouts.

## Testing

\`\`\`bash
pnpm vitest run src/renderer/core/layout/store/layoutStore.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11747-test-add-unit-tests-for-layoutStore-setter-and-query-paths-3516d73d365081d9bc1de336ff7258ea)
by [Unito](https://www.unito.io)
2026-05-05 05:03:20 +00:00
Kelly Yang
5e16802832 refactor: remove @ts-expect-error suppressions in CustomizationDialog (#11339)
… (issue #11092 phase 4b)

## Summary

Part of #11092 — Phase 4b: remove 2 `@ts-expect-error` suppressions from
`CustomizationDialog.vue`.

## Changes

`selectedIcon` ref initialisation and `resetCustomization` assignment
both suppressed a type error on `Array.find()` returning `T |
undefined`.

**Why**

`Array.find()` has no way to statically guarantee a match, so its return
type is always `T | undefined`. Both usages were searching `iconOptions`
— a literal array of 8 entries declared in the same scope — and
TypeScript could not prove that the searched value would always be
found, even though at runtime it always is (the default icon value is
defined from `iconOptions[0]`).

**How**

Added `iconOptions[0]` as a final fallback via `??` in both places.
Because `iconOptions` is a non-empty literal array, `iconOptions[0]` is
provably non-null to TypeScript, which makes the overall expression type
`T` and satisfies the assignment. The explicit generic on `ref<{ name:
string; value: string }>` was also dropped — TypeScript infers the type
correctly from the non-nullable initialiser. In `resetCustomization`,
`||` was replaced with `??` since the values being null-coalesced are
objects (never falsy), making `??` the semantically precise operator for
this case.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: UI-only refactor that adds explicit fallbacks for
`Array.find()` results and introduces a small unit test suite; behavior
should remain the same except for safer handling of unexpected icon
values.
> 
> **Overview**
> Removes two `@ts-expect-error` suppressions in
`CustomizationDialog.vue` by making `selectedIcon` initialization and
`resetCustomization` use a guaranteed fallback (`iconOptions[0]`) via
`??`, ensuring the selected icon is never `undefined`.
> 
> Adds `CustomizationDialog.test.ts` to verify `confirm` emits the
expected icon/color for default, provided initial values, and an invalid
`initialIcon` fallback.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f77addf713. 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-11339-refactor-remove-ts-expect-error-suppressions-in-CustomizationDialog-3456d73d36508165865ac569e047db2e)
by [Unito](https://www.unito.io)
2026-05-04 21:41:09 -04:00
Kelly Yang
0e9a5ecbe9 refactor: extract GPU lifecycle into useGPUResources (phase D) (#11784)
## Summary
Phase D of the **useBrushDrawing-refactor plan.md**. Extract `WebGPU`
state management from `useBrushDrawing` into a dedicated
`useGPUResources` composable, reducing `useBrushDrawing` from ~1,160
lines to ~230. This is Phase D of the ongoing `useBrushDrawing`
decomposition (Phases A–C landed in previous PRs).
   
## Changes
- **What**: Split `useBrushDrawing` along a clean boundary — GPU
device/texture lifecycle moves to `useGPUResources`, stroke
orchestration stays in `useBrushDrawing`. Shared reactive state
(`dirtyRect`, `isSavingHistory`, `previewCanvas`) is now owned by
`useGPUResources` and exposed as refs. A pure
   `clampDirtyRect` helper is extracted to `gpuUtils.ts`.
- **Dependencies**: No new dependencies
## Tests
Local test - pass            


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Refactors WebGPU initialization, texture management, and readback
paths used during drawing; regressions could affect stroke rendering,
canvas visibility, and undo/redo GPU sync.
> 
> **Overview**
> Extracts WebGPU device/texture/renderer lifecycle, watchers (clear,
undo/redo sync, texture recreation), and readback logic out of
`useBrushDrawing` into a new `useGPUResources` composable, with shared
refs (`dirtyRect`, `isSavingHistory`, `previewCanvas`, `hasRenderer`)
now owned by that module.
> 
> Updates `useBrushDrawing` to delegate GPU-specific operations
(prepare/render/draw point/composite/readback/cleanup) to
`useGPUResources` while keeping CPU drawing + stroke orchestration, and
adds new pure helpers in `gpuUtils` (`clampDirtyRect`,
`buildStrokePoints`) to centralize dirty-rect clamping and stroke point
resampling.
> 
> Adds Vitest coverage for the new helpers, `useGPUResources`
no-op/error behavior when GPU isn’t available, and `useBrushDrawing`
interactions with the extracted GPU API (composition mode selection,
shift-line, history save, and canvas/preview opacity restoration).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a9fcd80ab5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11784-refactor-extract-GPU-lifecycle-into-useGPUResources-phase-D-3526d73d365081108a97c164a0bfa13e)
by [Unito](https://www.unito.io)
2026-05-04 20:49:10 -04:00
Dante
9013102db9 fix: use capitalize for keybinding badges (#11810)
## Summary

Render keybinding badges in sentence case (`Ctrl + Shift + A`) instead
of UPPERCASE (`CTRL + SHIFT + A`) by swapping the `uppercase` Tailwind
class for `capitalize` in `KeyComboDisplay.vue`.

`KeyComboImpl.getKeySequences()` already returns labels in their
canonical form (`Ctrl`, `Alt`, `Shift`, plus the raw key). The badge
styling was forcing them all to UPPER, which is what FE-524 calls out.
`text-transform: capitalize` cleanly handles every case: lower modifier,
upper modifier, and single character keys.

- Fixes FE-524

## Before / After

| Before (`uppercase`) | After (`capitalize`) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c6bb96fce/docs/screenshots/fe-524/before.png"
width="480"> | <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c6bb96fce/docs/screenshots/fe-524/after.png"
width="480"> |

## Test plan

- [ ] Open Settings → Keybinding panel and confirm modifier badges
render as `Ctrl`, `Alt`, `Shift` instead of `CTRL`, `ALT`, `SHIFT`
- [ ] Confirm single-letter keys (e.g. `A`, `S`) still render uppercase
- [ ] Edit a keybinding and verify the live preview badges in the dialog
also render in sentence case
2026-05-04 20:38:31 -04:00
Christian Byrne
6ea5a5e32d fix(load3d): preserve unknown Model Config fields with spread (#11838)
## Summary

Use spread pattern when writing `nodeValue.properties['Model Config']`
so future ModelConfig fields are preserved across viewer dialog
cancel/apply.

## Changes

- **What**: Spread existing `Model Config` before applying known keys in
both `restoreInitialState()` and `applyChanges()` in
[useLoad3dViewer.ts](src/composables/useLoad3dViewer.ts). Removes the
hard-coded `showSkeleton: false` override from `applyChanges()` so it
falls through from the existing config.

## Review Focus

The change is intentionally minimal and matches the suggestion in the
upstream issue. Two regression tests added (one each for restore/apply)
verify that an unknown future field on Model Config survives both code
paths.

Fixes #11346

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11838-fix-load3d-preserve-unknown-Model-Config-fields-with-spread-3546d73d3650819686efc4e1a9799ad9)
by [Unito](https://www.unito.io)
2026-05-04 20:32:57 -04:00
Alexander Brown
90b3d8a5c6 test: add mask editor brush adjustment and layer management browser tests (#11368)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `browser_tests/tests/maskEditorBrushLayers.spec.ts` covering
untested brush settings interaction and tool/layer management in the
mask editor.

### Coverage gaps filled
- `useBrushDrawing.ts` — brush thickness/opacity/hardness slider
interaction
- `useToolManager.ts` — tool switching with independent mask data, data
preservation across tool switches

### Test cases (5 tests, 2 groups)
| Group | Tests | Behavior |
|---|---|---|
| Brush settings | 3 | Thickness slider changes size, opacity slider
changes opacity, hardness slider changes hardness |
| Layer management | 2 | Different tools produce independent mask data,
switching tools preserves previous mask data |

### References
- Reuses patterns from existing `maskEditor.spec.ts` (`loadImageOnNode`,
`openMaskEditorDialog`, `drawStrokeOnPointerZone`,
`getMaskCanvasPixelData`)
- Follows `browser_tests/AGENTS.md` directory structure
- Follows `browser_tests/FLAKE_PREVENTION_RULES.md` assertion patterns

### Verification
- TypeScript: clean
- ESLint: clean
- oxlint: clean
- oxfmt: formatted

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11368-test-add-mask-editor-brush-adjustment-and-layer-management-browser-tests-3466d73d36508170ae24ebea2b73d60d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 20:31:15 -04:00
Christian Byrne
551cf21fb1 fix(load3d): reapply up-direction after fitToViewer() transform reset (#11826)
## Summary

`fitToViewer()` in `SceneModelManager` resets the model rotation to
`(0,0,0)` as part of its normalize-and-scale flow, but does not reapply
`currentUpDirection` afterward. This causes a state/view mismatch when
the user has previously selected a non-default up-axis (e.g. `+x`,
`-z`): the model snaps back to its raw orientation while the Up
Direction control still shows the previously selected value.

## Changes

- In `fitToViewer()`, clear `originalRotation` (to avoid compounding
with the stale pre-fit base) then reapply `currentUpDirection` if it is
not `'original'`
- Add 5 unit tests covering: no-op when no model, reapplication of
direction, no rotation compounding on repeated calls, zero rotation for
`'original'` direction, and stale `originalRotation` guard

## Testing

### Automated

- `src/extensions/core/load3d/SceneModelManager.test.ts` — 5 new tests
in `describe('fitToViewer')`
- All 48 unit tests pass

### E2E Verification Steps

1. Open the Load3D viewer with any 3D model
2. Change **Up Direction** to any non-default value (e.g. `+x` or `-z`)
— observe model rotates correctly
3. Click **Fit to Viewer** — model should remain in the chosen
up-direction orientation, not snap back to raw orientation
4. Click **Fit to Viewer** again — rotation should remain stable (no
compounding)
5. Change Up Direction back to `original` then click **Fit to Viewer** —
model should return to neutral orientation `(0,0,0)`

## Review Focus

The key invariant: `fitToViewer()` resets `rotation.set(0,0,0)`
explicitly, so we must clear `originalRotation = null` before calling
`setUpDirection`. Otherwise `setUpDirection` restores the stale pre-fit
rotation as a base and then adds the direction offset on top,
compounding incorrectly.

Fixes #11347

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

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11826-fix-load3d-reapply-up-direction-after-fitToViewer-transform-reset-3546d73d36508166b9bcecc9949c4952)
by [Unito](https://www.unito.io)
2026-05-04 20:29:24 -04:00
Christian Byrne
2c8ecd82ec fix(load3d): snapshot original materials before reapplying materialMode (#11825)
## Summary

Fixes a bug where models reloaded in wireframe/normal/depth modes would
not restore to their original materials correctly, because the material
snapshot was being taken *after* the mode was applied.

## Changes

- Move `setupModelMaterials(model)` to immediately after
`scene.add(model)` and before `setMaterialMode()` / `setUpDirection()`
in `SceneModelManager.setupModel()`
- Save `materialMode` into `pendingMaterialMode` before calling
`setupModelMaterials()`, since the latter internally calls
`setMaterialMode('original')` which resets `this.materialMode` —
preserving the value ensures the subsequent reapplication guard works
correctly
- Update stale test assertion that reflected the old (incorrect) call
order
- Add regression test: verifies that `originalMaterials` captures the
pre-mutation material and that restoring to `'original'` after a
non-original load gives back the true original mesh material

## Testing

### Automated

- `src/extensions/core/load3d/SceneModelManager.test.ts` — 44 tests, all
pass
- Full load3d test suite — 401 tests, all pass

### E2E Verification Steps

1. Open ComfyUI with a Load3D node
2. Load any GLB/OBJ model
3. Switch Material Mode to **Wireframe** (or Normal/Depth)
4. Load a different model (or reload the same one)
5. Switch Material Mode back to **Original**
6. Verify the model renders with its original diffuse/PBR materials (not
wireframe)

## Review Focus

The key invariant: `setupModelMaterials()` must snapshot mesh materials
in their *unmodified* state. It must run before any `setMaterialMode()`
call that mutates them. The `pendingMaterialMode` variable is needed
because `setupModelMaterials()` internally calls
`setMaterialMode('original')`, which updates `this.materialMode`, making
the subsequent guard `if (this.materialMode !== 'original')` silently
skip reapplication without it.

Fixes #11344

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

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11825-fix-load3d-snapshot-original-materials-before-reapplying-materialMode-3546d73d3650818b9c35fa60c15f17a3)
by [Unito](https://www.unito.io)
2026-05-04 20:28:09 -04:00
Kelly Yang
7b59c561ff fix(load3d): update renderer pixel ratio on canvas zoom to fix LOD resolution (#11734)
## Summary

Preview 3D and Animation nodes were stuck at the LOD from initial page
load because CSS `scale3d` transforms don't affect
`clientWidth`/`clientHeight` — `handleResize()` always received
layout-space dimensions regardless of zoom level. This fix passes
`ds.scale` as the renderer pixel ratio so the 3D scene renders at the
correct visual resolution when the graph is zoomed in or out.

## Changes

- **What**: In `Load3d.handleResize()`, call
`renderer.setPixelRatio(ds.scale)` before `setSize` so pixel density
scales with canvas zoom. A `getZoomScale` callback is threaded through
`Load3DOptions` → `Load3d` constructor → `handleResize`. In `useLoad3d`,
a watcher on `canvasStore.appScalePercentage` triggers `handleResize`
whenever the zoom level changes.
- **What**: Fix `SceneManager.captureScene()` to save and restore the
renderer's logical size and pixel ratio around capture, so exact-pixel
output is unaffected by the current zoom state.

## Review Focus

- `handleResize` now calls `setPixelRatio` before `setSize`. Three.js
renders at `logicalWidth × pixelRatio` physical pixels while CSS
displays it at `logicalWidth` CSS pixels — this is the standard pattern
for HiDPI but here used to match the visual zoom level.
- `captureScene` must reset `pixelRatio` to 1 so `setSize(w, h)`
produces exactly `w×h` pixel output. It saves and restores both logical
size and pixel ratio via `renderer.getSize()` /
`renderer.getPixelRatio()`.
- The zoom watcher is guarded with `getActivePinia()` to avoid errors in
unit tests and non-Pinia contexts.

## Test
before


https://github.com/user-attachments/assets/9778ad54-7cb2-4fdc-b200-65a683ee8e4d

after


https://github.com/user-attachments/assets/acfaaf7a-43c7-495f-b352-5dd2cdaa94db

## Analysis Report

https://linear.app/comfyorg/issue/FE-401/bug-preview-3d-and-animation-nodes-lod-stuck-at-initial-page-load

## More
- Add `debounce` and pixel ratio limit


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes core `Load3d.handleResize()` rendering
behavior (pixel ratio/LOD) and adds a debounced zoom-driven resize
watcher, which could affect performance or visual output across all
Load3D nodes. Capture logic is also refactored to manipulate renderer
size/pixel ratio and camera params, so regressions would show up in
thumbnails/exports.
> 
> **Overview**
> Fixes Load3D LOD/render sharpness when the graph canvas is zoomed by
threading a new `getZoomScale` option from `useLoad3d` into `Load3d` and
using it to call `renderer.setPixelRatio()` (clamped) during
`handleResize()`.
> 
> Adds a debounced watcher on `canvasStore.appScalePercentage` to
trigger `handleResize()` on zoom changes, and updates
`SceneManager.captureScene()` to temporarily force pixel ratio 1 and
restore renderer size/pixel ratio and camera settings after capture.
Coverage is expanded with new Playwright smoke coverage plus unit tests
for zoom propagation, debouncing, pixel ratio behavior, and capture
state restoration.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
261940d111. 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-11734-fix-load3d-update-renderer-pixel-ratio-on-canvas-zoom-to-fix-LOD-resolution-3516d73d365081e6b3d4cdd05f516489)
by [Unito](https://www.unito.io)
2026-05-04 20:25:55 -04:00
LifetimeVip
8b1d564729 chore(i18n): correct zh and zh-TW translations (#11930)
## Summary

Fixes several issues in both Simplified Chinese (zh) and Traditional
Chinese (zh-TW) locale files that were identified through systematic
comparison against the English source.

### Changes

**nodeDefs.json (zh + zh-TW):**
- **CLIPLoader.description**: Added missing model recipes (lumina2, wan,
hidream, omnigen2) to match English source
- **ByteDanceSeedreamNode.display_name**: Updated version from "Seedream
4" to "Seedream 4.5 and 5.0" to match English
- **BriaImageEditNode.display_name**: Added missing "FIBO" model name

**nodeDefs.json (zh only):**
- **APG.inputs.eta.name**: Fixed incorrect translation "预计到达时间" (ETA) ->
kept as "eta" (technical parameter name)

**commands.json (zh + zh-TW):**
- **Comfy_ToggleLinear**: Updated label to match English "Toggle App
Mode"
- **Experimental_ToggleVueNodes**: Rebranded "Vue 节点"/"Vue 節點" to "Nodes
2.0" to match English

**settings.json (zh + zh-TW):**
- **Comfy_VueNodes_Enabled / Comfy_VueNodes_AutoScaleLayout**: Rebranded
"Vue 节点"/"Vue 節點" to "Nodes 2.0"

**main.json (zh + zh-TW):**
- **errorDialog.accessRestrictedMessage / accessRestrictedTitle**: Added
missing Chinese translations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11930-fix-i18n-correct-zh-and-zh-TW-translations-3566d73d365081ff9b0beb1c1fc7ef1a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: LifetimeVip <lifetimevip@users.noreply.github.com>
2026-05-05 00:05:51 +00:00
Christian Byrne
ea2e8e59f2 test: add MembersPanelContent unit tests (#11402)
## Summary

Add 27 unit tests for MembersPanelContent component covering workspace
views, member management, and billing states.

## Changes

- **What**: New test file for MembersPanelContent with 27 tests across 8
describe blocks (personal workspace, owner view, member view, sorting,
search filtering, pending invites, single seat plan, member count
display)

## Review Focus

- Uses `@testing-library/vue` + `@testing-library/user-event` per
project conventions
- Component stubs (Button, SearchInput, Menu, UserAvatar) for isolation
- Reactive mock refs in `vi.hoisted()` shared across `vi.mock()` calls

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11402-test-add-MembersPanelContent-unit-tests-3476d73d36508107abcbce95b72b3fb7)
by [Unito](https://www.unito.io)
2026-05-04 20:02:15 -04:00
Christian Byrne
1f60f7cfcc test: add unit tests for useImageCrop composable (#11138)
## Summary

Add 40 unit tests for `useImageCrop` composable (previously 0% coverage,
277 missed lines).

## Changes

- **What**: New test file `src/composables/useImageCrop.test.ts`
covering:
  - Crop computed properties (read/write/defaults)
  - `cropBoxStyle` computation
  - `selectedRatio` / `isLockEnabled` aspect ratio locking
  - `applyLockedRatio` with boundary clamping
  - `resizeHandles` filtering (8 handles unlocked, 4 corners locked)
  - `handleImageLoad` / `handleImageError`
  - Drag start/move/end with boundary clamping
  - Resize from all 4 edges + MIN_CROP_SIZE enforcement
  - Constrained resize with locked aspect ratio (corner handles)
  - `getInputImageUrl` with subgraph node resolution
  - `updateDisplayedDimensions` for landscape/portrait/zero dimensions
  - `initialize` with `resolveNode` lookup

## Review Focus

Test-only change. Mocks `resolveNode`, `useNodeOutputStore`, and
`useResizeObserver`. No production code changes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11138-test-add-unit-tests-for-useImageCrop-composable-33e6d73d365081e6aa06e98b66feb585)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-04 19:55:06 -04:00
Christian Byrne
5e3266e0c2 test: add e2e tests for node replacement flows (#11242)
## Summary

Add Playwright e2e tests for the node replacement feature (swap nodes UI
in the errors tab).

## Changes

- **What**: 6 e2e test cases across two describe blocks covering single
and multi-type node replacement flows. Tests verify swap nodes group
visibility, in-place replacement, widget value preservation, Replace All
across multiple types, output connection preservation, and success toast
display. Includes typed mock data for `/api/node_replacements` and two
workflow fixture files with fake missing node types mapped to real core
nodes.

## Review Focus

- Mock setup pattern in `setupNodeReplacement` — enables feature flag
via `page.evaluate` and routes the API endpoint
- Workflow fixture design — uses fake node types (E2E_OldSampler,
E2E_OldUpscaler) that map to real registered types (KSampler,
ImageScaleBy)
- Assertion coverage for link preservation after replacement

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11242-test-add-e2e-tests-for-node-replacement-flows-3426d73d3650811e87d7f0d96fd66433)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-04 15:52:33 -07:00
Terry Jia
b5b502755f fix(load3d): parse [output]/[input]/[temp] annotation when loading (#11805)
## Summary
The Vue node model picker mixes output assets into the dropdown with a
trailing ' [output]' suffix on the value. Forwarding that string to
loadModel as a literal filename under the configured input folder caused
a 404 and the model never rendered. Strip the trailing folder annotation
per call and use the matching folder so picking an output asset loads
correctly while plain values keep the configured folder.

Output assets stored under a subfolder (e.g. 3d/) were exposed in the
Vue node dropdown as just '<filename> [output]', so selection produced
an /api/view URL with empty subfolder and a 404. Read the subfolder from
the asset's OutputAssetMetadata and prefix it onto the annotated path so
the downstream load handler can split it back out and target the correct
folder.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/463d1071-efdc-46a4-b101-8e1c012d19c7

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11805-fix-load3d-parse-output-input-temp-annotation-when-loading-3536d73d365081a8ac9cf75d14ae29e6)
by [Unito](https://www.unito.io)
2026-05-04 18:44:52 -04:00
pythongosssss
5fbcea6b27 test: add test for workflow delete confirmation (#11780)
## Summary

Adds tests for the `Comfy.Workflow.ConfirmDelete` setting

## Changes

- **What**: 
- ensures dialog does/doesnt appear based on the setting

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11780-test-add-test-for-workflow-delete-confirmation-3526d73d36508134a3cdf0e908b95919)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-04 21:51:50 +00:00
Comfy Org PR Bot
ac36dc47a4 docs: Weekly Documentation Update (#11465)
## Summary

Fixed two minor documentation inaccuracies found during comprehensive
documentation audit:
- Corrected outdated "Lodash" reference to "Utility Functions" in unit
testing guide
- Updated package manager command from `npx` to `pnpm dlx` in Playwright
skill documentation

## Changes Made

### Documentation Fixes

#### docs/testing/unit-testing.md:150
- **Before**: `## Mocking Lodash Functions`
- **After**: `## Mocking Utility Functions`
- **Reason**: The section describes mocking `es-toolkit/compat`
functions, not Lodash. The project uses es-toolkit as stated in
AGENTS.md line 158 and docs/guidance/typescript.md line 60.

#### .claude/skills/writing-playwright-tests/SKILL.md:117
- **Before**: `npx playwright show-trace trace.zip`
- **After**: `pnpm dlx playwright show-trace trace.zip`
- **Reason**: Project standardizes on pnpm, explicitly avoiding npx per
AGENTS.md line 42: "use `pnpx` or `pnpm dlx` — never `npx`"

## Audit Summary

Comprehensive audit verified accuracy of:
-  Core documentation (CLAUDE.md, AGENTS.md, README.md)
-  All docs/**/*.md files (40+ files including ADRs, testing guides,
architecture docs)
-  All README files throughout repository (21 files)
-  All .claude/commands/*.md files (8 files)
-  Code examples and API references
-  File structure references (verified src/router.ts, src/i18n.ts,
src/main.ts, config files exist)
-  Package dependencies (es-toolkit ^1.39.9 confirmed)
-  Script commands (pnpm test:unit, pnpm test:browser:local, etc.)
-  External resource links
-  ADR index and dates

All other documentation remains accurate and up-to-date as of
2026-05-04.

## Review Notes

This PR contains only two trivial corrections to terminology/commands.
No functional changes, no code changes, no breaking changes. The
documentation audit found the codebase documentation to be in excellent
condition overall.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-04 21:37:52 +00:00
Christian Byrne
aef71852f0 feat: add demo pages with Arcade embeds at /demos/{slug} (#11436)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a demo pages system to the website that embeds Arcade interactive
walkthroughs at `comfy.org/demos/{slug}`. These pages will be linked
from welcome/lifecycle emails via Customer.io.

- Adds `/demos/image-to-video` and `/demos/workflow-templates` as the
first two demos
- Follows the existing `customers/[slug].astro` pattern exactly
(config-driven `getStaticPaths()`)
- Full SEO: OG/Twitter cards, HowTo + LearningResource + BreadcrumbList
JSON-LD schemas
- GEO: AI crawler directives in robots.txt, crawlable transcript
alongside iframe
- A11y: iframe title, sr-only transcript, aria-expanded toggle, noscript
fallback
- Email optimization: 1200x630 OG images, SSG pre-rendered, preconnect
to Arcade CDN
- Full zh-CN localization
- Library index stub at /demos for future expansion
- Automatic sitemap inclusion

## Architecture

Adding a new demo = adding one object to `src/config/demos.ts`.

## Note

OG images are tiny placeholders — replace with real 1200x630 screenshots
before go-live.

## Screenshots

![Demo detail page showing Arcade embed with full design
system](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d4e44d93c258f779ed62667c7924810f9ae7f20f0c9105acd9c3f86f63816bd1/pr-images/1776645565133-5566bf1b-e965-437d-b21f-89e7a751f883.png)

![Demo library index - Coming Soon
stub](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d4e44d93c258f779ed62667c7924810f9ae7f20f0c9105acd9c3f86f63816bd1/pr-images/1776645565461-0e334640-13e6-4554-ad6e-b3843e107572.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11436-feat-add-demo-pages-with-Arcade-embeds-at-demos-slug-3486d73d365081abbd72e02bf497a43a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:53:36 -07:00
Dante
94b570a177 test: rename misnamed Mixpanel test and cover the actual provider class (#11749)
## Summary

The existing \`MixpanelTelemetryProvider.test.ts\` was misnamed: it only
tested \`getExecutionContext\` from \`../../utils/getExecutionContext\`,
never the provider class itself — provider coverage sat at **0%**
despite a 239-line test file living next to it.

This PR:

1. **Renames** the existing test file to
\`src/platform/telemetry/utils/getExecutionContext.test.ts\` (co-located
with the source it actually tests). Updates its relative import to
\`./getExecutionContext\`.
2. **Adds** a fresh
\`src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts\`
covering the provider class.

Lifts provider coverage from **0% → 81.1%** lines (functions 73.5%,
branches 88.5%).

## Test Coverage (new tests)

Constructor / initialization:
- Without \`mixpanel_token\`, warns and disables itself; subsequent
\`trackXxx\` calls are no-ops.
- With \`mixpanel_token\`, dynamically imports mixpanel-browser, calls
\`init\`, and after \`loaded()\` fires identifies users via
\`onUserResolved\`.

Queueing semantics:
- Events fired before \`loaded()\` are queued and flushed in order once
Mixpanel reports ready.

Filtering:
- Events listed in the default \`disabledEvents\` set (e.g.
\`workflow_opened\`) are suppressed.

Direct dispatchers (parameterized \`it.each\`):
- 16 \`trackXxx\` methods covered: signup/auth/login, subscription
lifecycle, credit topup events, template lifecycle, workflow
imported/saved, default-view, enter-linear, share-flow, execution
success/error.
- \`trackApiCreditTopupButtonPurchaseClicked\` payload includes
\`credit_amount\`.
- \`trackEmailVerification\` dispatches the matching
\`USER_EMAIL_VERIFY_*\` event for each stage.
- \`trackSubscription\` maps \`'modal_opened'\` and
\`'subscribe_clicked'\` to their distinct events.
- \`trackRunButton\` populates \`RunButtonProperties\` from the
execution context.
- \`trackWorkflowExecution\` consumes the latest \`trigger_source\` from
\`trackRunButton\`, then resets it to \`'unknown'\`.

Survey:
- On \`'submitted'\`, normalized properties are written to
\`Mixpanel.people\`.
- On \`'opened'\`, \`Mixpanel.people\` is not touched.

Topup delegation:
- \`startTopupTracking\`, \`clearTopupTracking\`,
\`checkForCompletedTopup\` all forward to the \`topupTracker\` utility.

## Testing

\`\`\`bash
pnpm vitest run
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
pnpm vitest run src/platform/telemetry/utils/getExecutionContext.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11749-test-rename-misnamed-Mixpanel-test-and-cover-the-actual-provider-class-3516d73d365081609c54f34bd2d8b00d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-04 14:52:03 -07:00
Comfy Org PR Bot
846412af17 [chore] Update Ingest API types from cloud@758732f (#11479)
## Automated Ingest API Type Update

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

- Cloud commit: 758732f
- Generated using @hey-api/openapi-ts with Zod plugin

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

---------

Co-authored-by: skishore23 <178779+skishore23@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-04 21:37:31 +00:00
Benjamin Lu
aa2169e108 test: reset queue history cap in browser tests (#11773)
## Summary
- Reset `Comfy.Queue.MaxHistoryItems` in the shared browser-test
`comfyPage` setup so worker-persisted queue settings cannot leak between
tests.
- Keep the queue settings spec focused on asserting the setting behavior
without local cleanup scaffolding.

## Validation
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:65419
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:65400
TEST_COMFYUI_DIR=/Users/ben/.codex/comfyui-preview-env/runtime/worktrees/fe-500-maxhistoryitems
pnpm exec playwright test
browser_tests/tests/queue/queueSettings.spec.ts --project=chromium
--workers=1`
- `pnpm exec eslint browser_tests/tests/queue/queueSettings.spec.ts
browser_tests/fixtures/ComfyPage.ts`
- `pnpm exec oxlint browser_tests/tests/queue/queueSettings.spec.ts
browser_tests/fixtures/ComfyPage.ts`
- `pnpm typecheck:browser`
- `git diff --check`
- commit hook: staged oxfmt, oxlint, eslint, `pnpm typecheck`, `pnpm
typecheck:browser`

Linear: FE-500
2026-05-04 21:31:36 +00:00
Comfy Org PR Bot
cc24d1411a 1.44.16 (#11813)
Patch version increment to 1.44.16

**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-05-04 21:30:31 +00:00
AustinMroz
c2abbeda80 Fix core node detection for missing nodes (#11809)
Nodes in the 'essentials' category do not have type 'core'. The check
has been updated to instead use the dedicated `isCoreNode` prop.

No tests currently. The existing tests for this code section all mock
out the relevant code path and properly writing a test for this would
take far more time than I can allocate right now.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11809-Fix-core-node-detection-for-missing-nodes-3536d73d3650815aabb2deb54c4ecec4)
by [Unito](https://www.unito.io)
2026-05-04 14:21:35 -07:00
Benjamin Lu
56ac3762a0 fix: catch angle-bracket Error assertions (#11909)
## Summary

Extends the new unsafe Error assertion lint rule from #11845 to also
reject angle-bracket assertions.

## Changes

- **What**: Adds a `TSTypeAssertion` selector alongside the existing
`TSAsExpression` selector and broadens the lint message to cover Error
type assertions generally.

## Review Focus

This is stacked on #11845 and only addresses the lint-rule bypass for
`<Error>value` syntax.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11909-fix-catch-angle-bracket-Error-assertions-3566d73d365081f58ecfecfa2e948c33)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-05-04 14:16:47 -07:00
guill
f70285dcb2 fix(website): point Install via GitHub buttons to install docs anchor (#11852)
*PR Created by the Glary-Bot Agent*

---

## Summary

Updates the "Install via GitHub" CTA buttons on the `/download` page to
deep-link to the install instructions section of the ComfyUI README
(`#installing`) instead of the repo root, so users land directly on
setup steps.

## Changes

- `apps/website/src/config/routes.ts`: add `externalLinks.githubInstall
= 'https://github.com/Comfy-Org/ComfyUI#installing'` (separate from
`externalLinks.github`, which is still used by the navbar/footer/star
badge for the generic repo link).
- `apps/website/src/components/product/local/HeroSection.vue`: switch
the secondary CTA next to "Download Local" from `externalLinks.github`
to `externalLinks.githubInstall`.
- `apps/website/src/components/product/local/EcoSystemSection.vue`: same
swap on the ecosystem-section CTA.

The platform-aware `Download Local` button (Windows/macOS installers via
`useDownloadUrl`) and the generic GitHub social/repo links elsewhere on
the site are unchanged.

## Verification

- `pnpm --filter @comfyorg/website typecheck` — 0 errors
- `pnpm --filter @comfyorg/website test:unit` — 23/23 passing
- `pnpm exec eslint` on changed files — clean
- `pnpm exec oxfmt --check` — clean
- Manual via `pnpm dev` + Playwright DOM assertion on `/download`:
- Hero "INSTALL FROM GITHUB" →
`https://github.com/Comfy-Org/ComfyUI#installing` ✓
- EcoSystem "INSTALL FROM GITHUB" →
`https://github.com/Comfy-Org/ComfyUI#installing` ✓
- Other "GitHub" links (nav, footer, star badge) → unchanged at
`https://github.com/Comfy-Org/ComfyUI` ✓

Per request from #website-and-docs: the local download surfaces should
at least link to the ComfyUI install instructions on GitHub. Companion
change to comfy-org/website#227.

## Screenshots

![Download page hero with INSTALL FROM GITHUB button now linking to
install docs
anchor](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3c608b478e1150f3fc43b6092811c93ff3cd90a253263ab05ac43fe8ce7a0843/pr-images/1777761785467-060efddb-f5a0-44a8-8bbe-287c991171ee.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11852-fix-website-point-Install-via-GitHub-buttons-to-install-docs-anchor-3546d73d365081fe8370cd675ae8f896)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:16:36 -07:00
Christian Byrne
6762c08134 feat(website): add payment success and failed pages (#11855)
*PR Created by the Glary-Bot Agent*

---

## Summary

Recreate the two payment status pages that were never migrated from
Framer (they weren't in the sitemap, so were missed). The Stripe
checkout flow in `comfy-api` redirects to
`https://www.comfy.org/payment/{success,failed}` after a checkout
session, so users currently hit a 404 on completion or cancel.

## Changes

- **What**: New static Astro pages at `/payment/success`,
`/payment/failed` and their `/zh-CN/` variants, sharing a
`PaymentStatusSection.vue` component built from the existing
`BrandButton` / `SectionLabel` primitives. Translation keys live in
`src/i18n/translations.ts` for both locales. Pages are `noindex` and
explicitly excluded from the generated sitemap. Adds a Playwright e2e
spec covering both pages in both locales.
- **Dependencies**: none

## Review Focus

- **URL slug**: matches production Stripe config
(`STRIPE_CANCEL_URL=…/payment/failed` in
`comfy-api/run-service-{prod,staging}.yaml`), not `/payment/failure`.
- **Primary CTA target**: links to `externalLinks.apiKeys`
(`platform.comfy.org/profile/api-keys`) — the platform root is just a
redirect, so this avoids a hop.
- **Locale-aware secondary CTA**: derived from `getRoutes(locale)` so
future i18n/route changes flow through the existing helper.
- **Stale fallback** (out of scope):
`comfy-api/gateways/stripe/stripe.go:159` has an unrelated stale
fallback pointing at `/payments/` (plural) that's overridden by the env
config in production. Worth fixing in a follow-up.

## Screenshots

EN success / failed and zh-CN success / failed at desktop widths. Mobile
viewport stacks the CTA buttons vertically (verified locally).

## Screenshots

![Payment success page (EN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774408278-fd3b63f2-357d-401a-8861-5e45050bc930.png)

![Payment failed page (EN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774408672-a8ada80c-030c-4f7e-805d-c9e3edd2ec1e.png)

![Payment success page (zh-CN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774408994-2ac1dc5a-8556-4ca1-929b-71d8812337e1.png)

![Payment failed page (zh-CN,
desktop)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c44f59e47f22968047255c353237b19fb0543c1e166ec4c315cd9c1085308814/pr-images/1777774409357-a79be0ae-36b3-4c1a-84ce-cb65415fee0a.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11855-feat-website-add-payment-success-and-failed-pages-3556d73d3650819f8f45d8ecf27cb264)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:15:57 -07:00
Alexander Brown
211c49f538 docs(skill): improve backport-management with tiered triage, path pre-filter, and public-API conflict review (#11868)
*PR Created by the Glary-Bot Agent*

---

Synthesizes lessons from the recent `v1.43.16` backport session (PRs
#11856–#11862) into the `.claude/skills/backport-management/` skill.

**Documentation only — no code, no workflow, no automation changes.**

## What's new

### `SKILL.md`
- **Quick Start** expanded from 7 to 12 steps, surfacing the pre-filter
and target-file-existence check that should run BEFORE per-PR triage
- **New gotcha**: *Cherry-Picked Tests Can Reference Files Added By
Earlier Unbackported PRs* — drop the test, document why
- **New gotcha**: *Backport-Only Compatibility Shims* — when a
refactor-style fix's mechanism relies on changes that aren't on the
older branch, a literal cherry-pick can recreate the bug for extensions
still using the old contract. Real example: #11541 + `LGraphNode.vue`
`handleDrop`
- **New section**: *Path Pre-Filter* under Auto-Skip Categories —
auto-skip PRs touching only `apps/website/`, `browser_tests/`,
`.github/`, `packages/design-system/`, generated types, `.claude/`,
`docs/`, or `*.stories.ts`. Removes 30–50% of candidates without reading
their bodies

### `reference/analysis.md`
- **New subsection**: *Verify Target File Existence* — `git cat-file -e`
before cherry-pick to skip PRs targeting features the branch doesn't
ship, faster than letting cherry-pick fail with modify/delete
- **New section**: *Tiered Triage* — **Tier 1** (core editor
must-haves), **Tier 2** (cloud-distribution only), **Tier 3** (skip)
before per-PR Y/N. Surfaces release-engineering decisions a flat
MUST/SHOULD list obscures

### `reference/discovery.md`
- Reconciliation workflow combining Slack bot list + git gap,
subtracting already-backported PRs (extracted via `Backport of #` grep
on the branch)
- Clarifies that no single source is authoritative

### `reference/execution.md`
- **New Step 0**: *Test-Then-Resolve Pre-Pass* — moved the dry-run loop
to the top of the workflow so you classify clean vs conflict before
triggering automation
- **New inline guard**: *Public-API conflict review* in the manual
cherry-pick loop — consult oracle BEFORE pushing if resolution touches
LiteGraph callbacks, `node.*` methods, or extension-API surfaces
- **Per-PR validation block** (typecheck + targeted unit tests + ESLint
+ oxfmt) before push, in addition to wave verification
- **Three new lessons learned** (19–21) covering the above
- **New PR Body Template** for manual cherry-picks — non-negotiable
conflict-resolution section so reviewers don't have to re-derive
resolution logic from the diff six months later

## Why

1. **Path pre-filtering** caught 35 of 61 candidates as `skip` before
any per-PR analysis was needed during the `v1.43.16` session
(`apps/website/`, CI, tests, design-system). The previous flat
MUST/SHOULD/SKIP rubric meant reading every PR.

2. **Oracle review on PR #11856** (#11541 backport) caught a regression
in `LGraphNode.vue:823` where the upstream PR's removal of the legacy
`handled === true` sync-return path would silently break custom nodes
still using the old `onDragDrop` contract — recreating the very
duplicate-node bug the PR was fixing. The skill had no guidance for this
class of issue.

3. **Two separate backports** (#11180, #11541) hit the same
modify/delete conflict pattern: a test file added on `main` by an
earlier unbackported PR. The skill's *Conflict Triage* table covered
modify/delete generally but didn't surface this specific anti-pattern of
smuggling in test scaffolding without its prerequisites.

4. **Discovery sources** — the skill assumed Slack bot + git gap as
inputs but didn't show how to reconcile them with already-backported
PRs, leading to potential double-cherry-picking.

## Verification

- No code, no workflow, no automation changes — skill documentation only
- `git diff --check` clean
- All four edited files render as valid markdown
- Cross-references between SKILL.md and `reference/*.md` files validated
by line-count check

## Out of scope (intentionally not changed)

- The `pr-backport.yaml` GitHub Action — the skill describes this but
doesn't own it
- The Slack bot — described as *Source 1*, not modified
- The PR title convention `[backport TARGET] ...` — kept as-is
(CodeRabbit's auto-skip filter relies on it)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11868-docs-skill-improve-backport-management-with-tiered-triage-path-pre-filter-and-public-3556d73d3650814faec3df795567bee3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-04 14:13:13 -07:00
Christian Byrne
b83602fd23 feat: hide Google SSO button in embedded webviews (#10699)
Hide the Google SSO login/signup button when the app runs inside an
embedded webview (Android WebView, iOS WKWebView, social app in-app
browsers), where Google blocks OAuth with a `403 disallowed_useragent`
error.


Fixes #7017

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10699-feat-hide-Google-SSO-button-in-embedded-webviews-3326d73d365081048e35d9d678fe1a2f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-04 14:08:06 -07:00
Christian Byrne
aee2e6e6dd test: add e2e tests for nested SubgraphNode input target resolution (#11405)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds four Playwright tests targeting `resolveSubgraphInputTarget`
lines 20-31 — the `inputNode.isSubgraphNode()` branch where the target
widget is a PromotedWidgetView
- These lines had 0% e2e coverage because no existing test loaded a
multi-level nested subgraph with VueNodes enabled
- Tests use the existing `subgraph-nested-promotion.json` workflow (node
5 → Sub 0 → node 6 → Sub 1), which has outer SubgraphNode inputs
connecting to an inner SubgraphNode

## Test cases

| Test | Coverage target | Mechanism |
|---|---|---|
| Nested SubgraphNode promoted widgets render without resolution
failures | Lines 20-31 (via VueNodes rendering) | Console warning
collection + widget count assertion |
| Subgraph input resolves through inner SubgraphNode with
PromotedWidgetView | Lines 20-31 (graph structure verification) |
`page.evaluate` walks link chain, asserts `isSubgraphNode() === true`
and `isPromotedWidgetView === true` |
| Promoted widgets from inner SubgraphNode carry correct source identity
| Lines 24-31 (source identity) | Asserts widgets with `sourceNodeId ===
'6'` have correct `sourceWidgetName` |
| Serialize and reload preserves nested promoted widget resolution |
Lines 20-31 (persistence) | `serializeAndReload()` + polled widget count
comparison |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11405-test-add-e2e-tests-for-nested-SubgraphNode-input-target-resolution-3476d73d365081ab932edc8a01c55c40)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-04 13:53:16 -07:00
Christian Byrne
2a3b692c0b Repair: re-add bug-dump-ingest skill (#11460) — GitHub squash-commit incident recovery, step 2 of 2 (#11630)
*PR Created by the Glary-Bot Agent*

---

## Step 2 of 2 — GitHub squash-commit incident recovery for #11460

This is the companion to #11629. After #11629 (the revert) merges, this
PR re-applies the original PR #11460 contents on top of the post-revert
`main`, restoring the intended state of the codebase.

### ⚠️ Sequencing — must merge after #11629

Until #11629 is merged, this PR's diff against `main` is empty (because
`main` currently still contains the squashed bug-dump-ingest commit that
#11629 will revert). After #11629 is merged into `main`, GitHub will
recompute the diff and this PR will show a clean re-add of the same 5
`.claude/skills/bug-dump-ingest/` files.

### Verification

The branch was constructed by:
1. Branching from `glary/revert-pr-11460` (the revert branch in #11629).
2. `git checkout FETCH_HEAD -- .claude/skills/bug-dump-ingest/` from
`refs/pull/11460/head` to restore the exact files.
3. Committing them as a single squash-style commit.

I verified that all 5 file blobs are byte-for-byte identical to the
original squash commit `559922eaa5c129767c22275c206c6877931ac15c` by
comparing git object SHAs:

| File | Object SHA |
|---|---|
| `.claude/skills/bug-dump-ingest/SKILL.md` |
`413737835fa1c996291019483effdd39e6da33e5` |
| `.claude/skills/bug-dump-ingest/reference/examples.md` |
`4fc54a4f14b1359de63f558acca0de48c1b65c57` |
| `.claude/skills/bug-dump-ingest/reference/linear-api.md` |
`57986740df2ee02c19b81059f0f1e00e54c2a042` |
| `.claude/skills/bug-dump-ingest/reference/schema.md` |
`84db1a5818c04ee53a94167092ec76dd814992d4` |
| `.claude/skills/bug-dump-ingest/reference/verify-commands.md` |
`a2c99a43a030ccc3769692d3c09be74132645bb4` |

### Notes

- One commit on `refs/pull/11460/head` (an automated lint commit
`76ca1598e`) modified
`src/renderer/extensions/vueNodes/widgets/components/WidgetChart.test.ts`
to remove an `eslint-disable` directive. That change was **not** in the
original squash commit on `main` (verified via `git show --stat`), and
the directive has separately been removed from `main` by an unrelated
commit (#11550), so re-applying that change would now be a no-op. This
repair PR therefore intentionally restores **only the 5 bug-dump-ingest
files**, matching the original squash commit exactly.
- Branch name is `glary/repair-pr-11460` (the `glary/` prefix is
required by the tooling; otherwise equivalent to GitHub's suggested
`repair-pr-11460`).

Refs: #11460, #11629

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11630-Repair-re-add-bug-dump-ingest-skill-11460-GitHub-squash-commit-incident-recovery--34d6d73d365081acbd54c19316561fa9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:51:29 -07:00
Alexander Brown
dac3396de8 test: add selection paste, rename, and batch rename browser tests (#11367)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `browser_tests/tests/selectionPasteRename.spec.ts` covering the
untested `pasteSelection()` and `renameSelection()` paths in
`useSelectionOperations.ts`.

### Coverage gaps filled
- `pasteSelection()` — copy → paste creates new nodes
- `renameSelection()` single node path — opens title editor
- `renameSelection()` batch path — prompt dialog with sequential naming
- Empty selection → toast warning

### References
- Follows patterns from `selectionToolboxMoreActions.spec.ts` (More
Options menu, `selectNodeWithPan`)
- Follows `browser_tests/AGENTS.md` directory structure
- Follows `browser_tests/FLAKE_PREVENTION_RULES.md` assertion patterns

### Verification
- TypeScript: clean
- ESLint: clean
- oxlint: clean
- oxfmt: formatted

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11367-test-add-selection-paste-rename-and-batch-rename-browser-tests-3466d73d3650812194a4d8bfbed3dee7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 20:30:42 +00:00
Christian Byrne
d253d87c92 Revert "feat: add bug-dump-ingest skill (#11460)" — GitHub squash-commit incident recovery (#11629)
*PR Created by the Glary-Bot Agent*

---

## Step 1 of 2 — GitHub squash-commit incident recovery for #11460

GitHub flagged the squash commit for #11460
(`559922eaa5c129767c22275c206c6877931ac15c`) as affected by the
squash-commit incident that produced non-deterministic merges. This PR
is **step 1 of 2** in the recovery procedure GitHub asked us to follow.

### What this PR does

Reverts `559922eaa5c129767c22275c206c6877931ac15c` (the affected squash
commit for #11460, "feat: add bug-dump-ingest skill") on top of current
`main`.

### Diff

5 files removed, all under `.claude/skills/bug-dump-ingest/`:
- `.claude/skills/bug-dump-ingest/SKILL.md`
- `.claude/skills/bug-dump-ingest/reference/examples.md`
- `.claude/skills/bug-dump-ingest/reference/linear-api.md`
- `.claude/skills/bug-dump-ingest/reference/schema.md`
- `.claude/skills/bug-dump-ingest/reference/verify-commands.md`

`git revert` applied cleanly with no conflicts.

### Recovery procedure

Per GitHub's instructions:

1. **This PR (step 1)** — Revert the affected squash commit. Removes
both the original changes and any unintended changes that the incident
may have introduced.
2. **Next PR (step 2)** — Re-apply the original PR's changes from
`refs/pull/11460/head`, rebased onto post-revert `main`. Will be opened
as a separate "repair" PR.

### Review notes

- This is a pure revert; please confirm the diff is exactly the inverse
of #11460.
- After this PR is merged, the companion repair PR will land the
original changes back on `main` from a clean source ref, restoring the
intended state of the codebase.
- Branch name is `glary/revert-pr-11460` (the `glary/` prefix is
required by the tool that opened this PR — it's otherwise equivalent to
GitHub's suggested `revert-pr-11460`).
- Code review: Oracle reviewed and found 0 issues
(critical/warning/suggestion all 0). Ready to merge.

Refs: #11460

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11629-Revert-feat-add-bug-dump-ingest-skill-11460-GitHub-squash-commit-incident-recove-34d6d73d3650810f9ed3f6068e4f1511)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:17:28 -07:00
Marwan Ahmed
4033dde983 refactor(website): replace UseCaseSection scroll override with hover on desktop (#11696)
## Summary

Per product feedback, scroll-jacking the page to step through
'Industries that create with ComfyUI' categories feels bad on desktop.
This PR removes the desktop scroll override and switches to hover-driven
imagery on lg+, while preserving the existing pin/scrub interaction on
mobile/touch breakpoints.

## Changes

- **What**:
- Gate `usePinScrub` setup to `(max-width: 1023px)` so the pin/scrub
only runs on touch breakpoints; desktop never engages it.
- Wire `@mouseenter` and `@focus` on each category button to update the
active category on desktop. Click still works on both modes via the
existing `scrollToIndex` (which falls through to a direct ref set when
no ScrollTrigger instance is present).
- Pass the same `(max-width: 1023px)` to `useParallax` via its existing
`mediaQuery` option so parallax doesn't run against the no-longer-pinned
section on desktop.
- Apply the section's `lg:h-[calc(100vh+60px)]` unconditionally on lg+
since pin no longer drives it; mobile height is still managed
dynamically by `usePinScrub`'s `cacheLayout`.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- Mobile path inside `usePinScrub.onMounted` is byte-identical to main —
only the early-return condition gained one extra clause
(`!window.matchMedia('(max-width: 1023px)').matches`), which mobile
evaluates to `false` so it falls through to the existing setup.
- `onCategoryHover` early-returns when `isEnabled` is true, making it a
no-op on mobile (where pin is engaged), so a tap doesn't accidentally
fight the scrub.
- `@focus` is wired alongside `@mouseenter` so keyboard tab navigation
also previews the imagery.
- The previous Lenis-on-macOS workaround from this branch is reverted —
it was only needed because the scroll override existed.

## Screenshots (if applicable)

N/A — interaction change. Test on desktop (≥1024px) by hovering category
labels — imagery should swap with no scroll-jacking. Test on mobile
(<1024px) by scrolling the section; pin/scrub should engage as before.

---------

Co-authored-by: Marwan Ahmed <marwan@Marwans-MacBook-Pro.local>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-04 13:16:34 -07:00
Christian Byrne
61a444ed99 test: combine duplicated undo/redo and settings dialog E2E tests with test.step (#11835)
## Summary

Refactor E2E tests added in #11210 that repeated full prior-test bodies
as setup, combining duplicate pairs into single tests with named
`test.step()` blocks.

## Changes

- **What**: In
[`browser_tests/tests/keyboardShortcutActions.spec.ts`](../blob/batch-dispatch/cr-11556/browser_tests/tests/keyboardShortcutActions.spec.ts):
- Merge `Ctrl+Z undoes` + `Ctrl+Shift+Z redoes` → single test with two
`test.step()` blocks.
- Merge `Ctrl+, opens settings dialog` + `Escape closes settings dialog`
→ single test with two `test.step()` blocks.
- **What**: In
[`browser_tests/tests/topbarMenuCommands.spec.ts`](../blob/batch-dispatch/cr-11556/browser_tests/tests/topbarMenuCommands.spec.ts):
- Merge `Edit > Undo` + `Edit > Redo` → single test with two
`test.step()` blocks.

The redo step now reuses the post-undo state from its preceding step
instead of re-creating and re-undoing the node, removing the duplicated
setup the reviewer flagged.

## Review Focus

- Naming of combined tests and `test.step()` labels.
- Note: per @AustinMroz's [comment
thread](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11210#discussion_r3113526265),
location 2 in the issue refers to the `Escape closes settings dialog`
test (which duplicated the `Ctrl+,` test body), not the `Delete` test
(which has unique logic). Treated accordingly.

Fixes #11556

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11835-test-combine-duplicated-undo-redo-and-settings-dialog-E2E-tests-with-test-step-3546d73d365081689df3c56bfbb6f4e4)
by [Unito](https://www.unito.io)
2026-05-04 12:54:52 -07:00
Christian Byrne
385a1d421d [chore] Update Comfy Registry API types from comfy-api@84a4468 (#11910)
## Automated API Type Update

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

- API commit: 84a4468
- Generated on: 2026-05-04T15:45:35Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11910-chore-Update-Comfy-Registry-API-types-from-comfy-api-84a4468-3566d73d365081ef8771cb77b5de6119)
by [Unito](https://www.unito.io)

Co-authored-by: bigcat88 <13381981+bigcat88@users.noreply.github.com>
2026-05-04 19:27:33 +00:00
Christian Byrne
341fef46a9 refactor: replace unsafe as Error assertions with type guards (#11845)
## Summary

Replaces all 7 production `as Error` type assertions with proper
`instanceof Error` narrowing or a new `toError()` helper, and adds an
ESLint rule to prevent new ones. First slice of #11429 (the `as Error`
category — 9 total occurrences, 7 production + 2 in a test file left
untouched).

## Changes

- **What**:
- New `src/utils/errorUtil.ts` exporting `toError(value: unknown):
Error` and `getErrorMessage(value: unknown): string | undefined`.
`toError` returns the value unchanged if already an `Error`, otherwise
wraps it (handles strings, `undefined`, JSON-serializable objects, and
circular refs via `String()` fallback).
  - Refactored 7 production call sites:
- `src/services/gateway/registrySearchGateway.ts` — `toError(error)` for
`lastError` assignment in fallback loop
- `src/platform/cloud/onboarding/auth.ts` (×2) — `toError(error)` for
`captureApiError` Sentry calls
-
`src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts`
— `toError(err)` before forwarding to `options.onError`
- `src/extensions/core/load3d/LoaderManager.ts` — replaced `error as
Error & { response?: ... }` cast inside `isNotFoundError` with
`'response' in error` + nested narrowing
- `apps/desktop-ui/src/stores/maintenanceTaskStore.ts` — inline `error
instanceof Error ? error.message : String(error)`
- `apps/desktop-ui/src/components/maintenance/TaskListPanel.vue` —
inline `error instanceof Error ? error.message : undefined`
- New ESLint rule (`no-restricted-syntax` block named
`comfy/no-unsafe-error-assertion`) banning `TSAsExpression
TSTypeReference[typeName.name='Error']` in `src/**` and `apps/*/src/**`,
with test files (`*.test.ts`, `*.spec.ts`) excluded.
  - 12 unit tests for the new helpers in `src/utils/errorUtil.test.ts`.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

- The lint rule is scoped to non-test source files. Test files retain
freedom to use `as Error` for fixture construction; only 2 occurrences
exist (in `teamWorkspaceStore.test.ts` and `errorDialog.spec.ts`) and
they're intentional.
- `toError` is duplicated as inline `instanceof` narrowing in
`apps/desktop-ui/` rather than imported, since the desktop-ui workspace
doesn't share `@/utils/` with the main app and adding a path mapping for
one helper felt heavier than two inline guards.
- Remaining `as`-on-DOM categories (HTMLElement ×133, HTMLInputElement
×55, HTMLCanvasElement ×36, KeyboardEvent ×7, Element ×3, MouseEvent ×2,
Event ×2) are intentionally left for follow-up PRs to keep this one
reviewable.

Refs #11429

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11845-refactor-replace-unsafe-as-Error-assertions-with-type-guards-3546d73d36508137a015c4f9e8708f23)
by [Unito](https://www.unito.io)
2026-05-04 11:40:28 -07:00
Yourz
24b548aebc fix: route footer Support link to Zendesk help center (#11904)
*PR Created by the Glary-Bot Agent*

---

## Summary

The "Support" link in the marketing site footer (Contact column) was
reusing
the Discord external link. Update it to point at the Zendesk help center
at
`https://support.comfy.org/hc/en-us`, as requested in the
`#website-and-docs` Slack thread.

## Changes

- `apps/website/src/config/routes.ts` — add `support` entry to
`externalLinks`
  pointing at `https://support.comfy.org/hc/en-us`.
- `apps/website/src/components/common/SiteFooter.vue` — use
  `externalLinks.support` for the Contact > Support entry instead of
  `externalLinks.discord`.

## Verification

- `pnpm format` and `pnpm exec eslint` clean on both files.
- `pnpm typecheck` passes.
- Verified locally with `pnpm dev` (Astro on `localhost:4321`); the
rendered
footer Support link now resolves to `https://support.comfy.org/hc/en-us`
  (screenshot below).

## Notes

Reviewer flagged that `/hc/en-us` forces English and bypasses Zendesk
locale
negotiation. The exact URL was explicitly requested by the user in the
Slack
thread, so it is preserved here. Switching to a locale-neutral
`https://support.comfy.org/` can be done as a follow-up if desired.


## Screenshots

![Marketing site footer with Support link pointing to
support.comfy.org/hc/en-us](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7cb1cde676098ecfc7a07ab2b8d341ba402b097e134b5eaaf42572e925bd6d40/pr-images/1777906238675-00158842-4368-478a-ae6e-c91d536a7986.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11904-fix-route-footer-Support-link-to-Zendesk-help-center-3566d73d36508189abcff34ae766d3c4)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 17:28:39 +00:00
Christian Byrne
6ea278da30 [chore] Update Comfy Registry API types from comfy-api@9ec8c25 (#11906)
## Automated API Type Update

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

- API commit: 9ec8c25
- Generated on: 2026-05-04T15:11:04Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11906-chore-Update-Comfy-Registry-API-types-from-comfy-api-9ec8c25-3566d73d365081e394a6c13c0e30499b)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-05-04 16:32:34 +00:00
Christian Byrne
560e53c68f fix: remove coming soon badge from parallel job execution (#11819)
*PR Created by the Glary-Bot Agent*

---

Removes the "coming soon" badge from the Parallel Job Execution feature
card on the cloud pricing page (`comfy.org/cloud/pricing`).

## Changes

- `apps/website/src/components/pricing/WhatsIncludedSection.vue`: drop
`isComingSoon: true` from feature11 so it renders with the standard
check icon and no badge.

The `isComingSoon` mechanism (clock icon + yellow badge) is preserved in
the component for future use on other features.

## Note

The FAQ copy elsewhere on the site (`cloud.faq.9.a`) still references
"one active job at a time" and "parallel runs soon". That copy will be
updated separately.

## Verification

- `pnpm typecheck` (website): 0 errors
- `pnpm lint`: clean (1 pre-existing warning unrelated to this change)
- `pnpm format:check`: clean
- `pnpm test:unit` (website): 20 passed
- Visual check via Playwright on local dev server (see screenshot)

## Screenshots

![Pricing page after change: Parallel job execution row shows green
check icon and no coming soon
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/54c41067c2ba0bce5de11dd3b919e3c370be4eba2fd44eb3c411921f34bc088e/pr-images/1777688853166-87c5c07e-e4ad-4ef3-a892-f3e01e2f980f.png)

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11819-fix-remove-coming-soon-badge-from-parallel-job-execution-3546d73d365081d19060f976095d03ac)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:27:31 +00:00
Yourz
1999b7fba0 fix: remove (beta) from cloud.faq.3.a (#11905)
*PR Created by the Glary-Bot Agent*

---

## Summary

Remove `(beta)` from the `cloud.faq.3.a` translation entry in both
English and Simplified Chinese (`zh-CN`), since Comfy Cloud is no longer
in beta.

## Changes

`apps/website/src/i18n/translations.ts`:
- en: `Comfy Cloud (beta) has zero setup...` → `Comfy Cloud has zero
setup...`
- zh-CN: `Comfy Cloud(测试版)无需任何设置...` → `Comfy Cloud 无需任何设置...`

## Verification

- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:website)
all passed
- Code review (oracle): 0 issues, ready to merge
- Manual verification via Playwright on `/cloud` and `/zh-CN/cloud` —
FAQ item 3 renders updated copy in both locales (screenshots attached)

## Screenshots

![English FAQ item 3 expanded — 'Comfy Cloud has zero setup...' (no
beta)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906512798-b5b8fc07-1ed1-43e2-88f5-35efd6ee7254.png)

![Simplified Chinese FAQ item 3 expanded — 'Comfy Cloud 无需任何设置...' (no
测试版)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906513275-1c0c0f6b-0408-4cc2-93e6-4a5e0d02a101.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11905-fix-remove-beta-from-cloud-faq-3-a-3566d73d36508150997bcf2c89826091)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:03:06 +00:00
Christian Byrne
285421a87c feat: add queue progress overlay feature survey (#11560)
*PR Created by the Glary-Bot Agent*

---

## Summary

Registers a new nightly feature survey for the Queue Progress Overlay
using the existing feature-survey registry (same pattern as the merged
node-search survey, PRs #8175/#8355/#9934).

- New registry entry `queue-progress-overlay` → Typeform `HZ5saxry`,
threshold **16**, 5s display delay.
- `trackFeatureUsed()` wired at the major user-initiated handlers inside
the overlay so the survey triggers regardless of panel location
(floating-right v1 or docked-left v2).
- Run button and other ActionBar items that the overlay pops over from
are deliberately **not** tracked — tracking is scoped to interactions
that originate inside the job panel / queue progress overlay itself.

## Tracked interactions

Both variants share most sub-components, so tracking is instrumented
once at each logical surface:

- **`QueueProgressOverlay.vue`** (v1 container): `viewAllJobs`,
`interruptAll`, `cancelQueuedWorkflows`, `onClearHistoryFromMenu`,
`toggleAssetsSidebar`, `onCancelItem`, `onDeleteItem`, `inspectJobAsset`
- **`QueueOverlayExpanded.vue`**: job tab switches
- **`JobHistorySidebarTab.vue`** (v2 docked): job tab switches,
`clearQueuedWorkflows`, `onClearHistory`, `onCancelItem`,
`onDeleteItem`, `onViewItem`
- **`JobFilterActions.vue`** (shared): workflow filter + sort mode
selections
- **`JobHistoryActionsMenu.vue`** (shared): docked-history toggle +
run-progress-bar toggle

Deliberately **not tracked** to keep the signal clean:
- Hover handlers (ambient preview behaviour)
- Search-box keystrokes (debounced typing)
- Context menu open and menu-item dispatch — menu actions either bubble
through already-tracked terminal handlers (e.g. inspect-asset →
`onViewItem`) or are secondary operations (copy-id, open-workflow,
download). Avoids double-counting per code review feedback.

## How it works (inherits from existing infrastructure)

1. `surveyRegistry.ts` drives `NightlySurveyController` →
`NightlySurveyPopover`, which handles the Typeform embed.
2. Eligibility already gated on `isNightly && !isCloud && !isDesktop`,
once-per-user, 4-day global cooldown across all surveys, and opt-out.
3. Typeform response routing to #C0ALLT6Q3SQ is handled on the Typeform
side.

## Verification

- `pnpm typecheck` 
- `pnpm lint`  (no new warnings)
- `pnpm knip` 
- `pnpm test:unit` on `src/components/queue`,
`src/components/sidebar/tabs/JobHistorySidebarTab`,
`src/platform/surveys` → **123/123 passing**
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck) all
pass
- Manual: dev server + backend boot cleanly, app loads without new
runtime errors, `localStorage['Comfy.FeatureUsage']` layout verified to
match what `useFeatureUsageTracker` writes

## Notes

- Survey key `queue-progress-overlay` covers both v1 (floating-right)
and v2 (docked-sidebar) per product guidance: _"This should trigger
regardless of the location of the panel (docked from left or floating on
right)."_ Both surfaces are the same product feature — the survey is
intentionally scoped to the whole job-panel experience.


## Screenshots

![App loads cleanly with the new survey code in place — empty canvas
with Run button and sidebar, no runtime
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fd18977704544ba278ad3fa42c695289ae7e02001550ce38955d6fb47d872146/pr-images/1776914667332-03e4ef0a-4137-47c6-87b8-b554770b8900.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11560-feat-add-queue-progress-overlay-feature-survey-34b6d73d3650819a9a50fd67fd9b5941)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:50:18 +00:00
Christian Byrne
5523df1aea fix(website): unstretch See all case studies button (#11854)
*PR Created by the Glary-Bot Agent*

---

## Summary

The "See all case studies" button on the homepage
`CaseStudySpotlightSection` was rendering oddly stretched because it had
`class="flex-1 text-center"` while being the sole child of a `flex-row`
container — it expanded to fill the entire content column (~592px)
instead of sizing to its label.

This drops `flex-1`/`text-center` and adds `items-start` to the wrapper
so the button sizes to its content and is left-aligned, matching the
proportions of every other outline `BrandButton` on the site (Hero,
UseCase, customer detail, etc.).

## Changes

- `apps/website/src/components/home/CaseStudySpotlightSection.vue`:
remove `flex-1 text-center` from the `BrandButton` and align the row's
items to the start.

`BrandButton` already centers its label internally via `inline-flex …
justify-center`, so dropping `text-center` is a no-op visually.

## Before / After

- Desktop before: button width = 592px (stretched across the column)
- Desktop after: button width = 223px (natural)
- Mobile: 1-column layout, now consistently left-aligned

## Review Focus

Whether the fix should also live on the `BrandButton` component itself
(e.g. a global `max-width`) instead of at the call site. I went with the
instance-level fix because every other CTA in the website intentionally
uses bare `BrandButton` and lets the content size it; only this one had
`flex-1`. A blanket `max-width` would risk changing Hero/MobileMenu
buttons that explicitly opt into `w-full lg:w-auto lg:min-w-60`.

## Screenshots

![Before: button stretched across the full content
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244354-4dd9af45-2458-4d8a-a1a7-1f6b88b6fc4b.png)

![After: button sized to content,
left-aligned](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244808-5bab2801-0140-4b4a-9d9e-61a467090de3.png)

![After: mobile view, left-aligned natural
width](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774245316-1ca9609d-3de0-4c85-973e-a87e296fa65f.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11854-fix-website-unstretch-See-all-case-studies-button-3556d73d365081abb3bbe9dbc51cbc07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:03:39 +00:00
Christian Byrne
65876c635d feat(website): add responsive media tooling for marketing assets (#11869)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the building blocks for a responsive media system on
`apps/website`, motivated by the gallery video blurriness raised in
Slack. Three independent pieces:

1. **`<SiteVideo>` Vue component + URL helper** — emits a `<video>` with
multiple `<source>` tags, designed to pair with assets named
`${name}-${width}.${format}` on `media.comfy.org`.
2. **`scripts/process-videos.sh`** — local-developer `ffmpeg` helper
that produces VP9/WebM + H.264/MP4 variants and a poster JPG. Not wired
into CI; the team uploads to `media.comfy.org` out-of-band.
3. **Marketing image conventions** — shared `MARKETING_FORMATS` /
`MARKETING_WIDTHS` constants and a README documenting how to render
local marketing images via Astro's built-in `<Picture>` from
`astro:assets`.

This PR is **infrastructure only** — no existing pages are modified.
Adoption (e.g. converting `HeroSection`, gallery videos) is a follow-up.
The new files are added to knip's ignore list with the existing "pending
stacked PR" pattern.

## Why this shape

- **No custom `<Picture>` wrapper.** Astro 5 already ships a
`ResponsiveImage` component (name conflict), and Astro's
`LocalImageProps | RemoteImageProps` discriminated union does not
survive a thin wrapper without unsafe `as` casts. Shared constants give
the consistency benefit at lower cost.
- **No CI media-upload step.** The `Release: Website` workflow currently
only refreshes the Ashby snapshot; wiring GCS uploads into it would
require new secrets and team coordination beyond this PR's scope. The
script runs locally and outputs are uploaded to `media.comfy.org` the
same way as today.
- **Single resolution per `<video>`.** `<source media="...">` inside
`<video>` is unreliable across browsers (Safari ignores it). The script
generates multiple widths so callers can pick one per page; JS-based
selection can be layered on later if metrics demand it.

## What's verified

- `pnpm --filter @comfyorg/website test:unit` — 30 pass (7 new for
`buildVideoSources` / `videoKey`)
- `pnpm --filter @comfyorg/website typecheck` — clean
- `pnpm --filter @comfyorg/website build` — 41 pages built clean
- `pnpm knip` — exit 0
- `oxfmt --check` and `oxlint` clean on all changed files
- `bash -n` on `process-videos.sh` clean; usage and missing-deps paths
exercised manually
- Manual: home page and `/gallery` rendered via `astro dev` — both
unchanged with zero console errors (screenshots attached)

## Review feedback addressed

After Oracle review, three follow-up commits land:

- **`SiteVideo` reactivity** — `sources` is now `computed`; the
`<video>` is keyed on the joined source URLs so it remounts when the
source set changes (browsers don't reload on `<source>` mutation).
- **`SiteVideo` accessibility** — `aria-hidden="true"` only when truly
decorative (no `alt` and no `controls`).
- **Shell script robustness** — probes duration with `ffprobe` and falls
back to `t=0` for clips shorter than 1s; enables `nocaseglob` so
`CLIP.MP4` is picked up.
- **Docs** — clarifies when to use `<SiteVideo>` (lightweight
multi-source) vs `<VideoPlayer>` (captions, controls, scrubber).

## Out of scope (follow-ups)

- Converting existing pages (`HeroSection`, customer detail heros,
gallery) to use the new components. Most current images are CDN-hosted
and migrating them is a separate decision.
- Re-encoding the gallery videos at a higher source width to actually
fix the blurriness — that requires the team to run `process-videos.sh`
against the source clips and re-upload.
- Combining `<SiteVideo>`'s multi-source support with `<VideoPlayer>`'s
rich chrome.

## Screenshots

![Home page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791647863-fb1ea2bf-32fc-40d9-852d-cceb3bc148f7.png)

![Gallery page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791648186-0b598260-a836-4866-9c55-9d0e99de6d4c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11869-feat-website-add-responsive-media-tooling-for-marketing-assets-3556d73d3650818899c7f9ed3204c9a5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:25:20 +00:00
jaeone94
04918360eb Use hash lookup for missing asset detection (#11873)
## Summary

Use exact BLAKE3 hash lookups first for missing model/media detection,
and add a separate public-inclusive input asset cache so public input
assets are considered missing-detection candidates without changing the
user-only input assets shown in the UI.

## Changes

- **What**:
- Added `assetService.checkAssetHash()` for `HEAD
/api/assets/hash/{hash}` status-only existence checks.
- Added strict BLAKE3 hash helpers so only `blake3:<64 hex>` media
values and raw 64-hex BLAKE3 model metadata are sent to the hash
endpoint.
- Updated missing media detection to group BLAKE3 candidates by hash,
resolve them through the hash endpoint, and fall back to the legacy
asset list path for invalid/unverifiable/non-hash values.
- Updated missing model detection to use hash lookup for BLAKE3-backed
asset-supported candidates before falling back to the existing node-type
asset matching path.
- Added `assetService.getInputAssetsIncludingPublic()` backed by a
dedicated cache that fetches input assets with `include_public=true` for
missing media fallback checks.
- Kept `assetsStore.inputAssets` user-only for widget/UI display, while
invalidating the public-inclusive missing-detection cache when input
assets may change.
- Added abort handling for paginated asset fetches and shared
public-input cache callers so one aborted caller does not cancel the
shared fetch for other callers.
- Added regression coverage for hash lookup, fallback behavior, abort
paths, public input fallback detection, and cache invalidation.
- **Dependencies**: None.
- **Change size**:
  - Production code: 4 files, 400 insertions, 24 deletions, net +376.
  - Test code: 4 files, 806 insertions, 59 deletions, net +747.
  - Total: 8 files, 1206 insertions, 83 deletions, net +1123.

## Review Focus

- The public-inclusive input asset cache is intentionally separate from
`assetsStore.inputAssets`. The existing store data is user-only and
drives the asset widgets/sidebar, so using it for missing input
detection misses public assets. Making that store public-inclusive would
change UI data semantics; this PR instead keeps the UI dataset unchanged
and adds a missing-detection-specific cache in `assetService`.
- Hash lookup is only used when the workflow exposes a valid BLAKE3
hash. Filename-like values and invalid hash values still use the legacy
fallback path.
- Missing model detection keeps the existing fallback behavior for
non-hash candidates and for hash checks that are invalid or fail
transiently.
- Async model download cache refresh behavior is left unchanged; this PR
avoids coupling model download completion to input asset cache
invalidation.
- No browser/e2e test was added because this changes the missing asset
detection data path, not UI interaction or rendering. The behavioral
coverage is in unit tests for the asset service and the missing
media/model scanners.

## Follow-up Items

- Fix `assetsStore.updateAssetTags()` partial-failure recovery. If
`removeAssetTags()` succeeds and `addAssetTags()` fails, the local model
asset cache can roll back to tags that the backend has already removed;
this should be handled in a focused model asset cache PR.
- Consider extracting shared hash-verification flow used by missing
media and missing model scans after this behavior stabilizes.
- Consider adding a concurrency cap or short-lived request cache for
large workflows with many unique hash lookups.
- Consider splitting `assetService.ts` further, e.g. hash helpers, abort
utilities, and the public-inclusive input asset cache.
- Consider tightening the asset hash service API shape so callers do not
directly depend on HTTP-oriented statuses such as `invalid`.
- Consider adding broader mutation-path coverage for public-inclusive
input cache invalidation once the cache has more consumers.

Linear: FE-534

## Screenshots (if applicable)

Before <false positive / missing image / public asset>


https://github.com/user-attachments/assets/db7ce2a9-b169-4fae-bf9f-98bb93d3ee6d

After 


https://github.com/user-attachments/assets/29af9f9e-b536-4fcd-a426-3add40bcb165



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11873-Use-hash-lookup-for-missing-asset-detection-3556d73d36508165babafb16614be0d8)
by [Unito](https://www.unito.io)
2026-05-04 03:59:54 +00:00
Dante
af70d88860 fix: keep finished badge fully opaque in ProgressToastItem (#11542)
## Summary
- fix
**[slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776801170742579)**
- Move `opacity-50` off the row container onto the asset-name column
only, so the contrast badge (white pill, dark label) is not dimmed to
gray-on-gray when a download completes.
- Matches the Figma intent that the `FINISHED` badge stands out —
designer spec uses `base/foreground` for pill, `base/background` for
text, which is unreadable when the parent is 50% opacity.

<img width="560" height="269" alt="Screenshot 2026-04-23 at 2 46 17 PM"
src="https://github.com/user-attachments/assets/fb84aa57-c348-4a86-9a65-9342c12400e1"
/>
<img width="764" height="332" alt="Screenshot 2026-04-23 at 2 46 41 PM"
src="https://github.com/user-attachments/assets/ecbe6a5f-c2e8-4427-9c1d-f8f123009d2e"
/>


## Before / After

![before /
after](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/jaewon/fe-237-fix-honeytoast-badge-finished-opacity/.github/pr-images/fe-237-before-after.png)

## Repro
Cloud → trigger a model download → wait for completion → the `FINISHED`
badge is the same tone as the toast surface (see Slack thread
screenshots).

## Test plan
- [ ] Complete a model download in cloud and confirm the `FINISHED`
badge is clearly legible in both themes.
- [ ] File name + subtitle still appear dimmed to signal the row is
completed.
- [ ] Running / failed / pending states unchanged.

- Fixes
[FE-237](https://linear.app/comfyorg/issue/FE-237/fix-honeytoast-badge-text-color-for-finished-job-matches-background)
2026-05-03 08:40:27 +00:00
Christian Byrne
c955309b26 [chore] Update Comfy Registry API types from comfy-api@911406c (#11518)
## Automated API Type Update

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

- API commit: 911406c
- Generated on: 2026-04-17T16:10:40Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11518-chore-Update-Comfy-Registry-API-types-from-comfy-api-911406c-3496d73d36508146a1e2e1ee90640fa4)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-03 01:01:41 -07:00
Comfy Org PR Bot
7abd9d12c8 chore(website): refresh Ashby roles snapshot (#11851)
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.

The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.

Triggered by workflow run `25260868155`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11851-chore-website-refresh-Ashby-roles-snapshot-3546d73d365081579f98f13f7b58e611)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-02 14:15:34 -07:00
176 changed files with 11964 additions and 4969 deletions

View File

@@ -9,13 +9,18 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## Quick Start
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
5. **Execute**Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
6. **Verify** — After each wave, verify branch integrity before proceeding
7. **Log & Report** — Generate session report (`reference/logging.md`)
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
5. **Analyze**Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
## System Context
@@ -107,6 +112,35 @@ Husky hooks fail in worktrees (can't find lint-staged config). Always use `git p
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
```bash
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
```
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
### Backport-Only Compatibility Shims
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
- `node.onXxx` callback assignments
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
- Public exports from `src/lib/litegraph/`
- Type changes affecting `litegraph-augmentation.d.ts`
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
@@ -147,6 +181,26 @@ Skip these without discussion:
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
### Path Pre-Filter (run BEFORE reading PR bodies)
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
| Path prefix | Bucket | Reason |
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
| `.github/workflows/` only | SKIP | CI/release infra |
| `packages/design-system/` only | SKIP | Design tokens, not core |
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
| `*.stories.ts` only | SKIP | Storybook only |
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
## Wave Verification
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:

View File

@@ -39,6 +39,89 @@ Check before backporting — these don't exist on older branches:
- **App builder** — check per branch
- **appModeStore.ts** — not on core/1.40
### Verify Target File Existence (Run Before Cherry-Pick)
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
```bash
# For each file the PR changes
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
echo "MISSING on $TARGET: $f"
fi
done
```
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
## Tiered Triage (Recommended for 30+ Candidates)
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
### Tier 1 — Core Editor Must-Haves
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
Indicators:
- `fix:` prefix and the bug is reproducible on the target branch
- Crash guards, runtime null checks, race-condition fixes
- Data-loss bugs (state not persisted, duplicates, drops)
- Security hardening (CSRF, XSS, auth)
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
- Subgraph correctness fixes
- Public-API extension callback fixes
Recommend `Y` to user.
### Tier 2 — Cloud-Distribution Only
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
Indicators:
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
- PR description mentions cloud staging issues
- Fix gated behind cloud feature flags
Default: ask the cloud release rotation owner. If unsure, defer.
### Tier 3 — Skip
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
Indicators:
- All changes in test files even if the PR touched `src/` test files
- Storybook stories only
- Lint config / lint rule additions
- Documentation comments
- Internal refactors with no behavior change
### Presentation Format
When showing tier results to the user, format as:
```text
Tier 1 (N PRs) — strong backport candidates
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
- #10849 fix: store promoted widget values per SubgraphNode instance
Why: Multiple instances overwriting each other's promoted widget values — data loss
Tier 2 (N PRs) — cloud-distribution release rotation should decide
- #11636 fix: enable Chrome password autofill on signup form
- ...
Tier 3 (N PRs) — skip recommended
- #11586 fix: website polish (apps/website/ only)
- ...
```
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
## Dep Refresh PRs
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.

View File

@@ -1,5 +1,11 @@
# Discovery — Candidate Collection
**Run all sources, then reconcile.** No single source is authoritative:
- Slack bot may flag PRs that have already been backported (false positive)
- Git gap may include PRs that don't need backport (test-only, design-system, website)
- Bot can also miss PRs that landed without the right labels
## Source 1: Slack Backport-Checker Bot
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
@@ -36,7 +42,43 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
gh pr view $PR --json files --jq '.files[].path'
```
## Source 4: Already-Backported PRs (cross-reference)
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
```bash
# Get all original PR numbers already backported to TARGET since the last release tag
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
| grep -oE "[0-9]+" \
| sort -un > /tmp/already-backported.txt
```
Subtract this list from your candidates.
## Reconciliation Workflow
```bash
# 1. Slack bot list (parse from export)
# /tmp/bot-flagged.txt — one PR# per line, sorted
# 2. Git gap fix/perf only
MB=$(git merge-base origin/main origin/$TARGET)
git log --format="%h|%s" $MB..origin/main \
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
| sort -un > /tmp/gap-fixes.txt
# 3. Already backported (Source 4 above)
# 4. Candidates = (gap-fixes bot-flagged) already-backported
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
```
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
## Output: candidate_list.md
Table per target branch:
| PR# | Title | Category | Flagged by Bot? | Decision |
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |

View File

@@ -6,6 +6,43 @@
2. Medium gap next (quick win)
3. Largest gap last (main effort)
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
cd /tmp/dryrun-TARGET
CLEAN=()
CONFLICT=()
for pr in "${CANDIDATES[@]}"; do
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
if git cherry-pick -m 1 $SHA 2>/dev/null; then
CLEAN+=($pr)
else
CONFLICT+=($pr)
git cherry-pick --abort
fi
git checkout --detach HEAD 2>/dev/null
git branch -D dryrun-$pr 2>/dev/null
done
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
cd -
git worktree remove /tmp/dryrun-TARGET --force
```
Use the result to:
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
## Step 1: Label-Driven Automation (Batch)
```bash
@@ -88,6 +125,39 @@ for PR in ${CONFLICT_PRS[@]}; do
git add .
GIT_EDITOR=true git cherry-pick --continue
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
# If the conflict resolution touched any of these surfaces, consult oracle
# BEFORE pushing. A bad shim is worse than no fix:
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
# - Public exports from src/lib/litegraph/
# - Type changes in litegraph-augmentation.d.ts
# If a public callback's signature/contract changed: add a backport-only
# compatibility shim that preserves the OLD contract while keeping the
# new fix. Document it in the commit body under
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
# ───────────────────────────────────────────────────────────────────────
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
pnpm typecheck
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
if [ ${#TEST_FILES[@]} -gt 0 ]; then
pnpm test:unit -- run "${TEST_FILES[@]}"
else
echo "No changed test files — skipping targeted unit tests"
fi
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
if [ ${#CODE_FILES[@]} -gt 0 ]; then
pnpm exec eslint "${CODE_FILES[@]}"
pnpm exec oxfmt --check "${CODE_FILES[@]}"
else
echo "No changed ts/vue files — skipping targeted lint/format"
fi
git push origin backport-$PR-to-TARGET --no-verify
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
@@ -243,6 +313,9 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
## CI Failure Triage
@@ -268,3 +341,40 @@ Common failure categories:
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
## PR Body Template (Manual Cherry-Picks)
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
```markdown
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
Cherry-picked from upstream merge commit `SHORT_SHA`.
## Why
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
## Conflict resolution
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
- [...]
## Backport-only compatibility fix (if applicable)
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
## Validation
- `pnpm typecheck`
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
- `pnpm exec eslint <changed files>` ✅ (0 errors)
- `pnpm exec oxfmt --check` ✅ (clean)
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
Original PR: #ORIG / Original commit: `FULL_SHA`
```
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"

View File

@@ -1,695 +0,0 @@
---
name: bug-dump-ingest
description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.'
---
# Bug Dump Ingest
**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically.
```text
fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals
→ POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call)
→ poll slack_read_thread → parse Linear bot card for FE-NNNN + URL
→ POST :white_check_mark: confirmation thread reply via slack_send_message
→ if verification = "real bug" AND no dedupe AND no open PR:
invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply
```
### Non-negotiable rules
1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt.
2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`.
3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt.
### What the skill cannot do
The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection.
## Team emoji scheme
| Emoji | Meaning | Who adds it | Skill behavior |
| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- |
| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps |
| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row |
| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification |
| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue |
## Design Priority
Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR.
## Quick Start
1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list.
2. **Fetch**`slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies.
3. **Filter** — drop already-processed (see Processed Detection).
4. **Classify** — bug / discussion / meta (see Classification Rules).
5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create.
6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification).
7. **Extract** — normalize to ticket schema (see Ticket Schema).
8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix).
9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute.
10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one.
11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: <URL>` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log.
12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract.
13. **Log** — append to session log; update `processed.json`.
## System Context
| Item | Value |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| Source channel | `#bug-dump` (`C0A4XMHANP3`) |
| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. |
| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` |
| State dir | `~/temp/bug-dump-ingest/` |
| Processed registry | `~/temp/bug-dump-ingest/processed.json` |
| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` |
| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually |
## Label Taxonomy
Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use:
| Label kind | Values | Source |
| ------------ | ------------------------------------------------------------------------------ | ------------------------- |
| `source:` | `source:bug-dump` | Always (marks Slack sync) |
| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics |
| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics |
| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics |
| `reporter:` | `reporter:<slack-handle>` (kebab-case) | From message author |
| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable |
Label rules:
- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`.
- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix".
- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo.
- `regression` — set when the bug mentions a version/upgrade correlation.
- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker.
Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title.
## Processed Detection
A top-level message is considered already-handled (skip creation) if ANY of:
- Its timestamp appears in `processed.json`.
- It carries a `:white_check_mark:` reaction on the parent — ticket already created.
- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue.
- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log.
- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up.
- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`).
- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user.
- It is a system/meta message (`has joined the channel`, bot-only message).
- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip".
Never re-ingest a message already marked in any of the above ways.
Filter query for Slack search-based sweeps:
```text
in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD
```
## False-Defect Verification
Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes.
| Check | Command / Signal | Demote-to |
| ---------------------------------------- | ---------------------------------------------------------------- | ---------- |
| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` |
| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` |
| Fix PR merged on main | `gh search prs "in:title <keyword>" --state merged --limit 3` | `fixed` |
| Fix PR open (already-filed) | `gh search prs "<keyword>" --state open --limit 3` | `pr-open` |
| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` |
| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` |
| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` |
| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` |
For each demoted candidate, record the demotion reason in the approval table as `Verify: <tag>` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion.
### Recommended verify commands
```bash
# 1. Search recent PRs for the feature in question
gh search prs "<keyword>" --repo Comfy-Org/ComfyUI_frontend --limit 5
# 2. Grep for the feature / component mentioned
rg -l "<ComponentOrFeatureName>" src/ apps/
# 3. Check if it's a known i18n / documented setting
rg "<setting-key>" src/locales/en/ docs/
```
Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck.
## Classification Rules
For each unprocessed top-level message, decide:
| Class | Signal | Action |
| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket |
| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip |
| **question** | User asking if something is expected or known | Skip unless answered = bug |
| **meta** | Channel joins, bot messages, cross-posts without content | Skip |
| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link |
When ambiguous, default to **bug** and let the human decide in the approval batch.
## Ticket Schema
Normalize each bug to this shape before presenting:
```json
{
"slack_ts": "1776639963.837519",
"slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519",
"reporter": "Ali Ranjah (wavey)",
"title": "Unet model dropdown missing selected model",
"description": "Body with repro steps, env, attachments list, thread summary",
"env": ["cloud prod"],
"severity": "low | medium | high",
"area": "ui | node-system | workflow | cloud | templates | unknown",
"attachments": [{ "name": "...", "id": "F...", "type": "image/png" }],
"thread_resolution": "solved | open | none"
}
```
Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: <permalink>". Attach thread summary only if it adds context beyond the top-level message.
### Severity Heuristics
- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset").
- **medium** — visible regression, template error, wrong pricing, broken UX on a common path.
- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue.
When unsure, mark `medium` and flag for human in the approval batch.
### Area Heuristics
- `ui` — visual glitches, palette issues, popover clipping, dropdown styling.
- `node-system` — canvas perf, reroute, node drag, widget rendering, undo.
- `workflow` — template failures, save/load, refresh regressions.
- `cloud` — jobs, pricing, assets, auth, queue.
- `templates` — specific template errors.
## Pre-flight Dedupe Gate (MANDATORY)
Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict.
### Check 1 — Open Linear issues (via `@Linear search`)
Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
})
```
Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class.
If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel:
```text
mcp__plugin_slack_slack__slack_search_public({
query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
})
```
This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log.
Treat a hit as a duplicate if any of:
- Title overlap ≥ 80% (after lowercasing + stopword removal)
- Same reporter + same component reference in description
- Same stack trace or error code
**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket.
### Check 2 — Open or merged fix PRs on GitHub
```bash
# Open PRs matching title keywords
gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \
--search "<keyword-1> <keyword-2>" --limit 5 \
--json number,title,url,createdAt
# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship"
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
--search "<keyword-1> <keyword-2> merged:>=<YYYY-MM-DD>" --limit 5 \
--json number,title,url,mergedAt
```
Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation.
**Verdict:**
- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix).
- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land).
### Failure handling
If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets.
Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit.
## Interactive Approval
Present candidates in batches of 5-10. Table format (10 columns):
```text
# | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec
----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+-----
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N
2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y
3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L
4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open
```
Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation.
### Default recommendation logic
The skill computes `Rec` deterministically from the gate results:
- `L` — Dedupe hit on open Linear issue.
- `pr-open` — Open GitHub PR hit.
- `fixed` — Merged PR hit within the reporter's observation window.
- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only.
- `?` — Repro incomplete or classification ambiguous.
- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path.
- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix.
### Response format
- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow.
- `S`**skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes.
- `N` — skip entirely (log reason in session file).
- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent.
- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match).
- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent.
- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks).
- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default.
Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body.
## Linear Slack Bot Integration (@Linear)
Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale.
### Prerequisites
- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today).
- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team.
- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently.
### Create an issue
For each approved `Y` candidate, call:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>"
})
```
Rules:
- First line MUST be `@Linear create` — this is the command token.
- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team.
- `Status: Triage` pins the initial state (per § System Context).
- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use.
- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction.
- Use real newlines (not literal `\n`) when constructing the text.
After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for:
- An `FE-NNNN` identifier
- A `https://linear.app/<org>/issue/FE-NNNN` URL
The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply).
### Search (dedupe)
See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside.
### Link an existing issue (`L` response)
When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear link FE-4521"
})
```
The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches.
### Label / status updates
When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link):
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 add-labels pr-open"
})
```
Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body.
### Captured fields per create
Every successful create must produce, via the Linear bot's reply card:
- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log
- `url``https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply
- `ts` of the Linear bot's card reply — recorded in session log for audit
If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value.
### Failure path
If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`):
1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply.
2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL:
```text
:warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN.
```
3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference).
4. Log the failure in the session log.
Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card.
## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL
Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread:
1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration.
2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card.
The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug.
The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested.
### Required call shape
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509"
text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>"
})
```
Rules:
- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection.
- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`.
- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug.
- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text.
- Capture the returned `ts` and record it in the session log for audit.
### NEVER-do list (common failure mode)
- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead.
- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply.
- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages.
- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail.
### Fix-path reply (after red-green-fix opens a PR)
When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<same parent ts>",
text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>"
})
```
Same "tool call, not chat output" rule applies.
### Parent reaction — optional visibility nudge (not on critical path)
The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human:
```text
Optional: add :white_check_mark: to parent messages for in-channel visibility.
LIN-4710 → <permalink>
LIN-4711 → <permalink>
```
This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion.
## Fix Workflow (auto-invoke red-green-fix)
For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff.
### Required Skill tool call
```text
Skill({
skill: "red-green-fix",
args: "<composed prompt — see below>"
})
```
Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue:
```text
Bug: <title>
Linear: <LIN-ID> (<LINEAR_URL>)
Source: Slack <permalink>
Reporter: <display-name>
Env: <env tags>
Area: <area>
Branch: fix/<lin-id-lowercase>-<short-slug>
Repro:
1. <step>
2. <step>
Expected: <expected behavior>
Actual: <actual behavior>
Test layer (inferred from area):
- ui → Vitest colocated + Playwright e2e tagged @regression
- node-system → Playwright e2e primarily
- workflow / templates → Playwright e2e
- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend"
Test naming:
- describe('<LIN-ID>: <one-line bug summary>', ...)
- Playwright test title must include the LIN-ID.
PR body must include:
- "Fixes <LIN-ID>"
- "Source: Slack <permalink>"
Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit.
```
The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic.
### Verifying the invocation ran
After the `Skill` call returns, the skill MUST confirm at least one of:
1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`).
2. A PR URL is present in `red-green-fix`'s return payload.
If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply.
### Inputs summary
- **Bug description** — the Linear description (includes repro, env, source permalink).
- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`.
- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`).
- **Test layer** — inferred from `area`:
- `ui` → unit (Vitest) + e2e (Playwright)
- `node-system` → e2e primarily; unit if isolable
- `workflow` / `templates` → e2e
- `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo"
### Handoff-Exclusion list (do NOT auto-invoke red-green-fix)
These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why:
- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?"
- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`.
- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing.
- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only.
- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision).
- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`).
When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`.
### Test authoring rules
Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion:
- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID:
```typescript
// src/components/node/UnetDropdown.test.ts
describe('LIN-4710: unet dropdown missing selected model', () => {
it('includes the currently-selected model in the list even when not in available models', () => {
// ...
})
})
```
- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title:
```typescript
test.describe(
'LIN-4710 unet dropdown regression',
{ tag: ['@regression'] },
() => {
test('keeps selected model visible in the dropdown', async ({
comfyPage
}) => {
// ...
})
}
)
```
- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`.
(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.)
### PR body template
The red-green-fix skill's PR template is extended with a `Source` line:
```markdown
## Summary
<Root cause>
- Fixes LIN-NNN
- Source: Slack <permalink>
## Red-Green Verification
| Commit | CI Status | Purpose |
| ------------------------------------------ | -------------------- | ------------------------------- |
| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug |
| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug |
## Test Plan
- [ ] Unit regression test passes locally
- [ ] E2E regression test passes locally (if UI)
- [ ] Manual repro no longer reproduces
- [ ] Linear ticket linked
```
After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply).
## Emoji Reaction Hints (read-only)
The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary):
| Reaction | Meaning | Action |
| -------------------- | ------------------ | -------------------------------------------------------- |
| `:white_check_mark:` | Ticket created | Skip — already ingested |
| `:pr-open:` | PR open | Skip creation; record PR link in session log |
| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking |
| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log |
Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these:
| Reaction | Meaning | Action |
| -------- | ------------------- | -------------------------------------------------- |
| `:eyes:` | Someone is triaging | Still ingestable |
| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row |
| `:+1:` | Acknowledged | Ignore |
Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID.
## Session Log
Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`:
```text
Bug Dump Ingest Session -- 2026-04-20 11:40 KST
Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST
Scanned: 28 top-level messages
Skipped (meta/discussion/processed): 14
Proposed: 14
Approved: 11
Created in Linear: 10
Draft-only (creation failed): 1
Linked-only (dedupe): 1
Thread replies posted: 11
Created:
- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui
- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud
- ...
Skipped with reason:
- 1776592837.616399 -- design discussion in thread, not a bug
- ...
```
## Gotchas
### Thread summaries, not raw dumps
Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter.
### Cross-posts are not bugs
When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself.
### Resolved-in-thread messages
If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket.
### Permalinks
Construct Slack permalinks as:
```text
https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED}
```
E.g. `1776510375.473579` → `p1776510375473579`.
### Attachment handling
Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread.
### No auto-create without approval
Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment.
## Reference Files
- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status).
- `reference/schema.md` — full ticket schema with field-by-field extraction notes.
- `reference/examples.md` — worked examples drawn from real #bug-dump messages.
- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class.
## Related Skills
- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof.
- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed.
- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill.

View File

@@ -1,123 +0,0 @@
# Worked Examples
Real #bug-dump messages (2026-04-17 → 2026-04-20) normalized through the skill.
## Example 1 — Clean bug with repro
**Source message** (wavey, 2026-04-20 08:06):
> unet model dropdown doesnt display all available models, think this is part of a larger issue with model dropdowns..
>
> • open flux.2 klein 4b image edit template
> • open unet drop down --> notice selected model isnt present in the list, even though its selected
> • execute (to check if it flags the model as missing) --> notice it still runs
> No action needed, this is solved
**Thread resolution**: "No action needed, this is solved" — reporter resolved it in the same message.
**Classification**: bug, but `thread_resolution = solved`. Flag for human.
**Approval row**:
```text
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud | low | ui | N | N (reporter marked solved)
```
Default recommendation: `N`. If human overrides to `Y`, file with a "Regression test" label so QA still tracks it.
---
## Example 2 — Clear high-severity cloud bug
**Source message** (Denys Puziak, 2026-04-18 05:45):
> I see two reports about jobs ending in 30 minutes while the user is on the Pro plan
> cc @Hunter
> https://discord.com/channels/.../1494078128971055145
**Classification**: bug, `env: [cloud prod]` (Pro plan = cloud), `severity: high` (paying users), `area: cloud`.
**Proposed title**: `Pro plan jobs end at 30 minutes`
**Description** (excerpt):
```markdown
**Reporter:** Denys Puziak
**Env:** cloud prod
**Severity (proposed):** high
**Area:** cloud
## Repro
1. User on Pro plan submits a job
2. Job ends at 30 minutes instead of the Pro plan limit
## Notes
- Two user reports aggregated by Denys
- cc'd @Hunter
## Source
Slack: <permalink>
Discord thread: https://discord.com/channels/.../1494078128971055145
```
---
## Example 3 — Not a bug (discussion)
**Source message** (Christian Byrne, 2026-04-19 19:00):
> @Glary-Bot okay option A is clearly superior and I feel embarrassed I didn't see that line myself...
**Classification**: discussion (design review chatter). Skip. Log reason in session file.
---
## Example 4 — Meta-action / PR planning
**Source message** (Christian Byrne, 2026-04-19 09:30):
> @Glary-Bot how about we make a PR to do:
>
> 1. Audit the rest of the codebase...
> 2. Create a helper in src/base...
**Classification**: discussion (PR-plan proposal). Skip.
---
## Example 5 — Performance regression
**Source message** (Terry Jia, 2026-04-18 12:52):
> With Nodes 2.0, large workflows (hundreds of nodes) make the canvas extremely laggy and unusable for actual work — switching tabs takes several seconds or more. Switching back to Litegraph, performance is significantly better.
**Classification**: bug, `area: node-system`, `severity: high`.
**Dedupe**: Post `@Linear search nodes 2.0 performance canvas lag` (Team: Frontend Engineering, Status: open) in the candidate's thread. Likely matches exist — flag `Dedup? ?` and ask human which ticket to link to.
---
## Example 6 — Reporter says it's a question, not a report
**Source message** (Luke, 2026-04-17 08:27):
> Is NodeInfo supposed to show information or docs about the node? It just brings up the node sidebar
**Classification**: question → ambiguous. Read thread. If replies confirm "that's unexpected, should show docs", upgrade to bug. If "yes that's intended", skip.
Default recommendation in the approval batch: `?` (needs expansion).
---
## Example 7 — Bug with PR already in flight
**Source message** (Pablo, 2026-04-17 08:52):
> when deleting multiple assets on cloud -> the confirmation popup still has the assets hashes as names instead of the display name
**Reaction**: `pr-open (1)` — someone's opened a PR.
**Classification**: `already-filed` branch. Skip creation; in the session log, note "PR already open". If the human wants a tracking Linear ticket anyway, still fileable with a link to the PR.

View File

@@ -1,160 +0,0 @@
# Linear Slack Bot (@Linear) Reference
The skill drives Linear exclusively through the Linear Slack app (`@Linear`). **There is no Linear MCP, no `LINEAR_API_KEY`, no GraphQL.** Every Linear read/write happens as a Slack message that mentions `@Linear` in the `#bug-dump` thread, and the Linear Slack app performs the action and posts a reply card containing the issue URL.
## Why Slack-only
- The `#bug-dump` thread is already the source of truth; keeping the entire lifecycle (report → ticket → PR → resolution) in one thread means Processed Detection can grep the thread instead of a separate registry.
- No API key rotation, no MCP server install, no OAuth browser flow — works on any machine that already has the Slack MCP configured.
- The Linear Slack app's reply card (with issue URL, title, status, and assignee) IS the canonical receipt; the skill records its `ts` in the session log.
## Prerequisites (one-time, per workspace)
The Comfy Slack workspace must already have the Linear Slack app installed (it is — that's how humans use `@Linear` reactions today) and `#bug-dump` (channel `C0A4XMHANP3`) must have Linear enabled for the `Frontend Engineering` team. Nothing else to configure. If a `@Linear` invocation silently does nothing, the bot isn't present in the channel — surface that to the human rather than re-trying.
## Supported operations
Every operation is a `mcp__plugin_slack_slack__slack_send_message` call with `channel_id=C0A4XMHANP3` and `thread_ts=<parent-ts>`. The `text` is a natural-language instruction to the Linear bot. Keep the text concise — Linear parses the first line as the command intent.
### 1. Create an issue from the thread
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description body>\n\nSource: <slack-permalink>"
})
```
Rules:
- Start with `@Linear create` on its own line — this is the command token the bot keys on.
- Always specify `Team: Frontend Engineering`. Without it, the bot falls back to the Slack workspace's default team, which may not be FE.
- `Status: Triage` pins the initial workflow state.
- `Labels:` — comma-separated. If a label doesn't exist yet in Linear, the bot creates it on first use (verified in Linear workspace settings). Keep the taxonomy exactly as SKILL.md § Label Taxonomy.
- `<description body>` — markdown per `reference/schema.md` Description Template. Use real newlines, not literal `\n`.
- End with `Source: <slack-permalink>` so the Linear issue body links back even if the auto-attachment of the parent message fails.
The Linear bot replies in the same thread with a card that contains:
- The Linear URL (`https://linear.app/comfy-org/issue/FE-NNNN`)
- Status, assignee (initially unassigned), and applied labels
- A "View in Linear" button
Parse the URL out of the bot's reply text (or attachments). If no card reply appears within ~10s of polling `slack_read_thread`, treat it as a creation failure — do NOT proceed to the `:white_check_mark:` confirmation reply.
### 2. Search existing open issues (dedupe)
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
})
```
The bot replies with a card listing up to ~5 matching open issues. Parse identifier (`FE-NNNN`) and URL per row. Treat a hit as a duplicate per SKILL.md § Pre-flight Dedupe Gate § Check 1.
If `@Linear search` is not supported in the installed Slack app version, fall back to Slack-native search across the `#bug-dump` thread replies (previous `@Linear` cards contain title + URL — grep those for the same keywords). Record which path was used in the session log so the human can see dedupe coverage.
### 3. Link an existing issue (dedupe: `L` response)
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear link FE-4521"
})
```
The bot replies with the linked issue card. The skill then posts its own `:white_check_mark: Linked to Linear: <URL>` confirmation reply (see SKILL.md § Slack Thread Reply).
### 4. Add labels to an existing issue
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 add-labels pr-open"
})
```
Used when an open PR is discovered after ticket creation and the Linear issue should flip to `pr-open`.
### 5. Change status
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 status In Progress"
})
```
Rarely used by the skill directly — usually status changes come from the `red-green-fix` PR lifecycle (Linear auto-moves to `In Review` when a PR references `Fixes FE-4521`).
## Description body template
The text that follows the command headers is rendered verbatim as the Linear issue description (markdown). Use this template — see `reference/schema.md` for field-by-field extraction notes:
```markdown
**Reporter:** <slack-display-name>
**Env:** cloud prod / local / electron / ...
**Severity (proposed):** high/medium/low
**Area:** ui / node-system / workflow / cloud / templates
## Repro
1. ...
2. ...
## Expected
...
## Actual
...
## Attachments (in Slack thread)
- image.png (png, 315 KB)
- Screen Recording.mov (mov, 37 MB)
## Source
Slack: <permalink>
Thread summary: <1-3 bullets if thread adds context>
```
The Slack permalink is load-bearing — it's the canonical route to attachments, reporter, and any follow-up discussion. Do NOT embed Slack file IDs (`F0AT...`) directly; they're permissioned.
## Parsing the bot's reply
After each `slack_send_message` that mentions `@Linear`, poll `slack_read_thread` (with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`) up to 3 times, ~3s apart. Scan replies authored by the Linear Slack app user for:
- Any `https://linear.app/<org>/issue/FE-\d+` URL → capture as the issue URL.
- The `FE-NNNN` identifier pattern → capture as the issue identifier.
- An error phrase (`couldn't`, `failed`, `not found`, `no team matched`) → treat as failure; surface the full bot text to the human.
Record the bot reply's `ts` alongside the captured URL and identifier in the session log.
## Failure modes & handling
| Symptom | Likely cause | Handling |
| ------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| No bot reply within 10s | Linear app not in channel, or bot outage | Halt the batch, surface to human, do NOT fabricate a Linear URL. Remaining approved candidates stay queued for re-run. |
| Bot replies with "no team matched" | Team name typo or Linear workspace drift | Re-send with the exact team name from the Linear workspace (default: `Frontend Engineering`). If it still fails, ask the human to verify. |
| Bot replies with "couldn't parse labels" | One of the labels has syntax the bot rejects | Drop the offending label, re-send; log the partial-label failure so the human can patch after. |
| Bot creates the issue but reply lacks the URL | Rare bot format change | Re-fetch the thread after ~5s; if URL still absent, open Linear search via `@Linear search <title>` and recover the identifier + URL. |
| Multiple `@Linear` replies match (duplicate card) | The skill retried without polling first | Keep the earliest card's URL; log the extras. Never re-issue `@Linear create` for the same candidate without confirming the first card failed. |
Never retry `@Linear create` without first running `@Linear search` for the same title keywords — a duplicate card is worse than an initial failure because the human has to close one of them manually.
## Why no direct API path
- The Linear MCP (official or community) would require either OAuth setup or `LINEAR_API_KEY` in env — both are per-machine hurdles the skill should not depend on.
- Direct GraphQL against `api.linear.app` has the same key-management cost and bypasses the Slack thread as the audit trail.
- Routing every action through `@Linear` in the thread gives humans full visibility in the channel (the bot's card is the receipt) and Processed Detection becomes a simple Slack thread read.
If a future need requires capabilities the `@Linear` Slack app doesn't expose (bulk operations, private field edits, webhooks), stop and surface the limitation to the human rather than quietly adding an API-key path — the "Slack-only" constraint is intentional.

View File

@@ -1,94 +0,0 @@
# Ticket Schema — Extraction Notes
Field-by-field guidance for normalizing a Slack #bug-dump message into a ticket.
## `slack_ts`
The top-level message timestamp from `slack_read_channel` response (`Message TS:` field). Always store the dotted form (`1776510375.473579`). This is the ingestion identity used in `processed.json`.
## `slack_permalink`
Construct:
```text
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p<ts-without-dot>
```
Example: `1776510375.473579``.../p1776510375473579`.
## `reporter`
The display name + parenthetical nickname if present. Examples from the channel:
- `Ali Ranjah (wavey)`
- `Denys Puziak`
- `Christian Byrne`
Do NOT use the Slack user ID (`U087MJCDHHC`) in Linear — names are more readable.
## `title`
Rules:
- Start with a verb or noun phrase describing the observed defect, not the reporter.
- ≤ 80 chars.
- Include env qualifier ("cloud prod", "local dev", "electron") only if ambiguous.
- Strip emoji and reactions from the original message when extracting.
Transformations:
| Slack message (excerpt) | Title |
| ----------------------------------------------------------------------- | --------------------------------------------------- |
| "unet model dropdown doesnt display all available models..." | Unet dropdown missing selected model |
| "Dates are broken on Settings -> Secrets. Cloud Prod" | Settings → Secrets dates broken on cloud prod |
| "LTX-2: Audio to VIdeo template results in the "RuntimeError..." error" | LTX-2 Audio-to-Video template RuntimeError on cloud |
## `description`
Structure — see `linear-api.md` § "Description body template". Key rules:
- Lead with **Repro** numbered list. Extract from the message body; if no steps are given, write "Repro: [Slack message body quoted verbatim]" and flag for human in approval.
- Preserve the reporter's own words in the Repro section when they include "step 1 / step 2" markers.
- Collapse multi-paragraph asides into "Notes" at the end.
## `env`
Detect from message text using these terms:
| Text in message | Tag |
| -------------------------- | ---------------------- |
| `cloud prod`, `prod cloud` | `cloud prod` |
| `cloud dev` | `cloud dev` |
| `cloud` | `cloud` (unqual.) |
| `local`, `localhost` | `local` |
| `electron`, `desktop` | `electron` |
| `nodes 2.0`, `LG` | (feature tag, not env) |
A message can have multiple env tags. If none are detectable, set `env: []` and flag "env unclear" in the approval row.
## `severity`
Heuristics in SKILL.md. When uncertain, mark `medium` and note in approval table: `Sev: medium (flag)`.
## `area`
Single tag. Use the one that best fits; tiebreak toward the more actionable team:
- `cloud` > `workflow` when the reported behavior is specific to cloud-hosted features (billing, queue, jobs)
- `node-system` > `ui` when the defect is canvas interaction, not just visual
- `templates` only when a named template is the subject
## `attachments`
From `slack_read_channel` message `Files:` field. Parse name, ID, type. Never include the Slack file ID in the Linear description — those are permissioned — just the filename and type.
## `thread_resolution`
Fetch via `slack_read_thread`. Scan replies for:
- `solved`, `resolved`, `fixed`, `no action needed``solved`
- A `:done:` reaction from the reporter → `solved`
- A `https://github.com/Comfy-Org/ComfyUI_frontend/pull/` URL in a reply → `pr-open` (keep but note in description)
- Otherwise → `open`
If `solved` and no PR merged, flag in approval table: reporter marked solved — confirm before filing.

View File

@@ -1,99 +0,0 @@
# Verify Commands Cookbook
One-shot commands for each False-Defect Verification class. Keep each under ~30s.
## 1. Check for existing fix PR
```bash
# By keyword in title
gh search prs --repo Comfy-Org/ComfyUI_frontend "<keyword>" --state merged --limit 5
# By keyword in body
gh pr list --repo Comfy-Org/ComfyUI_frontend --search "<keyword>" --state all --limit 5
# Recent closing PRs near the reported date
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
--search "merged:>=<YYYY-MM-DD> <keyword>" --limit 10
```
Verify tag: `fixed` if a merged PR explicitly matches; `pr-open` if an open PR matches.
## 2. Check for existing open Linear issue
```text
# Primary: @Linear search in the candidate's bug-dump thread
# mcp__plugin_slack_slack__slack_send_message({
# channel_id: "C0A4XMHANP3",
# thread_ts: "<parent-ts>",
# text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
# })
# → poll slack_read_thread, parse the Linear app's reply card for FE-NNNN matches.
#
# Fallback: grep past @Linear bot replies in the channel for prior ingested titles
# mcp__plugin_slack_slack__slack_search_public({
# query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
# })
```
Verify tag: `dedupe` with the `FE-NNNN` identifier in the approval row. See `reference/linear-api.md` § "Search existing open issues (dedupe)" for full handling.
## 3. Feature actually exists in codebase
```bash
# Find the component / feature mentioned
rg -l "<ComponentOrFeatureName>" src/ apps/ --type vue --type ts
# Find a setting key
rg "<setting-key>" src/locales/en/ src/stores/settingStore.ts
# Find a store action
rg "<actionName>" src/stores/ --type ts
```
Verify tag: `stale` if 0 hits AND the feature name is specific (not a generic word).
## 4. Intended behavior check
```bash
# Check docs and release notes
rg -l "<feature keyword>" docs/ CHANGELOG.md
# Check if behavior is asserted in an existing test (green today)
rg "<observed behavior>" src/**/*.test.ts browser_tests/
```
Verify tag: `expected` if docs describe this as the intended behavior, or a test asserts it.
## 5. Reporter self-resolution
Already gathered via `slack_read_thread`. Look for reporter's own replies containing:
- "solved", "resolved", "fixed", "no action needed", "nvm", "my bad"
- A `:done:` reaction from the reporter
- A `:white_check_mark:` reaction
Verify tag: `resolved`.
## 6. Env-specific / local setup
If the message mentions "my machine", "my proxy", "my docker", "my cache" AND no other reporter has confirmed in-thread:
```bash
# Check thread for cross-user confirmations
# slack_read_thread → count distinct users replying with "same", "repro'd", "+1"
```
Verify tag: `env` if only the reporter is affected.
## 7. Cross-post (X posting)
If the top-level message is just a link + "X posting":
```bash
# Follow the link — use slack_search_public to find the original thread
# slack_search_public({ query: "<in:channel from:@reporter> <before:date>" })
```
If the original is already ingestable, ingest from the original's permalink. If it's a GitHub issue, prefer linking that GitHub issue to the Linear ticket instead of creating two entries.
Verify tag: `cross-post` with the resolved source permalink.

View File

@@ -114,7 +114,7 @@ await expect(async () => {
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `npx playwright show-trace trace.zip`
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => {
message = t('maintenance.error.taskFailed')
} catch (error) {
message = (error as Error)?.message
message = error instanceof Error ? error.message : undefined
}
toast.add({

View File

@@ -66,7 +66,7 @@ class MaintenanceTaskRunner {
this.error = undefined
return true
} catch (error) {
this.error = (error as Error)?.message
this.error = error instanceof Error ? error.message : String(error)
throw error
} finally {
this.executing = false

View File

@@ -3,6 +3,23 @@ import sitemap from '@astrojs/sitemap'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
const LOCALES = ['en', 'zh-CN'] as const
const DEFAULT_LOCALE = 'en'
const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
)
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')
return SITEMAP_EXCLUDED_PATHNAMES.has(pathname)
}
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
@@ -17,7 +34,12 @@ export default defineConfig({
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
integrations: [
vue(),
sitemap({
filter: (page) => !isExcludedFromSitemap(page)
})
],
vite: {
plugins: [tailwindcss()],
server: {
@@ -27,8 +49,8 @@ export default defineConfig({
}
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
locales: [...LOCALES],
defaultLocale: DEFAULT_LOCALE,
routing: {
prefixDefaultLocale: false
}

View File

@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test'
test.describe('Demo pages @smoke', () => {
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
await expect(page.getByText('Coming Soon')).toBeVisible()
})
test('non-existent demo returns 404', async ({ page }) => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => {
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
'https://github.com/Comfy-Org/ComfyUI#installing'
)
await context.close()

View File

@@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -0,0 +1,115 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { test } from './fixtures/blockExternalMedia'
const CLOUD_URL = externalLinks.cloud
const PLATFORM_USAGE_URL = externalLinks.platformUsage
const SUPPORT_URL = externalLinks.support
const DOCS_SUBSCRIPTION_URL = externalLinks.docsSubscription
async function expectNoIndex(page: Page) {
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex, nofollow'
)
}
test.describe('Payment success page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/payment/success')
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful — Comfy')
await expectNoIndex(page)
})
test('shows success heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Payment successful/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/Thanks for your purchase/i)).toBeVisible()
})
test('primary CTA links to Comfy Cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /CONTINUE TO COMFY CLOUD/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', CLOUD_URL)
})
test('secondary CTA links to platform usage & payments page', async ({
page
}) => {
const cta = page.getByRole('link', { name: /VIEW USAGE & PAYMENTS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', PLATFORM_USAGE_URL)
})
})
test.describe('Payment failed page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/payment/failed')
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed — Comfy')
await expectNoIndex(page)
})
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Payment was not completed/i,
level: 1
})
).toBeVisible()
await expect(page.getByText(/payment didn't go through/i)).toBeVisible()
})
test('primary CTA links to support help center', async ({ page }) => {
const cta = page.getByRole('link', { name: /CONTACT SUPPORT/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', SUPPORT_URL)
})
test('secondary CTA links to subscription docs', async ({ page }) => {
const cta = page.getByRole('link', { name: /READ SUBSCRIPTION DOCS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
})
})
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
).toBeVisible()
await expect(
page.getByRole('link', { name: '前往 COMFY CLOUD' })
).toHaveAttribute('href', CLOUD_URL)
await expect(
page.getByRole('link', { name: '查看用量与支付' })
).toHaveAttribute('href', PLATFORM_USAGE_URL)
})
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付未完成', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',
SUPPORT_URL
)
await expect(
page.getByRole('link', { name: '查看订阅文档' })
).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -29,5 +29,30 @@ Allow: /
Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-User
Allow: /
User-agent: Claude-SearchBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -0,0 +1,83 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -0,0 +1,51 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -88,7 +88,7 @@ const contactColumn = {
{ label: t('footer.sales', locale), href: routes.contact },
{
label: t('footer.support', locale),
href: externalLinks.discord,
href: externalLinks.support,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { ref } from 'vue'
import { t } from '../../i18n/translations'
const {
arcadeId,
title,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
locale?: Locale
}>()
const loaded = ref(false)
</script>
<template>
<section
class="px-4 py-8 lg:px-20 lg:py-16"
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
>
<div
v-if="!loaded"
aria-hidden="true"
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
>
<div
class="border-primary-comfy-canvas/60 mb-4 size-10 animate-pulse rounded-full border-2"
/>
<p class="text-primary-warm-gray text-sm">
{{ t('demos.loading', locale) }}
</p>
</div>
<iframe
class="size-full"
:src="`https://demo.arcade.software/${arcadeId}?embed&show_title=0`"
:title="`${t('demos.embed.label', locale)}: ${title}`"
loading="lazy"
allow="clipboard-write"
referrerpolicy="strict-origin-when-cross-origin"
@load="loaded = true"
/>
</div>
<noscript>
<p class="text-primary-warm-gray mt-4 text-sm">
{{ t('demos.noscript', locale) }}
<a
class="text-primary-comfy-yellow ml-2 underline"
:href="`https://demo.arcade.software/${arcadeId}`"
rel="noopener noreferrer"
target="_blank"
>
{{ t('demos.noscript.link', locale) }}
</a>
</p>
</noscript>
</section>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
label,
title,
description,
difficulty,
estimatedTime,
locale = 'en'
} = defineProps<{
label: string
title: string
description: string
difficulty: 'beginner' | 'intermediate' | 'advanced'
estimatedTime: string
locale?: Locale
}>()
const difficultyKey = `demos.difficulty.${difficulty}` as TranslationKey
</script>
<template>
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ label }}
</span>
<h1
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
>
{{ title }}
</h1>
<p
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
>
{{ description }}
</p>
<div class="mt-6 flex flex-wrap justify-center gap-3">
<span
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold tracking-wide uppercase"
>
{{ t(difficultyKey, locale) }}
</span>
<span
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold"
>
{{ t(estimatedTime as TranslationKey, locale) }}
</span>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
nextTitle,
nextSlug,
nextThumbnail,
locale = 'en'
} = defineProps<{
nextTitle: string
nextSlug: string
nextThumbnail: string
locale?: Locale
}>()
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
</h2>
<div
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
>
<a :href="nextHref" class="shrink-0 lg:w-1/2">
<img
:src="nextThumbnail"
:alt="nextTitle"
class="w-full rounded-4xl object-cover"
/>
</a>
<div class="flex flex-col gap-6">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
{{ nextTitle }}
</h3>
<a :href="nextHref" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
>
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
</span>
</a>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { t } from '../../i18n/translations'
const { transcript, locale = 'en' } = defineProps<{
transcript: string
locale?: Locale
}>()
const expanded = ref(false)
</script>
<template>
<section
class="px-4 py-8 lg:px-20 lg:py-12"
:aria-label="t('demos.transcript.label', locale)"
>
<div class="mx-auto max-w-4xl">
<button
type="button"
class="text-primary-comfy-canvas text-left"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<span class="text-sm font-semibold tracking-wide uppercase">
{{ t('demos.transcript.label', locale) }}
</span>
<span class="text-primary-warm-gray ml-2 text-xs">
{{ t('demos.transcript.note', locale) }}
</span>
</button>
<div
role="region"
:aria-label="t('demos.transcript.label', locale)"
:class="
cn(
expanded ? 'mt-4' : 'sr-only',
'text-primary-warm-gray text-sm/relaxed'
)
"
v-html="transcript"
/>
</div>
</section>
</template>

View File

@@ -35,7 +35,10 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -52,12 +55,8 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -106,6 +106,11 @@ function onNavKeydown(event: KeyboardEvent) {
navButtons()?.[next]?.focus({ preventScroll: true })
}
function onCategoryHover(index: number) {
if (isEnabled.value) return
activeCategory.value = index
}
function travelRange(el: HTMLElement) {
if (window.matchMedia('(min-width: 1024px)').matches) return 150
@@ -116,31 +121,29 @@ function travelRange(el: HTMLElement) {
}
const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%`
const parallaxMediaQuery = '(max-width: 1023px)'
useParallax([rightImgRef], {
trigger: sectionRef,
fromY: (el) => -travelRange(el),
y: (el) => travelRange(el),
start: 'top top',
end: pinScrubEnd
end: pinScrubEnd,
mediaQuery: parallaxMediaQuery
})
useParallax([leftImgRef], {
trigger: sectionRef,
fromY: (el) => travelRange(el),
y: (el) => -travelRange(el),
start: 'top top',
end: pinScrubEnd
end: pinScrubEnd,
mediaQuery: parallaxMediaQuery
})
</script>
<template>
<section
ref="sectionRef"
:class="
cn(
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
isEnabled && 'lg:h-[calc(100vh+60px)]'
)
"
class="bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:h-[calc(100vh+60px)] lg:py-24"
>
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
<defs>
@@ -202,6 +205,8 @@ useParallax([leftImgRef], {
"
:aria-current="index === activeCategory ? 'true' : undefined"
@click="scrollToIndex(index)"
@mouseenter="onCategoryHover(index)"
@focus="onCategoryHover(index)"
>
{{ category.label }}
</button>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import SectionLabel from '../common/SectionLabel.vue'
// Display-only thank-you / failure pages: payment state is verified
// server-side via Stripe webhooks (see comfy-api). These pages exist
// solely as the redirect target for Stripe Checkout.
type Status = 'success' | 'failed'
const { status, locale = 'en' } = defineProps<{
status: Status
locale?: Locale
}>()
const primaryHref =
status === 'success' ? externalLinks.cloud : externalLinks.support
const secondaryHref =
status === 'success'
? externalLinks.platformUsage
: externalLinks.docsSubscription
const iconRingClass =
status === 'success'
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'border-secondary-mauve text-secondary-mauve'
</script>
<template>
<section
class="flex min-h-[calc(100dvh-12rem)] items-center justify-center px-6 py-16 lg:py-24"
>
<div class="flex max-w-2xl flex-col items-center gap-6 text-center">
<div
:class="
cn(
'flex size-20 items-center justify-center rounded-full border-2',
iconRingClass
)
"
aria-hidden="true"
>
<svg
v-if="status === 'success'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M6 6l12 12" />
<path d="M18 6L6 18" />
</svg>
</div>
<SectionLabel>{{ t(`payment.${status}.label`, locale) }}</SectionLabel>
<h1
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
>
{{ t(`payment.${status}.title`, locale) }}
</h1>
<p
class="text-primary-comfy-canvas/80 max-w-xl text-base font-light lg:text-lg"
>
{{ t(`payment.${status}.subtitle`, locale) }}
</p>
<div
class="mt-2 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center"
>
<BrandButton :href="primaryHref" variant="solid" size="nav">
{{ t(`payment.${status}.primaryCta`, locale) }}
</BrandButton>
<BrandButton :href="secondaryHref" variant="outline" size="nav">
{{ t(`payment.${status}.secondaryCta`, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -101,17 +101,9 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->

View File

@@ -28,7 +28,11 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<!-- CTA buttons -->
<div class="mt-10 flex flex-col gap-4 lg:flex-row">
<DownloadLocalButton :locale />
<BrandButton :href="externalLinks.github" variant="outline" size="lg">
<BrandButton
:href="externalLinks.githubInstall"
variant="outline"
size="lg"
>
<span class="inline-flex items-center gap-2">
<i
class="icon-mask size-5 -translate-y-px mask-[url('/icons/social/github.svg')]"

View File

@@ -323,7 +323,7 @@ onUnmounted(() => {
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
<DownloadLocalButton :locale class="lg:min-w-60 lg:p-4" />
<BrandButton
:href="externalLinks.github"
:href="externalLinks.githubInstall"
variant="outline"
size="lg"
class="lg:min-w-60 lg:p-4"

View File

@@ -20,6 +20,9 @@ interface PinScrubOptions {
/** Viewport-height percentage each category occupies in the scroll distance. */
export const VH_PER_ITEM = 20
/** Pin/scrub is mobile-only — desktop uses hover-based category switching. */
const PIN_SCRUB_MEDIA_QUERY = '(max-width: 1023px)'
function interpolateY(
index: number,
buttonCenters: number[],
@@ -66,7 +69,8 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
!refs.section.value ||
!refs.content.value ||
!refs.nav.value ||
prefersReducedMotion()
prefersReducedMotion() ||
!window.matchMedia(PIN_SCRUB_MEDIA_QUERY).matches
)
return
const section: HTMLElement = refs.section.value

View File

@@ -0,0 +1,68 @@
import type { TranslationKey } from '../i18n/translations'
interface Demo {
readonly slug: string
readonly arcadeId: string
readonly category: TranslationKey
readonly title: TranslationKey
readonly description: TranslationKey
readonly ogImage: string
readonly thumbnail: string
readonly estimatedTime: TranslationKey
readonly durationIso: string
readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
readonly tags: readonly string[]
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
}
export const demos: readonly Demo[] = [
{
slug: 'image-to-video',
arcadeId: 'F3CTalnGnR4R0qJIVMNX',
category: 'demos.category.templates',
title: 'demos.image-to-video.title',
description: 'demos.image-to-video.description',
transcript: 'demos.image-to-video.transcript',
ogImage: '/images/demos/image-to-video-og.png',
thumbnail: '/images/demos/image-to-video-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
},
{
slug: 'workflow-templates',
arcadeId: 'KhqcXDElnFWklo7ACBqE',
category: 'demos.category.gettingStarted',
title: 'demos.workflow-templates.title',
description: 'demos.workflow-templates.description',
transcript: 'demos.workflow-templates.transcript',
ogImage: '/images/demos/workflow-templates-og.png',
thumbnail: '/images/demos/workflow-templates-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
}
]
export function getDemoBySlug(slug: string): Demo | undefined {
return demos.find((demo) => demo.slug === slug)
}
export function getNextDemo(slug: string): Demo {
if (demos.length === 0) {
throw new Error('No demos configured')
}
const index = demos.findIndex((demo) => demo.slug === slug)
if (index === -1) {
throw new Error(`Unknown demo slug: ${slug}`)
}
return demos[(index + 1) % demos.length]
}

View File

@@ -11,6 +11,7 @@ const baseRoutes = {
about: '/about',
careers: '/careers',
customers: '/customers',
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact'
@@ -33,8 +34,12 @@ export const externalLinks = {
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -1,24 +1,10 @@
{
"fetchedAt": "2026-04-24T18:59:03.989Z",
"fetchedAt": "2026-05-02T20:15:18.321Z",
"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",
@@ -33,13 +19,6 @@
"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",
@@ -115,6 +94,13 @@
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
},
{
"id": "2eb53e8943cc9396",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
}
]
},
@@ -122,6 +108,27 @@
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
@@ -144,7 +151,7 @@
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Senior Technical Recruiter",
"title": "Talent Lead",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"

View File

@@ -914,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1280,10 +1280,6 @@ const translations = {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.included.comingSoon': {
en: 'coming soon',
'zh-CN': '即将推出'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },
@@ -3546,6 +3542,80 @@ const translations = {
'zh-CN': '我们会为您处理请求。'
},
'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
'demos.image-to-video.title': {
en: 'Create a Video from an Image',
'zh-CN': '从图片创建视频'
},
'demos.image-to-video.description': {
en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
'zh-CN':
'了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
},
'demos.image-to-video.transcript': {
en: '<ol><li><strong>Open ComfyUI</strong> — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.</li><li><strong>Browse templates</strong> — Click the workflow templates button in the sidebar to browse available starting points.</li><li><strong>Select Image to Video</strong> — Find and select the "Image to Video" template from the list to load it onto your canvas.</li><li><strong>Upload your image</strong> — Click the image upload node and select the source image you want to animate.</li><li><strong>Run the workflow</strong> — Click the "Queue" button to execute the workflow and generate your video output.</li></ol>',
'zh-CN':
'<ol><li><strong>打开 ComfyUI</strong> — 启动应用程序,您将看到基于节点的工作流画布。</li><li><strong>浏览模板</strong> — 点击侧栏中的工作流模板按钮,浏览可用模板。</li><li><strong>选择图片转视频</strong> — 从列表中找到并选择"图片转视频"模板。</li><li><strong>上传图片</strong> — 点击图片上传节点,选择要动画化的源图片。</li><li><strong>运行工作流</strong> — 点击"排队"按钮执行工作流并生成视频输出。</li></ol>'
},
'demos.workflow-templates.title': {
en: 'Browse Workflow Templates',
'zh-CN': '浏览工作流模板'
},
'demos.workflow-templates.description': {
en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
},
'demos.workflow-templates.transcript': {
en: '<ol><li><strong>Open the template browser</strong> — Click the templates icon in the ComfyUI sidebar to open the template library.</li><li><strong>Browse categories</strong> — Templates are organized by task: image generation, video, upscaling, and more.</li><li><strong>Preview a template</strong> — Hover over any template to see a preview of its workflow and expected output.</li><li><strong>Load and customize</strong> — Click to load a template, then modify parameters to fit your needs.</li></ol>',
'zh-CN':
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
},
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
'demos.transcript.note': {
en: '(for accessibility & search)',
'zh-CN': '(无障碍和搜索)'
},
'demos.loading': {
en: 'Loading interactive demo…',
'zh-CN': '正在加载互动演示…'
},
'demos.noscript': {
en: 'This interactive demo requires JavaScript.',
'zh-CN': '此互动演示需要 JavaScript。'
},
'demos.noscript.link': {
en: 'View on Arcade →',
'zh-CN': '在 Arcade 上查看 →'
},
'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
'demos.difficulty.intermediate': {
en: 'Intermediate',
'zh-CN': '中级'
},
'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
'demos.embed.label': {
en: 'Interactive demo',
'zh-CN': '互动演示'
},
'demos.comingSoon.title': {
en: 'Coming Soon',
'zh-CN': '即将推出'
},
'demos.comingSoon.body': {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
en: "What's next?",
'zh-CN': '接下来看什么?'
@@ -3596,6 +3666,49 @@ const translations = {
'customers.feedback.role3': {
en: 'Head of AI at Creative Studios',
'zh-CN': 'Creative Studios AI 负责人'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',
'zh-CN': '支付'
},
'payment.success.title': {
en: 'Payment successful',
'zh-CN': '支付成功'
},
'payment.success.subtitle': {
en: "Thanks for your purchase. Your account has been credited and you're ready to keep building.",
'zh-CN': '感谢您的购买。您的账户已充值完成,可以继续创作了。'
},
'payment.success.primaryCta': {
en: 'CONTINUE TO COMFY CLOUD',
'zh-CN': '前往 COMFY CLOUD'
},
'payment.success.secondaryCta': {
en: 'VIEW USAGE & PAYMENTS',
'zh-CN': '查看用量与支付'
},
'payment.failed.label': {
en: 'PAYMENT',
'zh-CN': '支付'
},
'payment.failed.title': {
en: 'Payment was not completed',
'zh-CN': '支付未完成'
},
'payment.failed.subtitle': {
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
'zh-CN':
'您的支付未能完成,未发生扣款。如需帮助,请联系支持或查阅订阅文档。'
},
'payment.failed.primaryCta': {
en: 'CONTACT SUPPORT',
'zh-CN': '联系支持'
},
'payment.failed.secondaryCta': {
en: 'READ SUBSCRIPTION DOCS',
'zh-CN': '查看订阅文档'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -109,6 +109,7 @@ const websiteJsonLd = {
)}
<ClientRouter />
<slot name="head" />
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (

View File

@@ -0,0 +1,139 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))
}
const { slug } = Astro.params
const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>
<DemoHeroSection
label={t(demo.category)}
title={title}
description={description}
difficulty={demo.difficulty}
estimatedTime={demo.estimatedTime}
/>
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
client:load
/>
{demo.transcript && (
<DemoTranscript
transcript={t(demo.transcript)}
client:visible
/>
)}
<DemoNavSection
nextTitle={t(nextDemo.title)}
nextSlug={nextDemo.slug}
nextThumbnail={nextDemo.thumbnail}
/>
</BaseLayout>

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -0,0 +1,12 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout
title="Payment Failed — Comfy"
description="Your payment was not completed."
noindex
>
<PaymentStatusSection status="failed" />
</BaseLayout>

View File

@@ -0,0 +1,12 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout
title="Payment Successful — Comfy"
description="Your payment was processed successfully."
noindex
>
<PaymentStatusSection status="success" />
</BaseLayout>

View File

@@ -0,0 +1,143 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))
}
const { slug } = Astro.params
const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>
<DemoHeroSection
label={t(demo.category, 'zh-CN')}
title={title}
description={description}
difficulty={demo.difficulty}
estimatedTime={demo.estimatedTime}
locale="zh-CN"
/>
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
locale="zh-CN"
client:load
/>
{demo.transcript && (
<DemoTranscript
transcript={t(demo.transcript, 'zh-CN')}
locale="zh-CN"
client:visible
/>
)}
<DemoNavSection
nextTitle={t(nextDemo.title, 'zh-CN')}
nextSlug={nextDemo.slug}
nextThumbnail={nextDemo.thumbnail}
locale="zh-CN"
/>
</BaseLayout>

View File

@@ -0,0 +1,17 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">
{t('demos.comingSoon.title', 'zh-CN')}
</h1>
<p class="text-primary-warm-gray mt-4 text-sm">
{t('demos.comingSoon.body', 'zh-CN')}
</p>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 — Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 — Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,3 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { buildVideoSources, videoKey } from './video'
describe('buildVideoSources', () => {
it('builds a source per requested format', () => {
const sources = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/website/marketing',
width: 1280,
formats: ['webm', 'mp4']
})
expect(sources).toEqual([
{
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
type: 'video/webm',
format: 'webm'
},
{
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
type: 'video/mp4',
format: 'mp4'
}
])
})
it('preserves caller-supplied format order', () => {
const sources = buildVideoSources({
name: 'clip',
baseUrl: 'https://cdn.example.com/v',
width: 960,
formats: ['mp4', 'webm']
})
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
})
it('strips a single trailing slash from baseUrl', () => {
const sources = buildVideoSources({
name: 'reel',
baseUrl: 'https://media.comfy.org/website/marketing/',
width: 1920,
formats: ['webm']
})
expect(sources[0]?.src).toBe(
'https://media.comfy.org/website/marketing/reel-1920.webm'
)
})
it('returns an empty list when no formats are requested', () => {
const sources = buildVideoSources({
name: 'x',
baseUrl: 'https://example.com',
width: 640,
formats: []
})
expect(sources).toEqual([])
})
})
describe('videoKey', () => {
it('changes when the source URL list changes', () => {
const at1280 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const at640 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 640,
formats: ['webm', 'mp4']
})
expect(videoKey(at1280)).not.toBe(videoKey(at640))
})
it('is stable across repeated calls with the same inputs', () => {
const args = {
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4'] as const
}
expect(
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
})
it('reflects format-order changes', () => {
const webmFirst = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const mp4First = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['mp4', 'webm']
})
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
})
})

View File

@@ -0,0 +1,49 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat
}
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
webm: 'video/webm',
mp4: 'video/mp4'
}
type BuildArgs = {
name: string
baseUrl: string
width: number
formats: VideoFormat[]
}
/**
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
* matching the output of `apps/website/scripts/process-videos.sh`.
*/
export function buildVideoSources({
name,
baseUrl,
width,
formats
}: BuildArgs): VideoSource[] {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return formats.map((format) => ({
src: `${base}/${name}-${width}.${format}`,
type: MIME_TYPES[format],
format
}))
}
/**
* Stable identifier for a list of video sources, suitable as a Vue `key`.
* Browsers do not reload a `<video>` when nested `<source>` children change;
* keying the parent forces a remount when the source set changes.
*/
export function videoKey(sources: VideoSource[]): string {
return sources.map((s) => s.src).join('|')
}

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldUpscaler",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [{ "name": "image", "type": "IMAGE", "link": null }],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldUpscaler" },
"widgets_values": ["lanczos", 1.5]
},
{
"id": 3,
"type": "SaveImage",
"pos": [900, 100],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 2 }],
"properties": { "Node name for S&R": "SaveImage" },
"widgets_values": ["ComfyUI"]
}
],
"links": [[2, 2, 0, 3, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -0,0 +1,59 @@
{
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "VAEDecode",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 1 },
{ "name": "vae", "type": "VAE", "link": null }
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "VAEDecode" },
"widgets_values": []
}
],
"links": [[1, 1, 0, 2, 0, "LATENT"]],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -505,6 +505,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or

View File

@@ -0,0 +1,47 @@
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
/**
* Mock node replacement mappings for e2e tests.
*
* Maps fake "missing" node types (E2E_OldSampler, E2E_OldUpscaler) to real
* core node types that are always available in the test server.
*/
export const mockNodeReplacements: NodeReplacementResponse = {
E2E_OldSampler: [
{
new_node_id: 'KSampler',
old_node_id: 'E2E_OldSampler',
old_widget_ids: ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'],
input_mapping: [
{ new_id: 'model', old_id: 'model' },
{ new_id: 'positive', old_id: 'positive' },
{ new_id: 'negative', old_id: 'negative' },
{ new_id: 'latent_image', old_id: 'latent_image' },
{ new_id: 'seed', old_id: 'seed' },
{ new_id: 'steps', old_id: 'steps' },
{ new_id: 'cfg', old_id: 'cfg' },
{ new_id: 'sampler_name', old_id: 'sampler_name' },
{ new_id: 'scheduler', old_id: 'scheduler' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
}
],
E2E_OldUpscaler: [
{
new_node_id: 'ImageScaleBy',
old_node_id: 'E2E_OldUpscaler',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'image', old_id: 'image' },
{ new_id: 'upscale_method', old_id: 'upscale_method' },
{ new_id: 'scale_by', old_id: 'scale_by' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
}
]
}
/** Subset containing only the E2E_OldSampler replacement. */
export const mockNodeReplacementsSingle: NodeReplacementResponse = {
E2E_OldSampler: mockNodeReplacements.E2E_OldSampler
}

View File

@@ -0,0 +1,136 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const MASK_CANVAS_INDEX = 2
const RGB_CANVAS_INDEX = 1
export type BrushSliderLabel = 'thickness'
export class MaskEditorHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {
return this.comfyPage.page
}
async loadImageOnNode() {
await this.comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await this.comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await this.comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = this.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
async openDialog(): Promise<Locator> {
const { imagePreview } = await this.loadImageOnNode()
await imagePreview.getByRole('region').hover()
await this.page.getByLabel('Edit or mask image').click()
const dialog = this.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async drawStrokeOnPointerZone(dialog: Locator) {
const pointerZone = dialog.getByTestId('pointer-zone')
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await this.page.mouse.move(startX, startY)
await this.page.mouse.down()
await this.page.mouse.move(endX, endY, { steps: 10 })
await this.page.mouse.up()
return { startX, startY, endX, endY, box }
}
async drawStrokeAndExpectPixels(dialog: Locator) {
await this.drawStrokeOnPointerZone(dialog)
await expect.poll(() => this.pollMaskPixelCount()).toBeGreaterThan(0)
}
getCanvasPixelData(canvasIndex: number) {
return this.page.evaluate((idx) => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
const canvas = canvases[idx] as HTMLCanvasElement | undefined
if (!canvas) return null
const ctx = canvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, canvas.width, canvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
}, canvasIndex)
}
pollMaskPixelCount(): Promise<number> {
return this.getCanvasPixelData(MASK_CANVAS_INDEX).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
pollRgbPixelCount(): Promise<number> {
return this.getCanvasPixelData(RGB_CANVAS_INDEX).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
getCanvasSnapshot(canvasIndex: number): Promise<string> {
return this.page.evaluate((idx) => {
const canvas = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)[idx] as HTMLCanvasElement | undefined
return canvas?.toDataURL() ?? ''
}, canvasIndex)
}
brushInput(dialog: Locator, label: BrushSliderLabel): Locator {
return dialog.getByTestId(`brush-${label}-input`)
}
}
export const maskEditorTest = comfyPageFixture.extend<{
maskEditor: MaskEditorHelper
}>({
maskEditor: async ({ comfyPage }, use) => {
await use(new MaskEditorHelper(comfyPage))
}
})

View File

@@ -0,0 +1,93 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
/**
* Mock `/api/node_replacements` and enable the node replacement feature.
*
* Unlike features that only consult settings (e.g. shareWorkflowDialog,
* managerDialog), node replacement gates on `api.serverFeatureFlags`. The
* server sends a `feature_flags` WS message that wholesale replaces
* `serverFeatureFlags`, racing with any test-side override done via
* `page.evaluate`. To make the flow deterministic across CI shards, this
* helper patches `WebSocket.prototype` so every incoming `feature_flags`
* message has `node_replacements: true` injected before the api's WS
* handler sees it. Reload the page so the patched WebSocket and persisted
* settings apply to a fresh app boot, then wait for the resulting
* `/api/node_replacements` fetch before returning.
*/
export async function setupNodeReplacement(
comfyPage: ComfyPage,
replacements: NodeReplacementResponse
): Promise<void> {
await comfyPage.page.route('**/api/node_replacements', (route) =>
route.fulfill({ json: replacements })
)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
await comfyPage.page.addInitScript(() => {
const proto = window.WebSocket.prototype
const originalAdd = proto.addEventListener
proto.addEventListener = function patchedAdd(
this: WebSocket,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean
) {
if (type === 'message' && typeof listener === 'function') {
const wrapped = function (this: WebSocket, event: Event) {
const msgEvent = event as MessageEvent
if (typeof msgEvent.data === 'string') {
try {
const msg = JSON.parse(msgEvent.data)
if (
msg &&
msg.type === 'feature_flags' &&
msg.data &&
typeof msg.data === 'object'
) {
msg.data.node_replacements = true
const patched = new MessageEvent('message', {
data: JSON.stringify(msg),
origin: msgEvent.origin,
lastEventId: msgEvent.lastEventId
})
return (listener as EventListener).call(this, patched)
}
} catch {
// not JSON or not a feature_flags message - pass through
}
}
return (listener as EventListener).call(this, event)
}
return originalAdd.call(this, type, wrapped as EventListener, options)
}
return originalAdd.call(
this,
type,
listener as EventListenerOrEventListenerObject,
options
)
}
})
const fetchPromise = comfyPage.page.waitForResponse(
(response) =>
response.url().includes('/api/node_replacements') && response.ok(),
{ timeout: 10000 }
)
await comfyPage.workflow.reloadAndWaitForApp()
await fetchPromise
}
export function getSwapNodesGroup(page: Page): Locator {
return page.getByTestId(TestIds.dialogs.swapNodesGroup)
}

View File

@@ -64,6 +64,7 @@ export const TestIds = {
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',

View File

@@ -0,0 +1,34 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const GROUP_TITLE_CLICK_OFFSET_X = 50
const GROUP_TITLE_CLICK_OFFSET_Y = 15
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
export async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate(
({ title, offsetX, offsetY }) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + offsetX,
group.pos[1] + offsetY
])
return { x: clientPos[0], y: clientPos[1] }
},
{
title,
offsetX: GROUP_TITLE_CLICK_OFFSET_X,
offsetY: GROUP_TITLE_CLICK_OFFSET_Y
}
)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}

View File

@@ -0,0 +1,20 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Opens the selection toolbox "More Options" menu and returns the menu
* locator so callers can scope follow-up queries to it.
*/
export async function openMoreOptions(comfyPage: ComfyPage): Promise<Locator> {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
return menu
}

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -12,15 +13,7 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
const titlePos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()

View File

@@ -2,29 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate((title) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
/**
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}

View File

@@ -13,45 +13,35 @@ test.describe('Keyboard shortcut actions', { tag: '@keyboard' }, () => {
await comfyPage.setup()
})
test('Ctrl+Z undoes the last graph change', async ({ comfyPage }) => {
test('Ctrl+Z undoes and Ctrl+Shift+Z redoes the last graph change', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Ctrl+Z undoes the last graph change', async () => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('ControlOrMeta+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('ControlOrMeta+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Ctrl+Shift+Z redoes after undo', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Ctrl+Shift+Z redoes after undo', async () => {
await comfyPage.page.keyboard.press('ControlOrMeta+Shift+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('ControlOrMeta+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.page.keyboard.press('ControlOrMeta+Shift+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('Ctrl+S opens save dialog', async ({ comfyPage }) => {
@@ -62,25 +52,23 @@ test.describe('Keyboard shortcut actions', { tag: '@keyboard' }, () => {
await expect(saveDialog).toBeVisible()
})
test('Ctrl+, opens settings dialog', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
test('Ctrl+, opens and Escape closes settings dialog', async ({
comfyPage
}) => {
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsDialog).toBeVisible()
})
test('Escape closes settings dialog', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
await test.step('Ctrl+, opens settings dialog', async () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsDialog).toBeVisible()
await expect(settingsDialog).toBeVisible()
})
await comfyPage.page.keyboard.press('Escape')
await expect(settingsDialog).toBeHidden()
await test.step('Escape closes settings dialog', async () => {
await comfyPage.page.keyboard.press('Escape')
await expect(settingsDialog).toBeHidden()
})
})
test('Delete key removes selected nodes', async ({ comfyPage }) => {

View File

@@ -0,0 +1,32 @@
import { expect } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
test.describe('Load3D LOD', () => {
test(
'canvas pixel dimensions scale with ComfyUI canvas zoom level',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await expect(load3d.canvas).toBeVisible()
await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(0)
const initialWidth = await load3d.canvas.evaluate(
(el: HTMLCanvasElement) => el.width
)
await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
window.app!.canvas.ds.scale = 2.0
node.onResize?.(node.size)
})
await comfyPage.nextFrame()
await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(initialWidth)
}
)
})

View File

@@ -1,117 +1,13 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { imagePreview } = await loadImageOnNode(comfyPage)
async ({ comfyPage, maskEditor }) => {
const { imagePreview } = await maskEditor.loadImageOnNode()
// Hover over the image panel to reveal action buttons
await imagePreview.getByRole('region').hover()
@@ -139,8 +35,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
'opens mask editor from context menu',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
@@ -166,63 +62,61 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
)
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('draws a brush stroke on the mask canvas', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
const dataBefore = await maskEditor.getCanvasPixelData(2)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
})
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('undo reverts a brush stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
})
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('redo restores an undone stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('clear button removes all mask content', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
})
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('cancel closes the dialog without saving', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
@@ -230,10 +124,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('invert button inverts the mask', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
const dataBefore = await maskEditor.getCanvasPixelData(2)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
@@ -242,26 +136,29 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await invertButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('keyboard shortcut Ctrl+Z triggers undo', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
@@ -279,9 +176,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
)
test('switching tools updates the selected indicator', async ({
comfyPage
maskEditor
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dialog = await maskEditor.openDialog()
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
@@ -300,9 +197,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
})
test('brush settings panel is visible with thickness controls', async ({
comfyPage
maskEditor
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dialog = await maskEditor.openDialog()
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
@@ -315,8 +212,11 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('save uploads all layers and closes dialog', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
let maskUploadCount = 0
let imageUploadCount = 0
@@ -359,8 +259,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
@@ -380,23 +280,23 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Draw a stroke with the mask pen (default tool)
await drawStrokeAndExpectPixels(comfyPage, dialog)
await maskEditor.drawStrokeAndExpectPixels(dialog)
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
const pixelsAfterDraw = await maskEditor.getCanvasPixelData(2)
// Switch to eraser tool (3rd tool, index 2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await toolEntries.nth(2).click()
// Draw over the same area with the eraser
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.poll(() => maskEditor.pollMaskPixelCount())
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)

View File

@@ -0,0 +1,100 @@
import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
const RGB_PAINT_TOOL_INDEX = 1 // RGB / color paint tool
const ERASER_TOOL_INDEX = 2 // Eraser tool
test.describe(
'Mask Editor brush adjustment and layer management',
{ tag: '@vue-nodes' },
() => {
test.describe('Brush settings interaction', () => {
test('Adjusting brush thickness slider changes stroke output', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
const thicknessInput = maskEditor.brushInput(dialog, 'thickness')
// Thin brush
await thicknessInput.fill('2')
await expect(thicknessInput).toHaveValue('2')
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const thinPixels = await maskEditor.pollMaskPixelCount()
await comfyPage.page.keyboard.press('Control+z')
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
// Thick brush
await thicknessInput.fill('200')
await expect(thicknessInput).toHaveValue('200')
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(thinPixels)
})
})
test.describe('Layer management', () => {
test('Drawing on different tools produces independent mask data', async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const maskSnapshotAfterPen = await maskEditor.getCanvasSnapshot(2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
await toolEntries.nth(RGB_PAINT_TOOL_INDEX).click()
await expect(toolEntries.nth(RGB_PAINT_TOOL_INDEX)).toHaveClass(
/Selected/
)
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollRgbPixelCount())
.toBeGreaterThan(0)
await expect
.poll(() => maskEditor.getCanvasSnapshot(2))
.toBe(maskSnapshotAfterPen)
})
test("Switching between tools preserves previous tool's mask data", async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const maskSnapshot = await maskEditor.getCanvasSnapshot(2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
await toolEntries.nth(ERASER_TOOL_INDEX).click()
await expect(toolEntries.nth(ERASER_TOOL_INDEX)).toHaveClass(/Selected/)
await toolEntries.nth(0).click()
await expect(toolEntries.nth(0)).toHaveClass(/Selected/)
await expect
.poll(() => maskEditor.getCanvasSnapshot(2))
.toBe(maskSnapshot)
})
})
}
)

View File

@@ -0,0 +1,168 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
mockNodeReplacements,
mockNodeReplacementsSingle
} from '@e2e/fixtures/data/nodeReplacements'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
{ name: 'litegraph', vueNodesEnabled: false }
] as const
test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
for (const mode of renderModes) {
test.describe(`(${mode.name})`, () => {
test.describe('Single replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_simple'
)
})
test('Swap Nodes group appears in errors tab for replaceable nodes', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
).toBeVisible()
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await swapGroup.getByRole('button', { name: /replace node/i }).click()
await expect(swapGroup).toBeHidden()
const workflow = await comfyPage.workflow.getExportedWorkflow()
expect(
workflow.nodes,
'Node count should be unchanged after in-place replacement'
).toHaveLength(2)
const nodeTypes = workflow.nodes.map((n) => n.type)
expect(nodeTypes).not.toContain('E2E_OldSampler')
expect(nodeTypes).toContain('KSampler')
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
expect(
ksampler?.id,
'Replaced node should keep the original id'
).toBe(1)
const linkFromReplacedToDecode = workflow.links?.find(
(l) => l[1] === 1 && l[3] === 2
)
expect(
linkFromReplacedToDecode,
'Output link from replaced node to VAEDecode should be preserved'
).toBeDefined()
})
test('Widget values are preserved after replacement', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()
const workflow = await comfyPage.workflow.getExportedWorkflow()
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
expect(ksampler?.widgets_values).toBeDefined()
const widgetValues = ksampler!.widgets_values as unknown[]
expect(widgetValues).toEqual([
42,
'randomize',
20,
7,
'euler',
'normal',
1
])
})
test('Success toast is shown after replacement', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()
await expect(comfyPage.visibleToasts.first()).toContainText(
/replaced|swapped/i
)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacements)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_multi'
)
})
test('Replace All replaces all groups across multiple types', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(swapGroup).toContainText('E2E_OldUpscaler')
await swapGroup
.getByRole('button', { name: 'Replace All', exact: true })
.click()
await expect(swapGroup).toBeHidden()
const workflow = await comfyPage.workflow.getExportedWorkflow()
const nodeTypes = workflow.nodes.map((n) => n.type)
expect(nodeTypes).not.toContain('E2E_OldSampler')
expect(nodeTypes).not.toContain('E2E_OldUpscaler')
expect(nodeTypes).toContain('KSampler')
expect(nodeTypes).toContain('ImageScaleBy')
})
test('Output connections are preserved across replacement with output mapping', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: 'Replace All', exact: true })
.click()
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
() =>
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
)
expect(
replacedNodeOutputLinkCount,
'Replaced upscaler should still drive its downstream consumer'
).toBeGreaterThan(0)
})
})
})
}
})

View File

@@ -15,6 +15,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const TOTAL_MOCK_JOBS = 20
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
const overflowJobsListRoutePattern = '**/api/jobs?*'
function isHistoryJobsRequest(url: string): boolean {
@@ -59,7 +60,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
}) => {
const TARGET_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
MAX_HISTORY_ITEMS_SETTING,
TARGET_LIMIT
)
@@ -106,7 +107,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
const VISIBLE_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
MAX_HISTORY_ITEMS_SETTING,
VISIBLE_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())

View File

@@ -2,21 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
async function openMoreOptions(comfyPage: ComfyPage) {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
// Wait for the context menu to appear by checking for 'Copy', which is
// always present regardless of single or multi-node selection.
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
}
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
test.describe('Single node actions', () => {
@@ -34,14 +20,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBePinned()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Pin', { exact: true }).click()
let menu = await openMoreOptions(comfyPage)
await menu.getByText('Pin', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).toBePinned()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Unpin', { exact: true }).click()
menu = await openMoreOptions(comfyPage)
await menu.getByText('Unpin', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBePinned()
@@ -57,14 +43,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBeCollapsed()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Minimize Node', { exact: true }).click()
let menu = await openMoreOptions(comfyPage)
await menu.getByText('Minimize Node', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).toBeCollapsed()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Expand Node', { exact: true }).click()
menu = await openMoreOptions(comfyPage)
await menu.getByText('Expand Node', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBeCollapsed()
@@ -78,8 +64,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Copy', { exact: true }).click()
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Copy', { exact: true }).click()
await comfyPage.nextFrame()
// Paste the copied node
@@ -99,8 +85,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Duplicate', { exact: true }).click()
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Duplicate', { exact: true }).click()
await comfyPage.nextFrame()
await expect

View File

@@ -0,0 +1,96 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
test.describe('Selection toolbox rename', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
})
test.describe('Single rename', () => {
test('Rename via More Options opens title editor for single node', async ({
comfyPage
}) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.page.getByTestId('node-title-input')).toHaveValue(
'KSampler'
)
})
test('Rename shows prompt dialog for group', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerGroupPos = await getGroupTitlePosition(
comfyPage,
'Outer Group'
)
await comfyPage.canvas.click({ position: outerGroupPos })
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
await comfyPage.nodeOps.promptDialogInput.fill('Renamed Group')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph.groups.some(
(g) => g.title === 'Renamed Group'
)
})
)
.toBe(true)
})
})
test.describe('Batch rename', () => {
test('Batch rename multiple selected nodes', async ({ comfyPage }) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
const emptyLatent = (
await comfyPage.nodeOps.getNodeRefsByTitle('Empty Latent Image')
)[0]
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
await comfyPage.nodeOps.promptDialogInput.fill('TestNode')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
await expect
.poll(async () => {
const titles = await Promise.all([
ksampler.getProperty<string>('title'),
emptyLatent.getProperty<string>('title')
])
return [...titles].sort()
})
.toEqual(['TestNode 1', 'TestNode 2'])
})
})
})

View File

@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe('Nested subgraph configure order', () => {
@@ -190,4 +191,106 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
})
}
)
test.describe(
'Nested subgraph input target resolution',
{ tag: ['@widget', '@vue-nodes'] },
() => {
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
const OUTER_NODE_ID = '5'
const INNER_SUBGRAPH_NODE_ID = '6'
test('Nested SubgraphNode promoted widgets render without resolution failures', async ({
comfyPage
}) => {
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(
widgets,
'asset has 4 promoted widgets on outer subgraph node'
).toHaveCount(4)
expect(warnings).toEqual([])
} finally {
dispose()
}
})
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgets).toHaveCount(4)
const valueWidget = outerNode
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
})
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
await expect
.poll(async () => {
const widgets = await getPromotedWidgets(comfyPage, OUTER_NODE_ID)
return widgets
.filter(
([sourceNodeId]) => sourceNodeId === INNER_SUBGRAPH_NODE_ID
)
.map(([, sourceWidgetName]) => sourceWidgetName)
})
.toContain('value')
})
test('Serialize and reload preserves nested promoted widget visibility', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(
widgets,
'asset has 4 promoted widgets on outer subgraph node'
).toHaveCount(4)
const initialCount = await widgets.count()
await comfyPage.subgraph.serializeAndReload()
await comfyPage.vueNodes.waitForNodes()
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
const valueWidget = outerNodeAfter
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
})
}
)
})

View File

@@ -22,44 +22,35 @@ test.describe('Topbar menu commands', { tag: '@ui' }, () => {
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
test('Edit > Undo undoes and Edit > Redo restores the last action', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Edit > Undo undoes the last action', async () => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Edit > Redo restores an undone action', async () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('File > Save opens save dialog', async ({ comfyPage }) => {

View File

@@ -0,0 +1,49 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const WORKFLOW_NAME = 'test-confirm-delete'
async function startDeletingFromSidebar(comfyPage: ComfyPage) {
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(WORKFLOW_NAME).click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Delete')
}
test.describe('Comfy.Workflow.ConfirmDelete', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflowAs(WORKFLOW_NAME)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('on (default): right-click → Delete prompts the confirm dialog', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', true)
await startDeletingFromSidebar(comfyPage)
await expect(comfyPage.confirmDialog.root).toBeVisible()
await expect(comfyPage.confirmDialog.delete).toBeVisible()
})
test('off: right-click → Delete bypasses the confirm dialog', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
await startDeletingFromSidebar(comfyPage)
const { workflowsTab } = comfyPage.menu
await expect(comfyPage.confirmDialog.root).toBeHidden()
await expect
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
.not.toContain(WORKFLOW_NAME)
})
})

View File

@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
})
```
## Mocking Lodash Functions
## Mocking Utility Functions
Mocking utility functions like debounce:

View File

@@ -230,6 +230,37 @@ export default defineConfig([
]
}
},
{
name: 'comfy/no-unsafe-error-assertion',
files: [
'src/**/*.ts',
'src/**/*.tsx',
'src/**/*.vue',
'apps/*/src/**/*.ts',
'apps/*/src/**/*.tsx',
'apps/*/src/**/*.vue'
],
ignores: ['**/*.test.ts', '**/*.spec.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
// Bans `value as Error` and `value as Error & { ... }`.
// Use `error instanceof Error` narrowing or `toError()` from
// @/utils/errorUtil instead — see issue #11429.
selector: "TSAsExpression TSTypeReference[typeName.name='Error']",
message:
'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
},
{
// Bans `<Error>value` and `<Error & { ... }>value`.
selector: "TSTypeAssertion TSTypeReference[typeName.name='Error']",
message:
'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
}
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts', 'apps/*/e2e/**/*.spec.ts'],

View File

@@ -54,6 +54,9 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

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

View File

@@ -29,6 +29,17 @@ export type {
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -307,6 +318,28 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
GetLegacyHistoryErrors,
GetLegacyJobByIdData,
GetLegacyJobByIdErrors,
GetLegacyJobOutputsData,
GetLegacyJobOutputsErrors,
GetLegacyModelsByFolderData,
GetLegacyModelsByFolderErrors,
GetLegacyModelsData,
GetLegacyModelsErrors,
GetLegacyObjectInfoByNodeClassData,
GetLegacyObjectInfoByNodeClassErrors,
GetLegacyPromptByIdData,
GetLegacyPromptByIdErrors,
GetLegacyUserdataV2Data,
GetLegacyUserdataV2Errors,
GetLegacyViewMetadataData,
GetLegacyViewMetadataErrors,
GetLogsData,
GetLogsError,
GetLogsErrors,
@@ -505,6 +538,7 @@ export type {
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
@@ -719,6 +753,13 @@ export type {
SubscribeResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
SyncApiKeyError,
SyncApiKeyErrors,
SyncApiKeyRequest,
SyncApiKeyResponse,
SyncApiKeyResponse2,
SyncApiKeyResponses,
SystemStatsResponse,
TagInfo,
TagsModificationResponse,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4014,6 +4014,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/seedance/visual-validate/groups": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List the caller's completed visual-validation groups
* @description Returns the caller's completed visual-validation groups (real-person H5 verification). Used to power the group selector in client UIs. Excludes virtual-library (AIGC) groups, which are not part of the public API surface.
*/
get: operations["seedanceListVisualValidationGroups"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/seedance/visual-validate/sessions/{session_id}": {
parameters: {
query?: never;
@@ -4037,7 +4057,11 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/**
* List the caller's assets across all owned groups
* @description Fans out to BytePlus ListAssets across the caller's completed verification groups, denormalizes the group label into each row, and returns a single flat list. Result is post-filtered by asset_type. Optional group_id narrows to one group. Hard caps: 5 pages × 100 assets per group, 1000 total assets.
*/
get: operations["seedanceListUserAssets"];
put?: never;
post: operations["seedanceCreateAsset"];
delete?: never;
@@ -13569,7 +13593,7 @@ export interface components {
stream: boolean | null;
};
/** @enum {string} */
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "chatgpt-4o-latest";
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.5" | "gpt-5.5-pro" | "chatgpt-4o-latest";
MoonvalleyTextToVideoInferenceParams: {
/**
* @description Height of the generated video in pixels
@@ -14442,6 +14466,10 @@ export interface components {
total_tokens?: number;
};
};
SeedanceCreateVisualValidateSessionRequest: {
/** @description Optional human-readable label for the asset group that will be created by this verification. Stored locally and returned by seedanceListVisualValidationGroups so users can identify their groups in selectors. */
name?: string;
};
SeedanceCreateVisualValidateSessionResponse: {
/**
* Format: uuid
@@ -14451,6 +14479,37 @@ export interface components {
/** @description BytePlus-issued H5 liveness link. Open in a browser with camera access. Valid for ~120 seconds. */
h5_link: string;
};
SeedanceListVisualValidationGroupsResponse: {
groups: components["schemas"]["SeedanceVisualValidationGroup"][];
};
SeedanceListUserAssetsResponse: {
assets: components["schemas"]["SeedanceUserAsset"][];
/** @description True if the global per-request asset cap was hit and older results were dropped. */
truncated: boolean;
};
SeedanceUserAsset: {
asset_id: string;
name?: string | null;
/** @description BytePlus access URL (~12h validity). Refreshed on each list call. */
url?: string | null;
group_id: string;
/** @description Display label of the source group, denormalized for client-side search. */
group_name: string;
/** @enum {string} */
asset_type: "Image" | "Video" | "Audio";
/** @enum {string} */
status: "Active" | "Processing" | "Failed";
/** Format: date-time */
create_time: string;
};
SeedanceVisualValidationGroup: {
/** @description BytePlus-issued asset group id. */
group_id: string;
/** @description Display label. Caller-supplied at creation time when available; otherwise a server-generated fallback derived from the creation date. */
name: string;
/** Format: date-time */
created_at: string;
};
SeedanceGetVisualValidateSessionResponse: {
/** Format: uuid */
session_id: string;
@@ -14458,6 +14517,8 @@ export interface components {
status: "pending" | "completed" | "failed";
/** @description Populated only when status == completed. This is the BytePlus Asset Group ID the user will upload assets into. */
group_id?: string | null;
/** @description Optional human-readable label provided when the session was created. */
name?: string | null;
error_code?: string | null;
error_message?: string | null;
};
@@ -30275,7 +30336,11 @@ export interface operations {
path?: never;
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": components["schemas"]["SeedanceCreateVisualValidateSessionRequest"];
};
};
responses: {
/** @description Verification session created */
201: {
@@ -30297,6 +30362,35 @@ export interface operations {
};
};
};
seedanceListVisualValidationGroups: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Visual-validation groups owned by the caller */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeedanceListVisualValidationGroupsResponse"];
};
};
/** @description Error 4xx/5xx */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
seedanceGetVisualValidateSession: {
parameters: {
query?: never;
@@ -30329,6 +30423,40 @@ export interface operations {
};
};
};
seedanceListUserAssets: {
parameters: {
query: {
/** @description Asset type to return. */
asset_type: "Image" | "Video";
/** @description Narrow the listing to one group. Caller must own it. */
group_id?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Assets owned by the caller */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeedanceListUserAssetsResponse"];
};
};
/** @description Error 4xx/5xx */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
seedanceCreateAsset: {
parameters: {
query?: never;

View File

@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isEmbeddedWebView } from '@/base/webviewDetection'
describe('isEmbeddedWebView', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Android WebView', () => {
it('detects Android WebView with wv token', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('does not flag regular Chrome on Android', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('iOS WKWebView', () => {
it('detects iOS WKWebView (AppleWebKit without Safari/)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('does not flag regular Safari on iOS', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Chrome on iOS (CriOS)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Firefox on iOS (FxiOS)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('social app in-app browsers', () => {
it('detects Facebook (FBAN)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Instagram', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects TikTok', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Line', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Snapchat', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
})
describe('regular desktop browsers', () => {
it('does not flag Chrome desktop', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Firefox desktop', () => {
const ua =
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Safari desktop', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('edge cases', () => {
it('handles empty string', () => {
expect(isEmbeddedWebView('')).toBe(false)
})
})
describe('JS bridge detection', () => {
it('detects webkit.messageHandlers bridge', () => {
vi.stubGlobal('webkit', { messageHandlers: {} })
expect(isEmbeddedWebView('')).toBe(true)
})
it('detects ReactNativeWebView bridge', () => {
vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() })
expect(isEmbeddedWebView('')).toBe(true)
})
})
})

View File

@@ -0,0 +1,72 @@
/**
* Detects whether the app is running inside an embedded webview.
*
* Google blocks OAuth via `signInWithPopup` in embedded webviews,
* returning a 403 `disallowed_useragent` error (policy since 2021).
* This utility is used to hide the Google SSO button in those contexts.
*
* Detection covers:
* • Android WebView (`wv` token in UA)
* • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`)
* • Social app in-app browsers (Facebook, Instagram, TikTok, etc.)
* • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`)
*/
const SOCIAL_APP_PATTERNS =
/FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i
function isAndroidWebView(ua: string): boolean {
return /\bwv\b/.test(ua) && /Android/.test(ua)
}
function isIOSWebView(ua: string): boolean {
if (!/AppleWebKit/i.test(ua)) return false
if (/Safari\//i.test(ua)) return false
if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false
return true
}
function isSocialAppBrowser(ua: string): boolean {
return SOCIAL_APP_PATTERNS.test(ua)
}
function hasWebViewBridge(): boolean {
try {
const win = globalThis as Record<string, unknown>
if (
typeof win.webkit === 'object' &&
win.webkit !== null &&
typeof (win.webkit as Record<string, unknown>).messageHandlers ===
'object'
) {
return true
}
if (win.ReactNativeWebView != null) return true
} catch {
// Access to bridge objects may throw in sandboxed contexts
}
return false
}
export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean {
if (isSocialAppBrowser(ua)) return true
if (isAndroidWebView(ua)) return true
if (isIOSWebView(ua)) return true
if (hasWebViewBridge()) return true
return false
}
/**
* Reason why Google SSO is blocked in the current environment, or `null` if it
* is available. Modeled as a discriminated string so call sites read as
* "if blocked, here's why" rather than an opaque boolean. Extend this union
* (e.g. `'unauthorized-host'`) as new blocking conditions are detected.
*/
type GoogleSsoBlockedReason = 'embedded-webview' | null
export function getGoogleSsoBlockedReason(
ua: string = navigator.userAgent
): GoogleSsoBlockedReason {
if (isEmbeddedWebView(ua)) return 'embedded-webview'
return null
}

View File

@@ -0,0 +1,104 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CustomizationDialog from './CustomizationDialog.vue'
const DEFAULT_ICON = 'pi-bookmark-fill'
const DEFAULT_COLOR = '#a1a1aa'
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
defaultBookmarkIcon: DEFAULT_ICON,
defaultBookmarkColor: DEFAULT_COLOR,
bookmarksCustomization: {}
})
}))
vi.mock('primevue/dialog', () => ({
default: {
name: 'Dialog',
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
props: ['visible']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
template: '<div />',
props: ['modelValue', 'options']
}
}))
vi.mock('primevue/divider', () => ({
default: { name: 'Divider', template: '<hr />' }
}))
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
default: {
name: 'ColorCustomizationSelector',
template: '<div />',
props: ['modelValue', 'colorOptions']
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
name: 'Button',
template: `<button @click="$emit('click')"><slot /></button>`,
emits: ['click']
}
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function renderDialog(extraProps: Record<string, unknown> = {}) {
const onConfirm = vi.fn()
render(CustomizationDialog, {
global: { plugins: [i18n] },
props: { modelValue: true, onConfirm, ...extraProps }
})
return { onConfirm }
}
describe('CustomizationDialog', () => {
describe('confirmCustomization', () => {
it('emits confirm with default icon and color when no initial values provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog()
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
})
it('emits confirm with matching initialIcon when provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialIcon: 'pi-star' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith('pi-star', DEFAULT_COLOR)
})
it('falls back to default icon when initialIcon does not match any option', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialIcon: 'pi-nonexistent' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
})
it('emits confirm with initialColor when provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialColor: '#007bff' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, '#007bff')
})
})
})

View File

@@ -94,17 +94,15 @@ const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
iconOptions.find((option) => option.value === props.initialIcon) ??
iconOptions[0]
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
}

View File

@@ -49,6 +49,7 @@
<div class="flex flex-col gap-6">
<template v-if="ssoAllowed">
<Button
v-if="!googleSsoBlockedReason"
type="button"
class="h-10"
variant="secondary"
@@ -157,6 +158,7 @@ import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isCloud } from '@/platform/distribution/types'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
import ApiKeyForm from './signin/ApiKeyForm.vue'
import SignInForm from './signin/SignInForm.vue'
@@ -172,6 +174,7 @@ const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,

View File

@@ -2,7 +2,7 @@
<span class="flex flex-row gap-0.5">
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag
class="min-w-6 justify-center gap-1 bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground uppercase"
class="min-w-6 justify-center gap-1 bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground capitalize"
:severity="isModified ? 'info' : 'secondary'"
>
{{ sequence }}

View File

@@ -64,6 +64,7 @@
</span>
<input
v-model.number="brushSize"
data-testid="brush-thickness-input"
type="number"
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
:min="1"

View File

@@ -98,6 +98,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
@@ -107,6 +108,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -119,6 +121,7 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async (close: () => void) => {
trackFeatureUsed()
close()
try {
@@ -138,6 +141,7 @@ const onToggleDockedJobHistory = async (close: () => void) => {
}
const onToggleRunProgressBar = async () => {
trackFeatureUsed()
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value

View File

@@ -13,7 +13,7 @@
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@@ -50,6 +50,7 @@ import type {
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -81,6 +82,7 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -95,6 +97,11 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
emit('update:selectedJobTab', value)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)

View File

@@ -66,6 +66,7 @@ import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -93,6 +94,7 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const {
totalPercentFormatted,
@@ -188,6 +190,7 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const jobId = item.taskRef?.jobId
if (!jobId) return
@@ -209,6 +212,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
@@ -224,10 +228,12 @@ const setExpanded = (expanded: boolean) => {
}
const viewAllJobs = () => {
trackFeatureUsed()
setExpanded(true)
}
const toggleAssetsSidebar = () => {
trackFeatureUsed()
sidebarTabStore.toggleSidebarTab('assets')
}
@@ -257,12 +263,14 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
@@ -275,6 +283,7 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
@@ -298,6 +307,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
})
const onClearHistoryFromMenu = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -122,6 +122,7 @@ import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
const {
hideShowAssetsAction = false,
@@ -147,6 +148,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
@@ -170,6 +172,7 @@ const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
trackFeatureUsed()
selectWorkflowFilter(value)
close()
}
@@ -179,6 +182,7 @@ const selectSortMode = (value: JobSortMode) => {
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
trackFeatureUsed()
selectSortMode(value)
close()
}

View File

@@ -2,15 +2,16 @@
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
<JobHistoryActionsMenu @clear-history="onClearHistory" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
v-model:selected-job-tab="selectedJobTab"
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
<JobFilterActions
@@ -81,13 +82,14 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -104,6 +106,17 @@ const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const onClearHistory = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
selectedJobTab.value = value
}
const {
selectedJobTab,
selectedWorkflowFilter,
@@ -145,6 +158,7 @@ const activeQueueSummary = computed(() => {
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
@@ -160,6 +174,7 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
@@ -194,10 +209,12 @@ const { jobMenuEntries, cancelJob } = useJobMenu(
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
progressToast: {
finished: 'Finished',
failed: 'Failed',
pending: 'Pending'
}
}
}
})
function completedJob(): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'controlnet-canny.safetensors',
bytesTotal: 100,
bytesDownloaded: 100,
progress: 1,
status: 'completed',
lastUpdate: Date.now()
}
}
describe('ProgressToastItem — completed state', () => {
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
render(ProgressToastItem, {
props: { job: completedJob() },
global: { plugins: [i18n] }
})
const badge = screen.getByText('Finished')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(badge.closest('.opacity-50')).toBeNull()
const assetName = screen.getByText('controlnet-canny.safetensors')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(assetName.closest('.opacity-50')).not.toBeNull()
})
})

View File

@@ -22,14 +22,9 @@ const isPending = computed(() => job.status === 'created')
<template>
<div
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
>
<div class="min-w-0 flex-1">
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
<span class="block truncate text-sm text-base-foreground">{{
job.assetName
}}</span>

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { buildStrokePoints, clampDirtyRect } from './gpuUtils'
const uninit = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
}
describe('clampDirtyRect', () => {
it('returns full canvas when dirty rect is uninitialised', () => {
expect(clampDirtyRect(uninit, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('returns the clamped rect when fully inside canvas bounds', () => {
const rect = { minX: 10, minY: 20, maxX: 60, maxY: 90 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 10,
dy: 20,
dw: 50,
dh: 70
})
})
it('clamps rect that extends beyond canvas edges', () => {
const rect = { minX: -5, minY: -10, maxX: 120, maxY: 250 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('returns full canvas when the clamped area has zero width', () => {
const rect = { minX: 50, minY: 10, maxX: 50, maxY: 80 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('returns full canvas when the clamped area has zero height', () => {
const rect = { minX: 10, minY: 50, maxX: 80, maxY: 50 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('floors dx/dy and ceils the far edges', () => {
const rect = { minX: 10.7, minY: 20.3, maxX: 59.2, maxY: 89.9 }
const result = clampDirtyRect(rect, 100, 200)
expect(result.dx).toBe(10)
expect(result.dy).toBe(20)
expect(result.dw).toBe(60 - 10) // ceil(59.2)=60, dx=10
expect(result.dh).toBe(90 - 20) // ceil(89.9)=90, dy=20
})
})
describe('buildStrokePoints', () => {
it('returns input points as-is when skipResampling is true', () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 100 }
]
const result = buildStrokePoints(points, true, 10)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ x: 0, y: 0, pressure: 1.0 })
expect(result[1]).toEqual({ x: 100, y: 100, pressure: 1.0 })
})
it('returns empty array for empty input', () => {
expect(buildStrokePoints([], false, 10)).toHaveLength(0)
expect(buildStrokePoints([], true, 10)).toHaveLength(0)
})
it('returns empty array for a single point (no segments to interpolate)', () => {
expect(buildStrokePoints([{ x: 5, y: 5 }], false, 10)).toHaveLength(0)
})
it('interpolates a horizontal segment into multiple evenly-spaced points', () => {
const points = [
{ x: 0, y: 0 },
{ x: 30, y: 0 }
]
const result = buildStrokePoints(points, false, 10)
// 30px distance / 10 stepSize = 3 steps → 4 points (s=0,1,2,3)
expect(result).toHaveLength(4)
expect(result[0]).toMatchObject({ x: 0, y: 0 })
expect(result[3]).toMatchObject({ x: 30, y: 0 })
result.forEach((p) => expect(p.pressure).toBe(1.0))
})
it('uses at least one step when points are very close together', () => {
const points = [
{ x: 0, y: 0 },
{ x: 0.1, y: 0 }
]
// distance 0.1 < stepSize 10 → steps=1 → 2 points
const result = buildStrokePoints(points, false, 10)
expect(result).toHaveLength(2)
})
it('interpolates all pressure values to 1.0', () => {
const points = [
{ x: 0, y: 0 },
{ x: 50, y: 50 }
]
const result = buildStrokePoints(points, false, 10)
result.forEach((p) => expect(p.pressure).toBe(1.0))
})
})

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