Compare commits

..

181 Commits

Author SHA1 Message Date
Kelly Yang
4a9001f675 test: add E2E tests for image crop widget Levels 4-7 (#11571)
## Summary

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

## Changes

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

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

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

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

No source code was modified.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus adding
`data-testid` attributes to BoundingBox inputs, with no behavioral logic
changes expected.
> 
> **Overview**
> **Expands E2E coverage for the `ImageCropV2` widget** by adding new
Playwright tests for ratio preset selection, lock/unlock behavior,
constrained resizing (including boundary clamping and min size), and
BoundingBox numeric input updates.
> 
> **Improves testability of BoundingBox controls** by adding
`data-testid` attributes to the `WidgetBoundingBox.vue`
`ScrubableNumberInput` fields (`x`, `y`, `width`, `height`) so E2E tests
can target increment/input elements reliably.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b008f42942. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11571-test-add-E2E-tests-for-image-crop-widget-Levels-4-7-34b6d73d365081c79118ca9ae08f291c)
by [Unito](https://www.unito.io)

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

N/A

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

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

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

---------

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

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

## Changes

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

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

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

## Changes

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

## Review Focus

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

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

---------

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

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

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

## Testing

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

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

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

---------

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

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

### Key changes

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

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

Every page has a story to tell.

## Changes

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

## Review Focus

Look closely at what was lost.

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

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

## Changes

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

## Review Focus

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11606-update-API-page-links-to-keys-and-docs-34c6d73d365081268bb5dd55c33646c8)
by [Unito](https://www.unito.io)
2026-04-24 10:28:03 -07:00
Yourz
7038cab926 update: replace placeholder images in API automation section (#11602)
## Changes

- Replace placeholder images in API automation section with final assets
  - Feature 1: `desert.webp` → `precision-tools.webp`
  - Feature 3: `free.webp` → `infrastructure-nodes.webp`


<img width="1000" height="552" alt="Kapture 2026-04-25 at 00 54 23"
src="https://github.com/user-attachments/assets/dd503d2f-56c3-4346-adfa-27b3b92a04a8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11602-update-replace-placeholder-images-in-API-automation-section-34c6d73d365081319ca4db9b60099835)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 16:02:36 +00:00
Yourz
1f888de0f6 update: content in Price page (#11600)
## Summary

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

Update wrong content in Price page of new website

## Changes

- **What**: <!-- Core functionality added/modified -->
- update both English and Chinese content of translate key
`pricing.included.feature2.description`

## Review Focus

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

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

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
<img width="1046" height="147" alt="image"
src="https://github.com/user-attachments/assets/0f2dc66c-d384-4fb4-85cb-2d9f68469dbc"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11600-update-content-in-Price-page-34c6d73d365081bb9ae1e09842c77249)
by [Unito](https://www.unito.io)
2026-04-24 15:59:18 +00:00
Yourz
b7a8056ab0 update: local hero illustration stroke colors and fix overflow clipping (#11601)
## Changes

- Update SVG stroke color from `#7E7C78` to `#4D3762` to match design
spec
- Reduce hex node stroke width from 6 to 3
- Fix illustration bottom edge being hard-clipped by removing
`lg:overflow-y-clip` from section
- Adjust container aspect ratio to match SVG viewBox proportions


<img width="1000" height="1287" alt="Kapture 2026-04-25 at 00 41 16"
src="https://github.com/user-attachments/assets/a3a14fba-74b3-4051-ac12-c175c4b3bd61"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11601-update-local-hero-illustration-stroke-colors-and-fix-overflow-clipping-34c6d73d3650811e9f0bdc1a3274800e)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 15:57:11 +00:00
pythongosssss
40fec7882c chore: remove unused glslUtils helpers (#11597)
## Summary

These look to have been added as part of the initial phased
implementation to align with the backend code, however they are unused:
- `detectOutputCount` - The frontend only shows a single output preview
which imo is fine, we can add multi display in future if required
- `hasVersionDirective` - The backend needs this as it can execute other
version shaders, the web browser will automatically validate and throw
an error if it is not a valid shader, this gets surfaced to the user
already.

## Changes

- **What**:  
- remove unused functions & tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11597-chore-remove-unused-glslUtils-helpers-34c6d73d365081eab784d205b8a0053e)
by [Unito](https://www.unito.io)
2026-04-24 10:52:45 +00:00
jaeone94
17d980dbc8 feat: add inline-CTA nightly survey for error panel (#11591)
## Summary

Adds an inline-CTA Typeform survey for the redesigned error panel,
targeting nightly localhost users. Reuses the existing node-search
survey infrastructure rather than introducing a parallel stack.

## Changes

- **What**:
- `surveyRegistry` gains optional `presentation: 'floating' |
'inline-cta'` and a `getFloatingSurveys()` helper; controller filters by
it.
- `NightlySurveyPopover` accepts `mode` + `v-model:open`. Manual mode
skips the eligibility watcher, drops the "Not Now" button, and leaves
open/close/markSeen to the parent.
- New `ErrorPanelSurveyCta.vue` renders a CTA in the error tab footer
once `useExecutionErrorStore.hasAnyError` has transitioned `null →
value` at least 3 times.
- Popover lives in `NightlySurveyController` (session-persistent).
Shared state via module-level singleton (`useErrorSurveyPopoverState`)
so the iframe survives error-tab unmounts during workflow switches.
- `useTypeformEmbed` centralises script loading (singleton Promise, 10s
timeout, explicit `window.tf.load()` on each new container). Necessary
because the embed's DOMContentLoaded auto-scan only fires once; late
consumers need an explicit re-scan to render.
  - CTA and feedback-gate strings added under `errorPanelSurvey.*`.

## Review Focus

- Manual-mode flow in `NightlySurveyPopover.vue` — CTA click is routed
through the module singleton; popover stays mounted after the first open
(`hasOpenedOnce` + `v-show`) to preserve the iframe across repeated
open/close cycles and workflow switches.
- `useTypeformEmbed.ts` — the `window.tf.load()` trick (verified against
the CDN `embed.js`) is what lets two surveys coexist; without it
Typeform's one-shot DOM scan misses whichever element mounts second.
- `NightlySurveyController.vue` guards against double-mount by requiring
`presentation === 'inline-cta'` on `errorPanelConfig`.
- Scope is nightly+localhost only (`isNightlyLocalhost` in
`useSurveyEligibility`); async component gate in `TabErrors.vue` keeps
this out of Cloud/Desktop/stable bundles.

## Test plan

- [x] `IS_NIGHTLY=true pnpm dev`, clear `Comfy.SurveyState` +
`Comfy.FeatureUsage`, trigger 3 failed runs → CTA appears in error tab
footer.
- [x] Click "Give feedback" → Typeform popover opens and renders the
form.
- [x] Close popover, switch workflow (error tab unmounts), trigger a new
error → CTA reappears and reopening the popover shows the same iframe.
- [x] Open error-panel popover, close, then trigger 3 node searches →
node-search auto-popup renders its own iframe correctly (two surveys
coexist).
- [x] CTA × dismisses and persists (`seenSurveys['error-panel']`).
- [x] "Don't ask again" inside popover sets `optedOut: true` and hides
all nightly surveys.
- [x] Cloud/Desktop/stable builds: CTA never renders, controller's
manual popover doesn't mount.

## Screenshot


https://github.com/user-attachments/assets/91145f23-fd1e-4caf-b6cc-4b97d33ed6b7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11591-feat-add-inline-CTA-nightly-survey-for-error-panel-34c6d73d3650817d9f95fddcf64633de)
by [Unito](https://www.unito.io)
2026-04-24 08:14:08 +00:00
Alexander Brown
9cd36c7f7d fix: website polish — prefetch, Safari video controls, border-spin perf (#11586)
## Summary

Website polish: enable Astro prefetch, fix mobile Safari video controls,
and optimize the ProductShowcase border-spin animation.

## Changes

- **What**:
- Enable `prefetch: { prefetchAll: true }` in Astro config for faster
navigation
- Hide native iOS Safari play-button overlays on autoplay background
videos (`BlobMedia`, global CSS)
- Refactor `ProductShowcaseSection` border-spin animation: use
`@property --border-angle` with `conic-gradient` directly on the
container (removes extra spinning `div`), gate animation on
`useIntersectionObserver` visibility, lazy-load non-active videos with
`preload="none"`, add `will-change-[opacity]` for smoother crossfade

## Review Focus

- `@property` CSS registered custom property — verify browser support is
acceptable for the website target audience
- `!important` on the webkit media controls rule — necessary to override
UA styles on iOS Safari

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11586-fix-website-polish-prefetch-Safari-video-controls-border-spin-perf-34c6d73d36508108a0e8f67df9b32a88)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 07:44:02 +00:00
Alexander Brown
8e3041aa2f ci: add e2e-status gate job for required checks (#11587)
## Summary

Follow-up to #11568. Fixes Playwright required checks hanging as
"Waiting for status to be reported" on PRs with no e2e-relevant file
changes.

## Problem

PR #11568 added a `changes` filter to skip E2E when only
docs/apps/storybook files are touched. The E2E workflow skips correctly,
but branch rulesets require the 11 matrix-expanded check names (e.g.
`playwright-tests-chromium-sharded (1, 8)`). When a matrix job is
skipped via dependency, GitHub only reports the parent job name — the
individual matrix entries are never reported, so required checks hang
forever.

## Fix

Add a single `e2e-status` gate job that:
- Uses `if: always()` so it always runs regardless of skipped
dependencies
- Passes when E2E was intentionally skipped (no relevant changes)
- Passes when all matrix jobs succeeded
- Fails when any matrix job failed

**After merging**, the ProtectMain and Core release branches rulesets
should be updated to require `e2e-status` instead of the 11 individual
matrix check names.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11587-ci-add-e2e-status-gate-job-for-required-checks-34c6d73d365081b59c01e8d61d0b808d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 05:43:45 +00:00
Alexander Brown
0b2e605dee content: website copy and pricing updates (#11567)
## Summary

Update website copy: fix branding ("Comfy" → "ComfyUI"), correct pricing
runtime, remove "coming soon" seat features, and shorten use-case label.

## Changes

- **What**: Copy corrections in `translations.ts` (branding, runtime "60
min" → "30 min" for Standard plan, remove placeholder seat features for
Creator/Pro plans); trim feature arrays in `PriceSection.vue`

## Review Focus

Verify zh-CN translations still make sense after the English copy
changes (runtime string not updated in zh-CN).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11567-content-website-copy-and-pricing-updates-34b6d73d365081c29af8ee1469b08358)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-24 04:25:15 +00:00
Christian Byrne
d038193d95 fix: add assetsPrefix to avoid /_astro collision with workflows site (#11584)
## Problem

The website and workflows sites both emit build assets under `/_astro/`.
The comfy-router sends all `/_astro/*` requests to the workflows Vercel
project, so when a page from the website references
`/_astro/SiteNav.xxx.js`, it 404s because that file only exists on the
website's Vercel deployment.

## Fix

Add `build.assetsPrefix: '/_website'` to the Astro config. This makes
Astro emit asset references as `/_website/SiteNav.xxx.js` instead of
`/_astro/SiteNav.xxx.js`.

The comfy-router already routes `/_website/*` to the website origin, so
these assets will resolve correctly.

## Why this matters

**This is blocking the comfy.org cutover from Framer → new site.**
Without it, the homepage loads but all CSS/JS 404s and nothing renders.

## Change

One line in `apps/website/astro.config.ts`:
```ts
build: {
  assetsPrefix: '/_website'
}
```

## Verification

After merge + Vercel deploy:
- Homepage HTML should reference `/_website/*.js` and `/_website/*.css`
instead of `/_astro/*`
- `/workflows` page should still reference `/_astro/*` (unaffected)
- Both sites render correctly through the proxy

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11584-fix-add-assetsPrefix-to-avoid-_astro-collision-with-workflows-site-34c6d73d36508101a932caa2905f0a2b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 21:05:21 -07:00
Benjamin Lu
04e430e006 fix: dedupe keybinding modifier display (#11570)
## Summary

Fix keybinding display so pressing a modifier key by itself shows that
modifier once instead of duplicated text like `Shift + Shift`.

## Changes

- **What**: Centralizes modifier key labels in `KeyComboImpl` and omits
the duplicated primary key when the pressed key is itself a modifier.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The keybinding model still includes active modifiers for normal
shortcuts like `Ctrl + Shift + k`, while modifier-only input now renders
as a single key. Regression coverage includes single modifier presses,
combined held modifiers, and a normal non-modifier shortcut.

Checks run: `pnpm exec vitest run
src/platform/keybindings/keyCombo.test.ts`; `pnpm lint:unstaged`; `pnpm
exec oxfmt --check src/platform/keybindings/keyCombo.ts
src/platform/keybindings/keyCombo.test.ts`; `pnpm exec vitest run
src/platform/keybindings/keyCombo.test.ts
src/platform/keybindings/keybindingStore.test.ts
src/platform/keybindings/keybindingService.escape.test.ts
src/platform/keybindings/keybindingService.canvas.test.ts`; `pnpm
typecheck`; pre-commit lint-staged checks; pre-push `pnpm knip --cache`.

Linear: FE-240

## Screenshots (if applicable)

Not applicable; covered by keybinding sequence unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11570-fix-dedupe-keybinding-modifier-display-34b6d73d365081968a88da4465c151de)
by [Unito](https://www.unito.io)
2026-04-24 03:41:42 +00:00
Comfy Org PR Bot
bab122ad6b 1.44.9 (#11581)
Patch version increment to 1.44.9

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-24 03:24:41 +00:00
Kelly Yang
bba3c38ae1 test: E2E coverage for painter widget (Levels 1–5) (#11551)
## Summary

Adds 7 new E2E tests for the Painter widget covering the remaining
untested items from Levels 1–5 of the widget test plan. All new tests
pass typecheck, lint, and the full pre-commit hook suite.

## Changes

**`browser_tests/tests/painter.spec.ts`**

- **Level 1.2** — assert node size ≥ 450×550 via the graph API to verify
the painter extension enforces its minimum dimensions
- **Level 1.3** — assert `width`, `height`, and `bg_color` widgets have
`options.hidden = true` so they don't appear as standard widget controls
- **Level 2.4** — cursor element becomes visible on pointer enter, its
CSS transform updates as the mouse moves across the canvas, and it hides
on pointer leave; uses `expect.poll` on transform to avoid the Vue
microtask race
- **Level 4.2** — set brush color to `#ff0000` via input event, draw a
stroke, sample a 40×40 region at canvas center and assert red pixels (R
> 200, G < 50, B < 50)
- **Level 4.3** — set opacity to 50%, draw a stroke, sample pixel alpha
values and assert semi-transparency (50 < α < 230)
- **Level 4.4** — focus the hardness slider, press ArrowLeft ×10, assert
the display value changes from `100%` to `90%`
- **Level 5.4** — set background color input to `#ff0000` via input
event, assert the canvas container div has `background-color: rgb(255,
0, 0)`

**`src/components/painter/WidgetPainter.vue`**

- Added `data-testid="painter-canvas-container"` to the inner canvas
wrapper div
- Added `data-testid="painter-cursor"` to the brush cursor div
- Added `data-testid="painter-bg-color-row"` to the background color
control row
- Added `data-testid="painter-hardness-value"` to the hardness display
span (mirrors the existing `painter-size-value` pattern)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus a few
`data-testid` attributes to stabilize selectors, with no functional
logic changes to the painter behavior.
> 
> **Overview**
> Adds **7 new Playwright E2E tests** for the Painter widget, covering
minimum node sizing/hidden standard widgets, cursor
visibility/positioning behavior, brush hardness display updates,
color/opacity effects on rendered strokes, and background color
application.
> 
> Updates `WidgetPainter.vue` to add a handful of **`data-testid`
hooks** (canvas container, cursor, background color row, hardness value)
used by the new tests.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a6da0c3e39. 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-11551-test-E2E-coverage-for-painter-widget-Levels-1-5-34a6d73d36508154a90fd24ffb3adb5b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-23 23:13:52 -04:00
Kelly Yang
3737a32999 test: extend minimap e2e for level 1& level4 (#11192)
## Summary

Adds Playwright coverage for minimap **Level 1** gaps and **Level 4**
drag-to-pan behavior (`browser_tests/tests/minimap.spec.ts`).

## Changes

- [x] **Level 1.1** — After closing the minimap with the X button,
assert `Comfy.Minimap.Visible` is `false` (in addition to minimap hidden
and toolbar toggle still visible).
- [x] **Level 1.2** — New `@mobile` + `@canvas` suite: on a narrow
viewport (Pixel 5 project), assert default `Comfy.Minimap.Visible` is
`false`, minimap container is absent (`toHaveCount(0)`), and the toolbar
minimap toggle remains visible.
- [x] **Level 1.3** — New test: close via X, re-open via toolbar toggle;
assert minimap and viewport are visible and `Comfy.Minimap.Visible` is
`true`.
- [x] **Level 4.1** — Pointer drag on `minimap-interaction-overlay`
(`pointerdown` / `pointermove` / `pointerup`); use `expect.poll` on main
canvas offset so each move step updates pan; assert meaningful total pan
distance after drag.
- [x] **Level 4.2** — Same drag pattern; assert `.minimap-viewport`
`style.transform` updates mid-drag via `expect.poll` between move steps.
- [x] **Helpers** — `clientPointOnMinimapOverlay`,
`readMainCanvasOffset`, shared `MINIMAP_POINTER_OPTS` (no
`waitForTimeout`).

## Notes

- [x] Desktop minimap tests unchanged in intent; mobile-only cases live
in a separate `test.describe` so `beforeEach` does not force
`Comfy.Minimap.Visible` on small screens.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright e2e tests and shared test
utilities, with no production logic modifications. Main risk is
increased test flakiness due to pointer-event simulation and
timing/polling on canvas state.
> 
> **Overview**
> Expands minimap Playwright coverage by adding shared helpers
(`minimapUtils.ts`) for overlay-relative pointer coordinates, consistent
pointer event options, and reading the main canvas offset.
> 
> Updates `minimap.spec.ts` to assert the `Comfy.Minimap.Visible`
setting toggles correctly when closing/reopening, adds
FitView+minimap-center regression coverage, and replaces mouse-drag with
explicit `pointerdown`/`pointermove`/`pointerup` overlay interactions
that assert progressive canvas panning and viewport `transform` updates.
Adds a new `@mobile` suite verifying the minimap is hidden by default on
small viewports while the toolbar toggle remains visible.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7fad2a7dd3. 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-11192-test-extend-minimap-e2e-for-level-1-gaps-and-drag-panning-3416d73d36508194bc81f81a6d4536b8)
by [Unito](https://www.unito.io)
2026-04-23 22:17:16 -04:00
Alexander Brown
39e1877065 ci: filter e2e workflow on PRs to skip unrelated changes (#11568)
## Summary

Skip the main e2e test suite on PRs that only touch unrelated paths
(website, docs, storybook, markdown).

## Changes

- **What**: Replace the broad `paths-ignore: ['**/*.md']` on the
`pull_request` trigger with a more targeted `paths-ignore` list covering
`apps/**`, `docs/**`, `**/*.md`, and `.storybook/**`. The `push` (to
main), `merge_group`, and `workflow_dispatch` triggers remain
unconditional.

## Review Focus

- The `merge_group` trigger has no path filter, so the merge queue
always runs e2e as a safety net before merge.
- Using `paths-ignore` (denylist) rather than `paths` (allowlist) so new
top-level directories trigger e2e by default.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11568-ci-filter-e2e-workflow-on-PRs-to-skip-unrelated-changes-34b6d73d365081ea8603ef94bc86b6e6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 23:02:03 +00:00
Dr.Lt.Data
bd96bdf4cc fix(manager): migrate 4 endpoints GET→POST for CSRF hardening (#11520)
## Summary

Align `comfyManagerService` and Manager UI state with CSRF hardening in
[Comfy-Org/ComfyUI-Manager#2818](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2818)
(4.2.0, Content-Type gate + GET→POST migration) and
[Comfy-Org/ComfyUI-Manager#2823](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2823)
(4.2.1, `extension.manager.supports_csrf_post` feature flag).

## Changes

- **Service layer**: Convert 4 state-mutation endpoints (`START_QUEUE`,
`UPDATE_ALL`, `UPDATE_COMFYUI`, `REBOOT`) from GET to POST. `body=null`
+ axios default `Content-Type: application/json` is allowed by the
backend's `reject_simple_form_post` gate (only the three CORS
simple-form types are rejected).
- **UI/state layer**: Add `ManagerUIState.INCOMPATIBLE` triggered when
the backend advertises `supports_manager_v4` but not
`supports_csrf_post`. Manager UI is treated as "not installed" — buttons
hide via `shouldShowManagerButtons` with zero call-site changes across
`TopMenuSection`, `MissingNodeCard`, `MissingPackGroupRow`, `TabErrors`.
- **Graceful degraded mode**: One-shot upgrade toast (warn, 15s)
dispatched via `watch(immediate:true)` with a module-level guard that
survives multiple composable instances. `openManager()` re-emits on
explicit user action so stale shortcuts still surface guidance. i18n
(en/ko) covering Desktop / standalone pip / Manager UI self-update
paths.
- **Breaking**: None. Existing policies preserved (`--enable-manager`
absent → `DISABLED`; `--enable-manager-legacy-ui` → `LEGACY_UI`; feature
flags not yet loaded → `NEW_UI` transient fallback).

## Review Focus

- Decision-tree ordering in `useManagerState.ts`: `supports_csrf_post`
check evaluates before `NEW_UI`/`LEGACY_UI` branches so stale Manager
backends never reach the enabled paths.
- Toast guard: module-level `incompatibleToastShown` survives multiple
composable instances (tests verify 3× `useManagerState()` = 1 toast
call).
- `generatedManagerTypes.ts` still declares the 4 endpoints as GET;
regeneration follows once Manager 4.2.1 OpenAPI is published. Runtime is
unaffected since axios operates on the route string.

## References

-
[Comfy-Org/ComfyUI-Manager#2818](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2818)
— CSRF Content-Type gate + GET→POST migration (4.2.0)
-
[Comfy-Org/ComfyUI-Manager#2823](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2823)
— `supports_csrf_post` feature flag (4.2.1)
- [comfyui-manager 4.2.1 on
PyPI](https://pypi.org/project/comfyui-manager/4.2.1) — release package
2026-04-23 15:53:19 -07:00
Dante
559922eaa5 feat: add bug-dump-ingest skill (#11460)
## Summary

Adds a new Claude Code skill at `.claude/skills/bug-dump-ingest/` that
syncs the `#bug-dump` Slack channel into Linear as the system of record,
per discussion in [this
thread](https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1776510375473579).

- Primary mode is bulk sync — every ingestable top-level message becomes
a Linear issue in the Frontend Engineering team's Triage state with
labels for area / env / severity / reporter.
- Marks handled messages via the team emoji scheme:
  - `` — ticket created
  - `:pr-open:` — fix PR open
  - `` — needs more context
  - `🔁` — duplicate
- Since the Slack reactions API isn't exposed to the skill, the
machine-readable marker is a thread reply carrying the Linear URL; the
human is prompted to add the visible parent reaction from a batch list
printed at session end.
- Secondary opt-in per-row mode delegates to `red-green-fix` to author a
failing unit + e2e test, then a minimal fix, then a PR.

## Files

- `SKILL.md` — entry point: workflow, classification, verification,
approval flow, Linear integration (MCP / GraphQL / draft fallback), fix
workflow
- `reference/linear-api.md` — GraphQL snippets for teams / states /
labels / issues
- `reference/schema.md` — field-by-field extraction rules
- `reference/examples.md` — seven worked examples from real recent
`#bug-dump` messages
- `reference/verify-commands.md` — cookbook of false-defect verification
commands

## Linear MCP setup

Two supported paths documented in `SKILL.md`:

- Option A: official hosted Linear MCP at `https://mcp.linear.app/sse`
via `claude mcp add`, OAuth-based, no API key.
- Option B: community self-hosted MCP with `LINEAR_API_KEY` in env.

## Test plan

- [ ] Restart Claude Code session after merging so the Linear MCP tools
register
- [ ] Authorize Linear OAuth on first tool call
- [ ] Dry-run the skill against a 48h window of `#bug-dump`, confirm the
approval table renders with all 9 columns
- [ ] File one real ticket end-to-end; verify labels, Triage state,
Slack thread reply, and permalink attachment
- [ ] Add a `` reaction on that parent; re-run and
confirm the message is skipped

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11460-feat-add-bug-dump-ingest-skill-3486d73d3650810094aee3e4ee79eb86)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-24 07:20:35 +09:00
Christian Byrne
4b7a027946 fix: route progress_text feature flag check through getDevOverride (#11384)
## Summary

Route the `progress_text` binary parser's feature-flag check through
`serverSupportsFeature()` so dev overrides via `localStorage` take
effect.

## Changes

- **What**: Replace
`this.getClientFeatureFlags()?.supports_progress_text_metadata` with
`this.serverSupportsFeature('supports_progress_text_metadata')` in the
`case 3` binary message handler, consistent with all other feature-flag
checks in the class.

## Review Focus

Minimal one-line change. The key consideration is that
`serverSupportsFeature()` routes through `getDevOverride()` first,
enabling `localStorage` overrides (`ff:supports_progress_text_metadata`)
for dev testing of the binary wire format.

Fixes #11187

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11384-fix-route-progress_text-feature-flag-check-through-getDevOverride-3476d73d36508161bca0d6c2ea7c3c55)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 15:04:18 -07:00
Christian Byrne
cc1338e51e perf: exclude canvas nodes from PostHog session recording (#10494)
## Summary

Add `ph-no-capture` class to TransformPane to block PostHog session
recording of canvas node DOM mutations, eliminating 226ms CPU overhead
in the viewport scenario.

## Changes

- **What**: Add `ph-no-capture` CSS class to the TransformPane root div
and a unit test guarding it
- **Why**: Profiling showed PostHog session recording (via rrweb
mutation observer) consuming **226ms CPU** in the viewport scenario —
**9× more** than the entire Vue reactivity system (25ms). The 150+
LGraphNode Vue components that mount/unmount during pan/zoom each
trigger rrweb to snapshot new DOM subtrees.

### How it works

PostHog uses rrweb under the hood. The `ph-no-capture` class maps to
rrweb's `blockClass`, which causes:
1. `processMutation` to **early-return** for `childList` mutations when
the parent is blocked
2. `genAdds` to **skip child traversal** (`dom.childNodes()`) for
blocked elements
3. The element to be replaced with a **same-size placeholder** in replay

This means all 150+ node components inside TransformPane produce **zero
mutation processing cost**.

### Scope

- **TransformPane**: blocked — wraps all Vue-rendered graph nodes
- **LinkOverlayCanvas**: evaluated but not blocked — contains only a
single `<canvas>` element with no DOM children, so no mutation overhead
- **All other UI** (sidebar, menus, dialogs, toolbar, bottom panel):
continues recording normally

### Trade-off

The graph canvas area appears as a blank placeholder in session replays.
This is acceptable because canvas interaction is better captured via
workflow JSON, and the performance gain far outweighs the replay
fidelity loss.

## Review Focus

- Correctness of the `ph-no-capture` placement on TransformPane as the
optimal DOM boundary
- Whether any other high-mutation DOM subtrees should also be blocked

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10494-perf-exclude-canvas-nodes-from-PostHog-session-recording-32e6d73d36508169ab07f1b193860fb0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-04-23 15:03:41 -07:00
pythongosssss
0052cdadd4 test: e2e coverage for node templates (#11564)
## Summary

Add E2E tests for node templates

## Changes

- **What**: 
- add tests for save, insert, delete, import export
- vue and litegraph
- add testid to dialog
- update `clickLitegraphMenuItem` to enable clicking children with the
same name as parent

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11564-test-e2e-coverage-for-node-templates-34b6d73d365081a39ce5c713f05a2a92)
by [Unito](https://www.unito.io)
2026-04-23 19:33:18 +00:00
Christian Byrne
aa40dd8a65 [chore] Update Comfy Registry API types from comfy-api@315429a (#11566)
## Automated API Type Update

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

- API commit: 315429a
- Generated on: 2026-04-22T21:35:59Z

These types are automatically generated using openapi-typescript.

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

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-23 18:21:03 +00:00
Alexander Brown
68798d8e37 feat(website): website mise en place (#11552)
## Summary

Assorted website copy and content refinements — tidying up loose ends
across the site.

## Changes

- **What**: Remove placeholder doc links from custom nodes feature
description on pricing page

## Review Focus

Low-risk copy changes only; no logic or layout modifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11552-feat-website-website-mise-en-place-34b6d73d3650813b954afbc965e4dc74)
by [Unito](https://www.unito.io)

> **Note:** The `PR: Vercel Website Preview` workflow is
`workflow_run`-triggered, so it always runs the **main branch version**
of the workflow file. Until this PR is merged, the preview workflow will
continue posting standalone comments using the old `<!--
VERCEL_WEBSITE_PREVIEW -->` marker instead of writing to the
consolidated `<!-- WEBSITE_CI_REPORT -->` comment. This is expected and
resolves itself on merge.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-04-23 17:26:06 +00:00
pythongosssss
a6c3ff1a54 fix: load3d used wrong i18n key, add test (#11546)
## Summary

Toast for Load3D initialization failure was using the wrong key and so
showed an untranslated key to the user.

## Changes

- **What**: 
- Update to use correct existing key
- Add test that forces init failure

## Screenshots (if applicable)
Fixed
<img width="482" height="121" alt="image"
src="https://github.com/user-attachments/assets/f89eef99-c1a6-463a-a711-7e9c16d0e89a"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11546-fix-load3d-used-wrong-i18n-key-add-test-34a6d73d36508159aab9f042d3e9c4f0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 11:52:42 -04:00
Dante
9599a4e00a fix cloud frontend runtime guard regressions (#11180)
## Summary
- harden cloud frontend runtime paths that were throwing on
`cloud.comfy.org`
- guard widget value propagation when the source widget is missing
- treat nullish executed outputs as empty output during flatten/parsing
- ignore stale autogrow disconnect callbacks after an autogrow group is
removed

## Root cause
This PR bundles three small runtime guard fixes from
`cloud-frontend-staging` issues that reproduce on
`https://cloud.comfy.org/`:

- `CLOUD-FRONTEND-STAGING-429`: widget propagation assumed
`this.widgets[0]` always existed and crashed during group-node/widget
lifecycle transitions
- `CLOUD-FRONTEND-STAGING-3QA` and sibling `3QB`: executed-event parsing
assumed `detail.output` was always an object and crashed on nullish
output payloads
- `CLOUD-FRONTEND-STAGING-42B`: `autogrowInputDisconnected()` could run
from a stale `requestAnimationFrame()` callback after its autogrow group
had already been removed

## User impact
- prevents unhandled frontend exceptions on `cloud.comfy.org`
- keeps node output rendering and linear-mode flattening resilient to
sparse executed payloads
- avoids autogrow disconnect crashes during graph/widget churn

## Changes
- extracted shared widget propagation logic into
`widgetValuePropagation.ts`
- added source-widget guards in custom widget / primitive widget
propagation paths
- added null guards in result parsing and linear-mode output flattening
- added an autogrow-group existence guard in `dynamicWidgets.ts`
- added focused regression tests for all three bug shapes

## Red / Green Verification
### Red
I ran the new targeted regression suite in a temporary pre-fix worktree
with the runtime guards reverted while keeping the new tests.

Failing tests in that state:
- `src/extensions/core/widgetValuePropagation.test.ts`
  - `returns early when the source widget is missing`
- `src/stores/resultItemParsing.test.ts`
  - `returns empty array for nullish node output`
  - `ignores nullish node outputs`
- `src/renderer/extensions/linearMode/flattenNodeOutput.test.ts`
  - `returns empty array for nullish node output`

Representative pre-fix errors:
- `TypeError: Cannot read properties of undefined (reading 'value')`
- `TypeError: Cannot convert undefined or null to object`

### Green
On the draft PR branch, the targeted regression suite passes:
- `pnpm exec vitest run src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/widgetValuePropagation.test.ts
src/extensions/core/customWidgets.test.ts`

Result:
- `5` test files passed
- `49` tests passed

## Validation
- `pnpm exec vitest run src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/widgetValuePropagation.test.ts
src/extensions/core/customWidgets.test.ts`
- `pnpm exec eslint --no-ignore src/core/graph/widgets/dynamicWidgets.ts
src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.ts src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/customWidgets.ts src/extensions/core/widgetInputs.ts
src/extensions/core/widgetValuePropagation.ts
src/extensions/core/widgetValuePropagation.test.ts`
- `pnpm exec oxfmt --check src/core/graph/widgets/dynamicWidgets.ts
src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.ts src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/customWidgets.ts src/extensions/core/widgetInputs.ts
src/extensions/core/widgetValuePropagation.ts
src/extensions/core/widgetValuePropagation.test.ts`

## Notes
- I explicitly skipped the `getCanvas: canvas is null` issue because it
is already covered by open PRs `#11173` / `#11174`.
- `pnpm typecheck` was not included in validation because the temporary
PR worktree used for publication hits local path-resolution issues
through the shared dependency install, which is unrelated to the changes
in this PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11180-codex-fix-cloud-frontend-runtime-guard-regressions-3416d73d365081e0af6ec612c9d0d8aa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 12:02:46 +00:00
pythongosssss
cc73baaf57 test: Test subgraph breadcrumbs (#11472)
## Summary

Add test coverage to subgraph breadcrumbs

## Changes

- **What**: 
- Add subgraph breadcrumb helpers
- Add tests for entering/navigating/menu/collapsing

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11472-test-Test-subgraph-breadcrumbs-3486d73d365081e1a75bf57404eaa63b)
by [Unito](https://www.unito.io)
2026-04-23 01:49:29 -07:00
Dante
700fcb6bda test: add unit tests for FormDropdownMenuActions (#11443)
## Summary

Adds 14 unit tests for \`FormDropdownMenuActions\` — isolated into its
own PR because the component is denser (three PrimeVue Popovers,
multiple filter models) than the sibling components in the form-dropdown
PR. Part of a widget-test-coverage sequence.

## Changes

- **What**: \`FormDropdownMenuActions.test.ts\` — search v-model, sort
popover options + selection, ownership popover visibility gated by
\`showOwnershipFilter\` + options present, base-model multi-select
toggle (add/remove/multiple), Clear Filters, list/grid layout-mode
v-model.

## Review Focus

- PrimeVue \`Popover\` stubbed as an always-slotted \`<div>\` with
\`toggle\`/\`hide\` methods on the Options-API \`methods\` (stub
\`expose\` did not satisfy template-ref access).
- Sort/ownership/base-model option discovery uses
\`within(popover-body)\` to disambiguate buttons across the three
popovers.
- Layout-switch locator uses an \`.icon-*\` class probe since the button
has no accessible name; covered by an \`eslint-disable-next-line
testing-library/no-node-access\`.
- No changes to the component source.

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 08:00:54 +00:00
Dante
30c07dc9ec fix: cancel-subscription dialog renders Invalid Date for ISO fractional seconds (#11539)
## Summary

`CancelSubscriptionDialogContent` calls `new Date(dateStr)` directly on
both `cancelAt` and `subscription.value?.endDate`. Strict ISO 8601
parsers (Safari and some WebViews) reject fractional seconds whose
length is anything other than 3 digits, so a Go-style backend
timestamp such as `2026-04-18T10:04:55.6513Z` rendered as
`Your access continues until Invalid Date.` in a destructive billing
flow.

PR #11358 already added the tolerant `parseIsoDateSafe` helper and
applied it to the Secrets panel. This PR closes the same gap in the
cancellation dialog and adds regression coverage that exercises the
strict-parser code path (V8 alone is too lenient to fail without it).

## Changes

- `CancelSubscriptionDialogContent.vue` — pipe both date sources through
  `parseIsoDateSafe`; collapse the two-step null check into one. When
  the value is missing OR unparseable, fall back to the existing
  `subscription.cancelDialog.endOfBillingPeriod` translation instead of
  emitting `Invalid Date`.
- `CancelSubscriptionDialogContent.test.ts` (new) — wraps the assertions
  in the same `withStrictMillisecondParser` shim used by
  `dateTimeUtil.test.ts`, so 1-, 4-, and 9-digit fractional inputs
  actually exercise the broken path. Also covers the missing/unparseable
  fallbacks and the `cancelAt`-takes-precedence ordering.

## Red-green proof (local)

Confirmed locally before splitting the commits:

| Commit | `pnpm exec vitest run
…CancelSubscriptionDialogContent.test.ts` |
|---|---|
| `c8aecd07f test: regression cover …` (test-only) | 5 failed / 1 passed
— DOM rendered `"Your access continues until Invalid Date."` |
| `f24cb903f fix: parse cancel-subscription dialog ISO timestamps …` | 6
passed |

CI on this branch only runs on PR HEAD; happy to force-push a transient
red commit if you want a recorded red CI run alongside the green one.

## Test plan

- [x] `pnpm exec vitest run
src/components/dialog/content/subscription/CancelSubscriptionDialogContent.test.ts`
(6 passing on green)
- [x] `pnpm typecheck`
- [x] `pnpm exec eslint
src/components/dialog/content/subscription/CancelSubscriptionDialogContent.{vue,test.ts}`
- [x] `pnpm exec oxfmt --check` on changed files
- [ ] Manual repro on Safari with a Go-emitted cancel timestamp (out of
scope here; the unit test asserts the equivalent strict-parser behavior)

## Origin

Surfaced by `/codex:adversarial-review` as the gap left after PR #11358
(Secrets panel) — the same `new Date(...)` hazard survived in a
destructive billing flow with no regression coverage.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11539-fix-cancel-subscription-dialog-renders-Invalid-Date-for-ISO-fractional-seconds-34a6d73d365081e4bdd6c941afd8cef3)
by [Unito](https://www.unito.io)
2026-04-23 03:30:31 +00:00
Comfy Org PR Bot
d81f3a9278 1.44.8 (#11514)
Patch version increment to 1.44.8

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-22 20:28:18 -07:00
Dante
bab2870131 test: harden strict parser coverage for long ISO fractions (#11529)
## Summary

- run every `parseIsoDateSafe` case with more than 3 fractional-second
digits through `withStrictMillisecondParser`
- assert the normalized 3-digit timestamp string passed into `Date` for
each long-fraction variant
- keep the follow-up scoped to test coverage only

## Root cause

V8 already accepts and truncates ISO timestamps with more than 3
fractional-second digits, so the existing tests could stay green even if
`parseIsoDateSafe` failed to normalize those values before constructing
`Date`. Wrapping the long-fraction cases in the strict parser shim makes
CI exercise the Safari/WebView-sensitive path the feature is meant to
protect.

## Testing

- `pnpm exec vitest run src/utils/dateTimeUtil.test.ts`
- `pnpm exec eslint src/utils/dateTimeUtil.test.ts`

Fixes #11528

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11529-codex-test-harden-strict-parser-coverage-for-long-ISO-fractions-34a6d73d365081119577eb5fb6d4992c)
by [Unito](https://www.unito.io)
2026-04-23 03:05:03 +00:00
Christian Byrne
e5cb244a2d test: add E2E tests for keyboard shortcut actions (#11210)
## Summary

Add Playwright E2E tests covering core keyboard shortcut actions.

## Changes

- **What**: 7 new E2E tests in
`browser_tests/tests/keyboardShortcutActions.spec.ts` covering:
  - Ctrl+Z undoes the last graph change
  - Ctrl+Shift+Z redoes after undo
  - Ctrl+S opens save dialog
  - Ctrl+, opens settings dialog
  - Escape closes settings dialog
  - Delete key removes selected nodes
  - Ctrl+A selects all nodes

## Review Focus

- Tests use `expect.poll()` for async graph state assertions
- `@keyboard` tag for selective test runs
- Uses `comfyPage.nodeOps` and `comfyPage.menu.topbar` helpers

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11210-test-add-E2E-tests-for-keyboard-shortcut-actions-3416d73d3650812eafd5e789cf8463a6)
by [Unito](https://www.unito.io)
2026-04-23 02:43:57 +00:00
Christian Byrne
c5b6fd9c40 test: clarify inert getClientFeatureFlags mock in progress_text binary parsing tests (#11385)
## Summary

Adds inline comments to three
`vi.mocked(api.getClientFeatureFlags).mockReturnValue()` calls in the
`progress_text binary message parsing` describe block, clarifying they
are intentionally inert — the parser checks `serverFeatureFlags` only.

This prevents future readers from being confused about whether the mock
has any effect.

- Fixes #11186

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11385-test-clarify-inert-getClientFeatureFlags-mock-in-progress_text-binary-parsing-tests-3476d73d365081a98c06c43c4737fdd9)
by [Unito](https://www.unito.io)
2026-04-23 02:43:17 +00:00
Christian Byrne
0f0210e482 test: add unit tests for workspaceApi (#11393)
## Summary
Adds 27 unit tests for `src/platform/workspace/api/workspaceApi.ts`,
increasing line coverage from 2.9% to 83.2%.

## Test Coverage
- Authentication: auth header null checks (workspace auth + firebase
auth)
- Error handling: axios error wrapping, fallback messages, non-axios
rethrow
- Workspace CRUD: list, create, update, delete, leave
- Member management: listMembers, removeMember
- Invite management: listInvites, createInvite, revokeInvite,
acceptInvite
- Billing: getBillingStatus, getBillingBalance, getBillingPlans
- Subscription: previewSubscribe, subscribe, cancelSubscription,
resubscribe
- Payment: getPaymentPortalUrl, createTopup
- Billing events: getBillingEvents, getBillingOpStatus

## Testing
```bash
pnpm vitest run src/platform/workspace/api/workspaceApi.test.ts
# 27 tests pass
```

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 02:43:10 +00:00
Christian Byrne
7d1d7c8315 fix: remove deleted workflow from search results in sidebar (#11425)
*PR Created by the Glary-Bot Agent*

---

## Summary

Deleting a workflow in the sidebar while search is active left the
deleted workflow visible in the search results. Interacting with the
stale entry (e.g. duplicate) caused undefined behavior.

## Root Cause

`filteredWorkflows` in `BaseWorkflowsSidebarTab.vue` was a `ref`
populated once per search event — a static snapshot that never reacted
to store mutations. When `workflowStore.deleteWorkflow()` removed a
workflow from `workflowLookup`, the search-panel's `filteredWorkflows`
still held a stale reference.

## Fix

Convert `filteredWorkflows` from a `ref` to a `computed` that reactively
derives from `searchQuery` and `workflowStore.workflows`. This follows
the same pattern already used by `filteredPersistedWorkflows` and
`filteredBookmarkedWorkflows` in the same component. `handleSearch` is
simplified to only manage tree expansion (its only remaining side
effect).

## Test Plan

Three e2e regression tests added to `workflowSearch.spec.ts`:
- **Delete during search removes from results** — deletes a workflow
while search is active, asserts it disappears
- **Delete during search preserves siblings** — asserts all other
matched workflows remain visible after one is deleted
- **Clear search after delete shows correct browse view** — deletes
during search, clears search, verifies browse view is consistent

## Screenshots

![Search active with 3 workflows
visible](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3a9cc4daaaf582a87638fc62ec96258cea63981a28bcb282dd625956286c582c/pr-images/1776596412591-c8cc80fb-e57e-4c76-b574-8ab776076105.png)

![After deleting test-alpha during search — removed from results
correctly](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3a9cc4daaaf582a87638fc62ec96258cea63981a28bcb282dd625956286c582c/pr-images/1776596412913-23eebf62-aac8-4848-8468-8ecb56c0dc8f.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11425-fix-remove-deleted-workflow-from-search-results-in-sidebar-3476d73d365081f19ef6c6b9261a1ee9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-23 02:42:21 +00:00
Christian Byrne
739d4b6136 fix: move template distribution filter from v-show to data pipeline (#11418)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Moves distribution-based template filtering from a CSS-level `v-show`
gate into the `useTemplateFiltering` composable's data pipeline,
guaranteeing that templates not meant for the current distribution never
reach the view layer
- Fixes "Showing 19 of 419" count mismatch when only 2 templates are
visible on Cloud with "Wan 2.2" filter active
- Derives `availableModels` and `availableUseCases` from
distribution-visible templates so filter dropdowns don't show options
that only exist on other distributions
- Always prunes `activeModels`/`activeUseCases` against available
options to prevent stale persisted selections from causing zero-result
filtering

## Root Cause

The template selector dialog used
`v-show="isTemplateVisibleOnDistribution(template)"` to hide templates
that don't match the current distribution (cloud/desktop/local). But
`filteredCount` and `totalCount` were computed upstream in the pipeline
before this visual filter, so the count text showed all matching
templates regardless of distribution visibility.

## Changes

- **`useTemplateFiltering.ts`**: Added `visibleTemplates` computed that
applies distribution filter at the top of the pipeline. All downstream
computeds (`fuse`, `availableModels`, `availableUseCases`,
`filteredBySearch`, counts) now operate on this distribution-filtered
set. `activeModels`/`activeUseCases` always prune against available
options.
- **`WorkflowTemplateSelectorDialog.vue`**: Passes `distributions` ref
to composable, removes `v-show` gate and
`isTemplateVisibleOnDistribution` function.
- **`useTemplateFiltering.test.ts`**: 10 new unit tests covering
distribution filtering, filter composition (search + model + use case +
runsOn), stale persisted selections, multi-distribution templates, and
Mac distribution.
- **`templateFilteringCount.spec.ts`**: 5 new `@cloud` e2e tests
verifying count/card consistency, DOM leak prevention, and filter reset
behavior with mocked template data.

## Verification

- 22 unit tests passing (12 existing + 10 new)
- `pnpm typecheck` clean
- `pnpm typecheck:browser` clean
- `oxlint` + `eslint` clean on all changed files
- E2E tests tagged `@cloud` — designed for CI cloud build execution

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11418-fix-move-template-distribution-filter-from-v-show-to-data-pipeline-3476d73d365081c3ba09fc8a42eb4c9b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-23 02:42:04 +00:00
Christian Byrne
0d5f8161f3 test: improve promotedWidgetView coverage to 100% (+9.2%) (#11403)
## Summary

Adds 11 unit tests covering all previously uncovered branches in
`src/core/graph/subgraph/promotedWidgetView.ts`, bringing line coverage
from 90.8% to 100.0%.

## Coverage Delta

| File | Before | After | Delta | Missed |
|------|--------|-------|-------|--------|
| promotedWidgetView.ts | 90.8% | 100.0% | +9.2% | 0 |

## What is covered

- `tooltip` getter
- `hidden` getter
- `label` setter
- `isWidgetValue` — number, boolean, and object branches
- `onPointerDown` — interior handler, concrete widget, and fallthrough
paths
- `bindConcretePointerHandlers` — onClick and onDrag lambdas
- `getProjectedWidget` — null concrete widget reset
- `resolveDeepest` — frame cache path
- `draw` — unknown widget type branch

## Testing

```bash
npx vitest run src/core/graph/subgraph/promotedWidgetView.test.ts
```

75/75 tests pass. All quality gates verified: `pnpm typecheck`, `pnpm
lint`, `pnpm format:check`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11403-test-improve-promotedWidgetView-coverage-to-100-9-2-3476d73d3650818a9558e70de3a92432)
by [Unito](https://www.unito.io)
2026-04-23 02:41:40 +00:00
Christian Byrne
7fc0c357da test: cover error branch in useWorkflowThumbnail (+6.1%) (#11404)
## Summary

Cover the remaining uncovered error-handling branch in
`useWorkflowThumbnail`, bringing unit test coverage from 93.9% to 100%.

## Changes

- **What**: Add 2 unit tests for `createMinimapPreview` error path and
`storeThumbnail` null-thumbnail branch

## Review Focus

Tests verify that when `createGraphThumbnail` throws,
`createMinimapPreview` returns `null` and `storeThumbnail` does not
persist anything.

## Coverage Delta

| File | Before | After | Delta | Missed |
|------|--------|-------|-------|--------|
| `useWorkflowThumbnail.ts` | 93.9% | 100.0% | 🟢 +6.1% | 0 |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11404-test-cover-error-branch-in-useWorkflowThumbnail-6-1-3476d73d36508148a135ee29f7abf22e)
by [Unito](https://www.unito.io)
2026-04-22 19:47:13 -07:00
Christian Byrne
25ff147176 test: add unit tests for CanvasPathRenderer (#11387)
## Summary

Add 51 unit tests for `CanvasPathRenderer`, improving line coverage from
23.2% to 84.19% (100% function coverage).

## Changes

- **What**: New test file
`src/renderer/core/canvas/pathRenderer.test.ts` covering color
determination, border rendering, linear/straight/spline path modes,
`findPointOnBezier`, center point calculation, arrows, flow animation,
center markers, disabled patterns, and `drawDraggingLink`.

## Review Focus

Pure test addition — no production code changes. Tests mock `Path2D` via
`vi.stubGlobal` and `CanvasRenderingContext2D` via a plain object with
`vi.fn()` methods.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11387-test-add-unit-tests-for-CanvasPathRenderer-3476d73d365081bbb526d584cc41b723)
by [Unito](https://www.unito.io)
2026-04-22 19:46:57 -07:00
Christian Byrne
0efc0c4d72 test: exclude legacy UI component library from e2e coverage (#11377)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Excludes `src/scripts/ui/**` (legacy DOM component library) from
Playwright e2e coverage reports — this code is kept solely for extension
backwards-compatibility and shouldn't count toward coverage metrics
- Extracts Monocart coverage config (`outputDir`, `sourceFilter`) into
`browser_tests/coverageConfig.ts` so coverage exclusions are
discoverable and centralized instead of buried in `globalTeardown.ts`

## Details

Monocart does support external config files (`mcr.config.ts`
auto-discovery), but since MCR is instantiated in two places with
different configs (per-worker collection in `ComfyPage.ts` vs final
report in `globalTeardown.ts`), auto-discovery would affect both
instances. A shared TypeScript constant is safer and more explicit.

## Changes
- **New**: `browser_tests/coverageConfig.ts` — shared
`COVERAGE_OUTPUT_DIR` and `coverageSourceFilter`
- **Modified**: `browser_tests/globalTeardown.ts` — imports from shared
config
- **Modified**: `browser_tests/fixtures/ComfyPage.ts` — imports
`COVERAGE_OUTPUT_DIR`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11377-test-exclude-legacy-UI-component-library-from-e2e-coverage-3466d73d365081b78dc9e4e14d913295)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-22 19:46:34 -07:00
Christian Byrne
d0e3b7ebf0 fix: handle EPERM/EBUSY in global teardown restorePath (#11013)
Fixes #11009

## Summary

On Windows, Chromium may still hold file handles on the user-data
directory when global teardown runs `restorePath`. The `fs.moveSync(...,
{ overwrite: true })` call fails with EPERM because it can't remove the
target while handles are held.

## Changes

- Split `restorePath` into explicit remove-then-move
- Added `removeWithRetry` that retries up to 3× on EPERM/EBUSY with
500ms delay between attempts
- Downgraded the catch from `console.error` (which looks like a test
failure) to `console.warn` so teardown noise doesn't mask real failures

No E2E regression test added: this is a test-infrastructure fix for a
Windows-specific race condition in teardown that cannot be reliably
reproduced in CI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11013-fix-handle-EPERM-EBUSY-in-global-teardown-restorePath-33e6d73d3650815ebe0cd42af23e6c0e)
by [Unito](https://www.unito.io)
2026-04-22 19:44:43 -07:00
Christian Byrne
f2677aa598 fix: include actual slot index in InputSlot/OutputSlot keys to prevent stale indices after autogrow (#11423)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes auto-grow input slot connections breaking in Nodes 2.0 when a node
has multiple auto-grow groups (e.g., `Wan 2.7 Reference to Video` with
both image and video auto-grow inputs).

## Problem

When auto-grow adds inputs to one group, it splices new entries into
`node.inputs`, shifting the indices of all subsequent groups. The data
layer handles this correctly via `spliceInputs()`, but Nodes 2.0 Vue
components retained stale slot indices because:

1. **`NodeSlots.vue`** keyed `InputSlot`/`OutputSlot` by name only — Vue
reused components without remounting when indices shifted
2. **`useSlotElementTracking`** registered `data-slot-key` once at mount
and stopped its watcher — stale keys persisted in the DOM
3. **`useSlotLinkInteraction`** captured `index` in closures at mount —
stale closures targeted wrong slots

This caused connections to land on wrong inputs, incorrect hover
indicators, and some slot types becoming unreachable.

## Fix

Include the actual slot index in the component key for `InputSlot` (both
in `NodeSlots.vue` and `NodeWidgets.vue`) and `OutputSlot`. When
autogrow shifts a slot's position or an output is removed, the key
changes, forcing Vue to remount — which re-registers `data-slot-key` and
refreshes all interaction closures with the correct index.

## Testing

- **Remount verification**: Tests use setup() invocation counting to
prove components are actually remounted (not just prop-patched) when
indices shift — directly validating that `useSlotElementTracking` and
`useSlotLinkInteraction` are re-initialized
- **Multi-group autogrow**: Verifies data-layer index correctness when
first group growth shifts second group
- **Output removal**: Verifies OutputSlot remount when earlier output
removal shifts later output indices
- All existing tests pass, lint/typecheck/format clean

## Screenshots

![ComfyUI frontend loads correctly with Nodes 2.0 after the
fix](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/ed95fd14fd7d0e7797f7fa7a2737ed737829303e39a30afe453eb05e19218a2d/pr-images/1776594420504-a5c1b967-626d-4463-b1e3-ae800535c57b.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11423-fix-include-actual-slot-index-in-InputSlot-OutputSlot-keys-to-prevent-stale-indices-aft-3476d73d365081859da6c450a840a625)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-22 19:42:45 -07:00
AustinMroz
fc9a1d6bfb Add audio/video preview tests (#11523)
Adds tests for the vue audio preview widget and vue video previews
(which are not widgets).

Also
- Fixes a bug where muted audio previews would incorrectly display a
'low volume' indicator instead of a muted indicator.
- Add test helper for deleting uploaded files after a test completes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11523-Add-audio-preview-tests-3496d73d365081be8630ede6dae1726a)
by [Unito](https://www.unito.io)
2026-04-23 02:14:48 +00:00
Yourz
bbb043c9cc feat(website): Polish and fix UI (#11363)
## Summary

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

Polish and fix UI for new website

## Changes

- **What**: <!-- Core functionality added/modified -->
  - [x] update about video
  - [x] update Moment factory story content
  - [x] update homepage visual
  - [x] update customer story visual
  - [x] put images and videos to bucket

## Review Focus

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

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

## Screenshots (if applicable)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11363-feat-website-Polish-and-fix-UI-3466d73d365081f895aff84b594450c9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-22 18:45:27 -07:00
Christian Byrne
ef59f46495 refactor: migrate cn imports from @/utils/tailwindUtil shim to @comfyorg/tailwind-utils directly (#11453)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Replace all `cn` / `ClassValue` imports from the
`@/utils/tailwindUtil` re-export shim with direct imports from
`@comfyorg/tailwind-utils` across 198 source files in `src/` and 3 in
`apps/desktop-ui/`
- Delete both shim files (`src/utils/tailwindUtil.ts` and
`apps/desktop-ui/src/utils/tailwindUtil.ts`)
- Add explicit `@comfyorg/tailwind-utils` dependency to
`apps/desktop-ui/package.json`
- Update documentation references in `AGENTS.md`,
`docs/guidance/design-standards.md`, and
`docs/guidance/vue-components.md`

Fixes #11288

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11453-refactor-migrate-cn-imports-from-utils-tailwindUtil-shim-to-comfyorg-tailwind-utils--3486d73d365081ec92cce91fbf88e6e4)
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>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-22 18:39:57 -07:00
Christian Byrne
8e7e4d6faa [chore] Update Comfy Registry API types from comfy-api@36463e1 (#11550)
## Automated API Type Update

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

- API commit: 36463e1
- Generated on: 2026-04-22T18:49:09Z

These types are automatically generated using openapi-typescript.

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

---------

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-23 01:23:45 +00:00
pythongosssss
975d5e5ec0 fix: add GLSL live update when custom size is changed (#11517)
## Summary

Changing the custom size mode width & height were not reactive and so
did not live update on the FE

## Changes

- **What**: 
- change `customResolution` to be computed, changing width/height then
triggers a live update
- add tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11517-fix-add-GLSL-live-update-when-custom-size-is-changed-3496d73d3650816e940bf821f4e18db5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-22 20:05:15 -04:00
pythongosssss
9522f68ae6 test: additional load3d e2e coverage (#11521)
## Summary

Expands the e2e coverage for the load3d node

## Changes

- **What**: 
- test recording, grid and background upload

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11521-test-additional-load3d-e2e-coverage-3496d73d3650814595e1eabe97448993)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-22 20:03:37 -04:00
Dante
ecf905c6b6 test: add unit tests for media widgets (chart, record audio) (#11444)
## Summary

Adds 20 unit tests across 2 files covering WidgetChart and
WidgetRecordAudio. Part of a widget-test-coverage sequence.

## Changes

- **What**:
- \`WidgetChart.test.ts\` (6) — default type 'line', honours
\`widget.options.type\`, passes model value through to the PrimeVue
Chart stub, empty-object fallback for labels/datasets, aria-label
includes widget name + type.
- \`WidgetRecordAudio.test.ts\` (14) — idle state renders Start
Recording button and disables it on \`readonly\`; recording state shows
"Listening..." and a stop button wired to \`recorder.stopRecording\`;
ready state shows Play button; playing state shows Stop-playback wired
to \`playback.stop\`.

## Review Focus

- \`WidgetRecordAudio\` mocks \`useAudioRecorder\` /
\`useAudioPlayback\` / \`useAudioWaveform\` at their module boundary
(follows "don't mock what you don't own" — MediaRecorder is behind those
composables).
- \`useAudioRecorder\` already has its own composable-level test; this
PR tests the orchestration only.
- \`WidgetLegacy\` is intentionally NOT covered here — 100+ LoC of
litegraph/canvas integration, already covered by e2e \`widget.spec.ts\`.
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11444-test-add-unit-tests-for-media-widgets-chart-record-audio-3486d73d365081c5b438e104d0e3b0df)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-22 20:58:57 +00:00
Dante
64dd3d7557 test: add e2e specs for float and combo Vue widgets (#11447)
## Summary

Adds two Playwright specs extending
\`browser_tests/tests/vueNodes/widgets/\` to cover float and combo value
types, following the existing \`integerWidget.spec.ts\` /
\`multilineStringWidget.spec.ts\` pattern. Part of a
widget-test-coverage sequence.

## Changes

- **What**:
- \`browser_tests/tests/vueNodes/widgets/float/floatWidget.spec.ts\` (3)
— number-input value change, increment/decrement on \`denoise\`, and
persistence through litegraph widget state after user edit.
- \`browser_tests/tests/vueNodes/widgets/combo/comboWidget.spec.ts\` (3)
— dropdown lists known sampler options, combo value updates on select,
\`scheduler\` value persists.

Reuses the existing \`vueNodes/linked-int-widget.json\` fixture
(KSampler exposes \`cfg\` / \`denoise\` floats and \`sampler_name\` /
\`scheduler\` combos). No new fixture files.

## Review Focus

- Specs tagged \`@vue-nodes\`, consistent with the sibling suites.
- Persistence assertions read widget state via
\`window.graph._nodes_by_id[...].widgets\` (typed through
\`TestGraphAccess\` from \`@e2e/types/globals\`) rather than
JSON-serializing the whole graph — avoids \`unknown\` typing on
\`window.graph.serialize()\`.
- Boolean and color e2e specs are intentionally NOT in this PR — they'd
need new workflow fixtures, which I'd prefer to design with you before
writing.
- \`pnpm typecheck:browser\` is clean locally; CI run needed to validate
the Playwright behaviour since I couldn't run the full e2e suite
locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11447-test-add-e2e-specs-for-float-and-combo-Vue-widgets-3486d73d365081f79302edc87595130c)
by [Unito](https://www.unito.io)
2026-04-22 20:58:26 +00:00
guill
a9efd4de62 fix: render edit pencil icon correctly in properties panel header (#11487)
*PR Created by the Glary-Bot Agent*

---

## Summary

The edit pencil button next to the selected node's name at the top of
the properties panel (rightSidePanel) rendered as a dark filled square
instead of a pencil icon.

## Root cause

The button was given `size-4` (16×16) while the inner iconify `<i
class="icon-[lucide--pencil] size-4">` was also 16×16. The icon
overflowed the button and was clipped, and `content-center` has no
effect on a default `<button>` element, so the icon wasn't centered
either. Since iconify icons render via `background-color` masked by the
SVG, a clipped mask rendered as a partial/solid block that reads as a
dark square.

## Fix

Remove `size-4` and `content-center` from the button; use `inline-flex
items-center justify-center` so the button sizes naturally around the
16×16 icon and centers it properly.

```diff
-class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
+class="relative top-[2px] ml-2 inline-flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground hover:text-base-foreground"
```

One-line change in `src/components/rightSidePanel/RightSidePanel.vue`.
No behavior change — clicking the button still switches the title into
edit mode via `isEditing = true`. Existing e2e coverage in
`browser_tests/tests/propertiesPanel/titleEditing.spec.ts` exercises
click behavior.

## Visual verification

Before — dark filled square:

![before](.glary/screenshots/before-fix-zoom.png)

After — pencil icon renders correctly:

![after](.glary/screenshots/after-fix-zoom.png)

Full panel view after the fix:

![after-full](.glary/screenshots/after-fix-full.png)

## Quality gates

- `pnpm lint` — clean (pre-existing unrelated warning in a test file)
- `pnpm typecheck` — clean
- `pnpm format` — no-op
- Manual verification: clicking the pencil still opens the editable
title input

## Context

Reported by Alex in Slack `#bug-dump`. Present in `main`, unrelated to
#11414.

Bug tracker: [Notion — Icon next to node name in properties panel is
broken](https://www.notion.so/Bug-Icon-next-to-node-name-in-properties-panel-is-broken-3486d73d365081919d7ae96dbe260ab4)

## Screenshots

![Before: broken dark filled square icon next to node
name](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/1e2499fbb5b130c2c7403202f49833ad5fa53cb7abf567f419708a2800935bec/pr-images/1776732250120-e64882bb-c47e-4163-ba0b-4d5a204dba3c.png)

![After: pencil icon renders correctly next to node
name](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/1e2499fbb5b130c2c7403202f49833ad5fa53cb7abf567f419708a2800935bec/pr-images/1776732250445-45ab977f-3572-43d9-a9fb-211055383573.png)

![After: full properties panel view with pencil
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/1e2499fbb5b130c2c7403202f49833ad5fa53cb7abf567f419708a2800935bec/pr-images/1776732250814-6cadec99-1d8d-461d-8811-2ca01864d1a6.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11487-fix-render-edit-pencil-icon-correctly-in-properties-panel-header-3496d73d36508157ba08f4a7a6e31fdd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-22 20:34:38 +00:00
pythongosssss
ac728b92ae fix: fix webcam node not showing preview in nodes 2.0 (#11549)
## Summary

Adds test coverage for webcam node & fixes issue found in testing where
the captured image does not show in nodes 2.0

## Changes

- **What**: 
- call `setNodePreviewsByNodeId` alongside `node.imgs = [img]`
- add tests for general coverage

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11549-fix-fix-webcam-node-not-showing-preview-in-nodes-2-0-34a6d73d3650810c89eee9c25cd07700)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-22 11:29:08 -07:00
Kelly Yang
66409488ce Refactor/brush drawing utils (#11531)
## Summary

Phase 1 of this https://github.com/Comfy-Org/ComfyUI_frontend/pull/11388

## Changes

* **`src/composables/maskeditor/brushDrawingUtils.ts` (New)** —
Extracted `premultiplyData`, `formatRgba`, `drawShapeOnContext`,
`createBrushGradient`, `getCachedBrushTexture`, `drawRgbShape`,
`drawMaskShape`, `resetDirtyRect`, and `updateDirtyRect`; also exports
`DirtyRect` / `MaskColor` types.
* **`src/composables/maskeditor/brushDrawingUtils.test.ts` (New)** — 11
unit tests with zero module mocking.
* **`src/composables/maskeditor/useBrushDrawing.ts`** — Replaced logic
with imports; updated all `updateDirtyRect` call sites to use pure
function calls, eliminating redundant calculations in `drawShape`.

## Test locally
1. Draw a few strokes on the canvas — verify brush marks appear
correctly- ok
2. Switch to the eraser tool and erase part of the stroke — verify
erasure works - ok
3. Press Ctrl+Z to undo — verify the canvas state is restored - ok
4. Alt+drag to adjust brush size/hardness — verify the brush parameters
update correctly - ok


https://github.com/user-attachments/assets/ba4ca54d-e1a9-4985-bc46-b996bbf13eee


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Refactors core brush rendering and dirty-rect tracking used during
interactive drawing, so subtle regressions in brush
appearance/performance or cache behavior are possible. Adds new error
paths when brush texture canvas context/radius are invalid.
> 
> **Overview**
> Extracts CPU brush rendering utilities into new
`brushDrawingUtils.ts`, including **shape drawing**, **soft brush
gradients/rect textures with an LRU cache**, **alpha
premultiplication**, and **dirty-rect reset/update** helpers.
> 
> Updates `useBrushDrawing.ts` to import and use these helpers,
switching dirty-rect tracking to a pure-function style (`dirtyRect.value
= updateDirtyRect(...)`) and simplifying `drawShape` by computing
effective radius/hardness once.
> 
> Adds `brushDrawingUtils.test.ts` with focused unit coverage for
premultiplication, dirty-rect bounds behavior, and RGB/mask drawing
paths (including cached soft-rect textures and error handling when a 2D
context can’t be created).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
abbc6813a6. 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-11531-Refactor-brush-drawing-utils-34a6d73d365081e1b404c384e099d1a9)
by [Unito](https://www.unito.io)
2026-04-22 10:12:20 -04:00
Dante
bea72410fd test: add unit tests for utility widgets (#11442)
## Summary

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

## Changes

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

## Review Focus

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

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

---------

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

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

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

## Test plan

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

Fixes #10932

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

---------

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

---

## Summary

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

## Why "Recent" is accurate

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

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

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

## Scope

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

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

## Changes

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

## Verification

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

## Screenshots

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

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

---------

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

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

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

These types are automatically generated using openapi-typescript.

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

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

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

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

---------

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

Add e2e tests for GLSL shader execution

## Changes

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

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

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

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

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

N/A

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

---------

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

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

<!-- CURSOR_SUMMARY -->
---

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11337-refactor-remove-ts-expect-error-suppressions-in-test-files-3456d73d3650815aa2a2fca5a9332377)
by [Unito](https://www.unito.io)

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

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

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

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11485-1-44-7-3496d73d36508175b725c4ffbed4c4d0)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-21 04:42:23 +00:00
Dante
65e27b5cdf test: add unit tests for graph-level widgets (#11445)
## Summary

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

## Changes

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

## Review Focus

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

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

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

N/A

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

---------

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

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

## Changes

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

## Review Focus

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

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

---

## Summary

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

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

## Changes

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

## Codebase Audit

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

The 4 Vue component implementations already reset correctly.

## Future Work

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

## Test Plan

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

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

---------

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

Test coverage for help center & associated popups

## Changes

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

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

Prevent needing to update the union with newly added keys

## Changes

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

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

N/A

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

---------

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

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

## Changes

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

## Review Focus

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

### Known limitation (out of scope)

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

### Follow-ups

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

## Screenshots (if applicable)

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

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

---------

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

---

## Summary

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

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

## Changes

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

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

## Verification

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

Fixes FE-219

## Screenshots

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

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

---------

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

Add 5 Playwright E2E tests covering topbar menu command interactions.

## Changes

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

## Review Focus

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

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

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

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

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

## Test Plan

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

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

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

## Changes

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

## Why

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

## Validation

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

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

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

## Changes

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

## Review Focus

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

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

---------

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

Adds test coverage for the integrated terminal

## Changes

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

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

---

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

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

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

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

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

---

## Summary

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

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

## Safety gates

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

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

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

## Companion PRs

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

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

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

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

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

**Base branch:** `main`

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

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

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

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

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

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

## Red-Green Verification

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

## Test Plan

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

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

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

## Changes

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

## Review Focus

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

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

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

## Changes

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

Fixes #11008

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

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

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

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

---------

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

## Summary

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

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

## Changes Made

### Documentation Updates

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

## Audit Scope and Findings

### Areas Audited (All  Verified Accurate)

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

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

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

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

**Key Verifications:**

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

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

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

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

## Review Notes

### Documentation Quality Assessment

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

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

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

### Minor Observations (Not Issues)

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

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

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

### What Was NOT Changed

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

## Statistics

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

## Conclusion

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

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

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

---

## Summary

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

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

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

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

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

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

## Changes

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

## Review Focus

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

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

## Testing focus

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

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

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

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

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

## Principles applied

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

## Testing focus

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

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

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

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

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

## Principles applied

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

**Base branch:** `main`

---------

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

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

## Background

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

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

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

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

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

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

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

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

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

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

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

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

## Changes

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

## Review Focus

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

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

---------

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

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

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

These types are automatically generated using openapi-typescript.

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

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

---

## Summary

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

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

## Changes

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

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

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

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

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

Resolves BE-353

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

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-19 04:51:01 +00:00
Christian Byrne
a3893a593d refactor: move select components from input/ to ui/ component library (#11378)
*PR Created by the Glary-Bot Agent*

---

## Summary

Reconciles `src/components/input/` (older select components) into
`src/components/ui/` (internal component library), eliminating the
separate `input/` directory entirely.

## Changes

- **Move MultiSelect** →
`src/components/ui/multi-select/MultiSelect.vue`
- **Move SingleSelect** →
`src/components/ui/single-select/SingleSelect.vue`
- **Extract shared resources** → `src/components/ui/select/types.ts`
(SelectOption type) and `src/components/ui/select/select.variants.ts`
(CVA styling variants)
- **Update 7 consuming files** to use new import paths
- **Update 1 test file** (AssetFilterBar.test.ts mock paths)
- **Move stories and tests** alongside their components
- **Delete `src/components/input/`** directory

## Context

The `input/` directory contained only MultiSelect and SingleSelect — two
well-built components that already used the same stack as `ui/` (Reka
UI, CVA, Tailwind 4, Composition API). MultiSelect even imported
`ui/button/Button.vue`. Moving them into `ui/` removes the split and
consolidates all reusable components in one place.

No API changes — all component props, slots, events, and behavior are
preserved exactly.

## Verification

- `pnpm typecheck` 
- `pnpm build` 
- `pnpm lint` (stylelint + oxlint + eslint) 
- All 15 relevant tests pass (MultiSelect: 5, SingleSelect: 2,
AssetFilterBar: 8) 
- `pnpm knip` — no dead exports 
- No stale `@/components/input/` references remain 
- Pre-commit hooks pass 
- Git detected all moves as renames (97-100% similarity)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11378-refactor-move-select-components-from-input-to-ui-component-library-3476d73d3650810e99b4c3e0842e67f3)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 20:00:34 -07:00
Terry Jia
deba72e7a0 gizmo controls (#11274)
## Summary
Add Gizmo transform controls to load3d

- Remove automatic model normalization (scale + center) on load; models
now appear at their original transform. The previous auto-normalization
conflicted with gizmo controls — applying scale/position on load made it
impossible to track and reset the user's intentional transform edits vs.
the system's normalization
- Add a manual Fit to Viewer button that performs the same normalization
on demand, giving users explicit control
- Add Gizmo Controls (translate/rotate) for interactive model
manipulation with full state persistence across node properties, viewer
dialog, and model reloads
- Gizmo transform state is excluded from scene capture and recording to
keep outputs clean

## Motivation
The gizmo system is a prerequisite for these potential features:
- Custom cameras — user-placed cameras in the scene need transform
gizmos for precise positioning and orientation
- Custom lights — scene lighting setup requires the ability to
interactively position and aim light sources
- Multi-object scene composition — positioning multiple models relative
to each other requires per-object transform controls
- Pose editor — skeletal pose editing depends on the same transform
infrastructure to manipulate individual bones/joints

Auto-normalization was removed because it silently mutated model
transforms on load, making it impossible to distinguish between the
original model pose and user edits. This broke gizmo reset (which needs
to know the "clean" state) and would corrupt round-trip transform
persistence.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558)
by [Unito](https://www.unito.io)
2026-04-18 22:45:06 -04:00
Rizumu Ayaka
3db0eac353 perf: textarea widget layer composition (#10804)
## Summary

I noticed that nodes using textarea for user input, which contain long
user-entered text, require scrolling within a single node.

Having 40 such textarea nodes in a test canvas is enough to cause lag
(20fps). In contrast, a control group using regular nodes can handle up
to 500 nodes without lag (60fps).

the numerous scrolling text widgets in test workflows are the main
source of performance pressure. Each scrolling text input box imposes
independent layout and layering pressure.

I initially tried more complex solutions to fix this issue, like virtual
scrolling. However, I found that a simple CSS modification was
sufficient and effective. Even when I quadrupled the problematic number
of nodes on my M5 MacBook Air, it remained smooth.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10804-perf-textarea-widget-layer-composition-3356d73d3650814da75adec266d7cad9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-19 02:29:00 +00:00
Dante
4c7729ee0b fix: remove hover dimming overlay on image nodes (#11296)
## Summary

Remove the black opacity/dimming overlay on image node hover and add
shadows to action buttons for visibility against light backgrounds.

## Changes

- **What**: Remove `opacity-50` dimming on hover in
`DisplayCarousel.vue`, remove `transition-opacity hover:opacity-80` from
grid thumbnails in `ImagePreview.vue`, add `shadow-md` to action buttons
in `ImagePreview.vue`. Applies to Save Image, Load Image, Preview Image,
and all nodes using these shared image components.

## Review Focus

Button shadows (`shadow-md`) should provide sufficient contrast against
light image backgrounds without needing the dimming overlay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11296-fix-remove-hover-dimming-overlay-on-image-nodes-3446d73d36508193bb5cc27d431014fd)
by [Unito](https://www.unito.io)
2026-04-18 22:40:11 +00:00
Dante
40083d593b test: cover Button, Textarea, Slider components (#11325)
Closes coverage gaps in \`src/components/ui/\` as part of the unit-test
backfill. Uses \`@testing-library/vue\` +
\`@testing-library/user-event\` for user-centric, behavioral assertions.

## Testing focus

Three Reka-UI primitives. The challenge is testing the contract — not
the library internals — given happy-dom's gaps and Reka's
\`useMounted()\`-based async initialization.

### \`Button\` (7 tests)

- Slot rendering + click event propagation.
- \`loading=true\`: three invariants hold **simultaneously** — slot
hidden, \`pi-spin\` spinner present, button is \`toBeDisabled()\`.
- \`disabled=true\` alone: button disabled, no spinner.
- \`as="a"\`: polymorphic root tag (Reka \`Primitive\`'s \`as\` prop
switches the rendered element).
- Variant class pass-through: **one** deliberate style assertion because
the variant-system wiring is part of the component's public contract. No
other styling/class checks (AGENTS.md bans class-based tests).

### \`Textarea\` (6 tests)

- \`v-model\` two-way binding: \`user.type()\` updates the bound ref;
initial value populates the textarea.
- \`disabled\` asserted **behaviorally** — typing is blocked when
disabled, not just the attribute presence.
- Pass-through: \`placeholder\`, \`rows\`, \`class\`.

### \`Slider\` (8 tests)

- Thumb count matches \`modelValue.length\` (range support).
- ARIA: \`aria-valuemin\` / \`aria-valuemax\` / \`aria-valuenow\`.
**Caveat:** Reka's \`SliderRoot\` uses \`useMounted()\`, so
\`aria-valuenow\` is absent on the first render tick. The tests use a
two-tick \`flush()\` helper (\`await nextTick()\` twice) to wait it out
— no mocking of Reka required.
- Keyboard drag: \`user.keyboard('{ArrowRight}')\` / \`'{ArrowLeft}'\`
moves the value; with \`step: 10\` starting from 50, ArrowRight produces
exactly \`[60]\`.
- \`disabled\` → no emit on keyboard events.

### Reka integration limit

Pointer-driven \`slide-start\` / \`slide-end\` gestures in happy-dom
would require faking \`getBoundingClientRect\` and \`setPointerCapture\`
— that crosses into mocking Reka internals. Keyboard-drag paths are
covered instead (the user-facing contract); the \`pressed\` CSS state is
exercised implicitly by surviving a full mount + update cycle.

## Principles applied

- No mocks of Vue, Reka, or \`@vueuse/core\`.
- Queries via \`getByRole\` / \`getByLabelText\`; **no** class-name or
Tailwind-token queries (per AGENTS.md).
- All 21 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-18 22:36:16 +00:00
Dante
7089a7d1a0 fix: show asset display names in bulk delete confirmation (#11321)
## Summary
Bulk-delete confirmation on Comfy Cloud listed raw SHA-256 filenames,
making the modal impossible to use to verify what would be deleted.

## Changes
- **What**: `useMediaAssetActions.deleteAssets` now maps each asset
through `getAssetDisplayName`, so the confirmation's `itemList` matches
the user-assigned names shown in the left media panel
(`MediaAssetCard`).
- **Tests**: Added two regression tests covering `user_metadata.name` /
`display_name` resolution and the `asset.name` fallback.

## Review Focus
- Parity with `MediaAssetCard` display: we reuse the same
`getAssetDisplayName` helper; extension stripping (via
`getFilenameDetails`) is not applied in the modal since file extensions
are useful context when confirming deletions.

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11321-fix-show-asset-display-names-in-bulk-delete-confirmation-3456d73d36508108a3d5f2290ca39e18)
by [Unito](https://www.unito.io)
2026-04-18 22:35:39 +00:00
Christian Byrne
3b4811b00d feat: deploy E2E coverage HTML report to GitHub Pages (#11291)
## Summary

Browsable E2E coverage report deployed to GitHub Pages on every main
merge, replacing the current workflow of downloading LCOV artifacts and
using an external viewer.

## Changes

- **What**: After merging shard LCOVs, run `genhtml` to produce an HTML
report with per-file line coverage. On `main`, deploy to GitHub Pages
via `actions/deploy-pages`. For PR runs, the HTML report is still
available as the `e2e-coverage-html` artifact.
- **Dependencies**: None new — `genhtml` is part of the `lcov` package
already installed in the workflow.

## Review Focus

- **GitHub Pages must be enabled**: Settings → Pages → Source → "GitHub
Actions". Without this the deploy job will fail silently.
- The deploy job only runs for `main` branch (`if:
github.event.workflow_run.head_branch == 'main'`) so PR coverage doesn't
clobber the deployed report.
- Added `pages: write` and `id-token: write` permissions to the workflow
for the Pages deployment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11291-feat-deploy-E2E-coverage-HTML-report-to-GitHub-Pages-3446d73d36508136ba6fd806690c9cfc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-18 15:40:59 -07:00
jaeone94
b756545f59 refactor: clean up ChangeTracker logging, guards, and redundant widget wrapper (#11328)
## Summary

Follow-ups to PR #10816. Bundles four review items left open after that
PR merged — three inside `ChangeTracker` itself and one in the widget
composable that wraps it.

### What changed

- **Removed all `loglevel` logging from `src/scripts/changeTracker.ts`**
— the logger was set to `info`, so every `logger.debug` call was dead
code at runtime. `logger.warn` calls were replaced with direct
reporting. The only-downstream dead code (`graphDiff` helper) and its
sole dependency (`jsondiffpatch`) are also removed.
- **Named the `captureCanvasState()` guard conditions** —
`isUndoRedoing` and `isInsideChangeTransaction` now carry the intent
that the inline `_restoringState` / `changeCount > 0` expressions used
to obscure.
- **Surfaced lifecycle violations through a single reporting helper** —
`reportInactiveTrackerCall()` logs `console.warn` once per method per
session and, on Desktop, emits a `Sentry.addBreadcrumb` with the
offending workflow path. `deactivate()` and `captureCanvasState()` share
this path so the same invariant is reported consistently.
- **Inlined `captureWorkflowState` wrapper in `useWidgetSelectActions`**
— the private helper forwarded to `changeTracker.captureCanvasState()`
with no added logic. Both call sites now invoke the change tracker
directly.

### Issues fixed

- Fixes #11249
- Fixes #11259
- Fixes #11258
- Fixes #11248

### Test plan

- [x] `pnpm test:unit src/scripts/changeTracker.test.ts` — 16 tests pass
- [x] `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.test.ts`
— 6 tests pass
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`
2026-04-18 22:28:05 +00:00
Alexander Brown
da91bdc957 fix: persist middle-click reroute node setting across reloads (#11362)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Remove hardcoded `LiteGraph.middle_click_slot_add_default_node = true`
from `slotDefaults` extension `init()` that unconditionally overrode the
user's persisted preference on every page load
- Add E2E regression test verifying both the setting store value and the
LiteGraph runtime flag persist through page reload

## Root Cause

The `Comfy.SlotDefaults` extension's `init()` method (in
`slotDefaults.ts`) contained a hardcoded
`LiteGraph.middle_click_slot_add_default_node = true` from the original
JS→TS conversion (July 2024). When `Comfy.Node.MiddleClickRerouteNode`
was later made configurable in v1.3.42, this line was never removed.
Since extension `init()` runs **after** `useLitegraphSettings()` syncs
the stored value, the hardcoded assignment overwrote the user's
preference on every reload.

## Changes

| File | Change |
|------|--------|
| `src/extensions/core/slotDefaults.ts` | Remove line 21
(`LiteGraph.middle_click_slot_add_default_node = true`) |
| `browser_tests/tests/dialogs/settingsDialog.spec.ts` | Add reload
persistence test asserting both store value and LiteGraph global |

The setting default (`true`) is already properly managed by
`coreSettings.ts` and reactively synced via `useLitegraphSettings.ts`,
so removing the hardcoded line preserves existing default behavior while
allowing user overrides to persist.

## Screenshots

![Setting shown as enabled (default
state)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970358-dcd6bd51-00c8-4ed4-86ce-0f1a89576f52.png)

![Setting toggled off by
user](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970719-fb1f587f-964d-4e6c-954e-3145812badaf.png)

![Setting correctly persists as off after page reload (with fix
applied)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528971113-36b577cb-5fd1-445d-8c8f-3ea8f6f46326.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11362-fix-persist-middle-click-reroute-node-setting-across-reloads-3466d73d365081ef8692dbd0619c8594)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 21:29:44 +00:00
Christian Byrne
cf3006f82c fix: reduce noise in coverage Slack notifications (#11283)
## Summary

Suppress low-signal coverage Slack notifications that show +0.0% or
-0.0% deltas.

## Changes

- **What**: Add `MIN_DELTA` threshold (0.05%) so only meaningful
improvements trigger notifications. Only display rows for metrics that
actually improved (no more E2E row showing -0.0% alongside a real unit
improvement). Fix `formatDelta` to clamp near-zero values to `+0.0%`
instead of showing `-0.0%`.
- 4 of the first 6 notifications posted were noise (+0.0% deltas from
instrumentation jitter). With this change, only 2 of 6 would have been
posted — both showing real improvements.

## Review Focus

The `MIN_DELTA` value of 0.05 means any delta that rounds to ±0.0% at 1
decimal place is suppressed. This matches the display precision so users
never see +0.0% notifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11283-fix-reduce-noise-in-coverage-Slack-notifications-3436d73d3650819ab3bcfebdb748ac8b)
by [Unito](https://www.unito.io)
2026-04-18 13:28:32 -07:00
pythongosssss
be2d757c47 test: add regression test for getCanvasCenter null guard (#8399) (#11271)
## Summary

Add a regression test for #8399 (null check in `getCanvasCenter` to
prevent crash on asset insert). The fix in
`src/services/litegraphService.ts` added optional chaining around
`app.canvas?.ds?.visible_area` with a `[0, 0]` fallback so inserting an
asset before the canvas finishes initializing no longer crashes. There
was no existing unit test for `litegraphService`, so this regression
could silently return.

## Changes

- **What**: New unit test file `src/services/litegraphService.test.ts`
covering `useLitegraphService().getCanvasCenter`.
- Mocks `@/scripts/app` so `app.canvas` can be swapped per test via
`Reflect.set`.
- Null-canvas case (regression for #8399): returns `[0, 0]` instead of
throwing.
- Missing `ds.visible_area` case: also returns `[0, 0]`.
- Initialised case: returns the centre of the visible area.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11271-test-add-regression-test-for-getCanvasCenter-null-guard-8399-3436d73d3650815c9925c8fdf9ec4bd3)
by [Unito](https://www.unito.io)
2026-04-18 16:32:03 +00:00
Terry Jia
54f3127658 test: regenerate screenshot expectations (#11360)
## Summary
regenerate screenshot expectations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11360-test-regenerate-screenshot-expectations-3466d73d365081878addd53a266a31b7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-18 09:10:02 -04:00
Kelly Yang
6dba67da6b refactor: remove @ts-expect-error suppressions in sidebar components (#11338)
…(issue #11092 phase 4a)

## Summary
Part of #11092 — Phase 4a: remove 10 @ts-expect-error suppressions from
three sidebar component files.
## Changes
3 files in the sidebar had `@ts-expect-error` suppressions that all
traced back to the same root cause: **optional properties on generic
interfaces that TypeScript cannot narrow through indirect conditions.**

`TreeExplorerNode<T>` declares `data?: T` — optional by design, since
folder nodes may carry no payload. Every `handleClick`, `handleDrop`,
and `handleDelete` method that accessed `this.data` was relying on the
runtime invariant that leaf nodes always have data, but TypeScript has
no way to derive `data !== undefined` from `this.leaf === true`. The fix
was to make the invariant explicit in the condition (`if (this.leaf &&
this.data)`) or add an early-return guard (`if (!nodeDefToAdd) return`).

The same pattern appeared in a closure in `ModelLibrarySidebarTab.vue`:
`model` was `ComfyModelDef | null` from an outer const, and `if
(this.leaf)` inside a method cannot narrow a captured variable. Widening
the condition to `if (this.leaf && model)` resolved it. Two additional
suppressions in that file covered `addNodeOnGraph`'s nullable return and
its optional `widgets` property, both fixed with optional chaining.

The remaining suppression was an unannotated function parameter inferred
as `any`; adding the explicit type from the `filters` ref removed it.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are TypeScript-safety refactors (extra
null/undefined guards) plus new unit tests; runtime behavior should only
differ in edge cases where `data`/`model`/`widgets` are unexpectedly
missing.
> 
> **Overview**
> Removes several `@ts-expect-error` suppressions in sidebar library
tabs by making leaf-node invariants explicit (`if (this.leaf &&
this.data/model)`), adding early returns for missing drag-drop payloads,
and using optional chaining for nullable `addNodeOnGraph`/`widgets`
access.
> 
> Adds new Vitest coverage for `ModelLibrarySidebarTab`,
`NodeLibrarySidebarTab`, and `NodeBookmarkTreeExplorer` to validate
click-to-add-node behavior, folder expansion toggling, filter add/remove
flow, bookmark drag/drop, and safe no-op paths when required data is
absent.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
acd2855151. 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-11338-refactor-remove-ts-expect-error-suppressions-in-sidebar-components-3456d73d365081e2858af020b88d7f05)
by [Unito](https://www.unito.io)
2026-04-17 20:28:25 -07:00
Johnpaul Chiwetelu
beaa269a63 feat: polish settings dialog layout and keybinding display (#11241)
Polish keybinding display. Based on #11212 with adjustments:
left-aligned content (no centering), key uppercase moved to UI layer.

- Reduce settings content font size to 14px
- Increase spacing between setting sections with cleaner dividers
- Consistent min-height for form items (toggle, slider, dropdown)
- Capitalize keybinding badges via CSS `uppercase` instead of mutating
data model
- Remove '+' separator between keybinding badges
- Unbold keybinding badges with fixed min-width

Supersedes #11212

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11241-feat-polish-settings-dialog-layout-and-keybinding-display-3426d73d3650812a97e4d941a76a4fe9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alex <alex@Mac.localdomain>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-17 20:22:39 -07:00
Kelly Yang
cf98013c18 test: expand Image Crop E2E and fix loading overlay deadlock (#11193)
## Summary

Expands Playwright coverage for the **ImageCropV2** widget (Levels 1–3
from the image crop E2E plan), fixes **loading / image mount** behavior
when `imageUrl` changes, adds **stable resize-handle selectors**, and
adds a **small Vitest** for URL→loading transitions.

## Changes

- [x] **Level 1 (E2E)** — Empty state: assert resize handle hidden;
screenshot baseline `image-crop-empty-state.png`; pointer drag on empty
state does not change widget bounds.
- [x] **Level 1 (E2E)** — After run: assert **8 visible** resize handles
with `data-testid` + `filter({ visible: true })`; broken `img.src`
returns to empty state (`crop-empty-state`, no overlay).
- [x] **Level 1 (E2E)** — **Slow `/api/view`** route (delay only
`example.png`) to assert **“Loading…”** then hidden after image loads;
comment clarifies delay is in the route handler, not
`page.waitForTimeout`.
- [x] **Level 2 (E2E)** — Drag clamps to **right/bottom** and
**top-left** image bounds via `setCropBounds` + `expect.poll` on natural
bounds.
- [x] **Level 3 (E2E)** — Free resize: right / left / bottom / top
edges; SE and NW corners; `MIN_CROP_SIZE` (16px); right-edge boundary
clamp; **8 handles** screenshot `image-crop-eight-handles.png`; SE/NW
screenshots (`image-crop-resize-se.png`, `image-crop-resize-nw.png`).
- [x] **E2E helpers** — Shared `getCropValue`, `setCropBounds`,
`dragOnLocator`, `POINTER_OPTS`; drag regression uses **`expect.poll`**
instead of `toPass` where appropriate.
- [x] **`WidgetImageCrop.vue`** — When `imageUrl` is set, **always
render `<img>`**; show loading as an **absolute overlay** (fixes
deadlock where `isLoading` blocked `<img>` so `@load` never ran); add
**`data-testid="crop-resize-{direction}"`** on resize handles.
- [x] **`useImageCrop.ts`** — Watch `imageUrl` and drive `isLoading`;
extract **`imageCropLoadingAfterUrlChange`** (`boolean | null`) for
clear semantics and tests.
- [x] **`useImageCrop.test.ts`** — Vitest coverage for
`imageCropLoadingAfterUrlChange` (null URL, URL change, first URL,
unchanged URL).

## Screenshot / CI notes

- [ ] **Linux screenshot expectations** for new/updated
`toHaveScreenshot(...)` names must be produced on **CI (Linux)** — add
the **`New Browser Test Expectation`** label (or equivalent workflow);
**do not** commit local **Darwin** golden files.
- [x] Existing Linux baselines under `imageCrop.spec.ts-snapshots/` for
prior tests are unchanged where applicable; new baselines are expected
from CI after merge workflow.

## Files

- [x] `browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts`
- [x] `src/components/imagecrop/WidgetImageCrop.vue`
- [x] `src/composables/useImageCrop.ts`
- [x] `src/composables/useImageCrop.test.ts` (new)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches interactive crop UI rendering and `isLoading` state
transitions, which can affect user-visible behavior and input handling;
changes are mitigated by extensive new E2E and unit tests.
> 
> **Overview**
> Improves the `WidgetImageCrop` loading behavior by always rendering
the preview `<img>` when `imageUrl` is set and showing “Loading…” as an
absolute overlay, preventing a deadlock where `isLoading` could block
the `@load` event. Adds stable `data-testid="crop-resize-{direction}"`
selectors for resize handles and hardens pointer-capture handling in
`useImageCrop`.
> 
> Greatly expands automated coverage: the Playwright spec now tests
empty-state rendering/screenshot, drag/resize interactions (edge/corner,
min size, and clamping to image bounds), aspect-ratio lock handle
visibility, slow `/api/view` loading overlay behavior, and broken image
fetch recovery. Adds a new Vitest suite for `useImageCrop` (including
`imageCropLoadingAfterUrlChange`) to unit-test URL→loading transitions
and core drag/resize/aspect-ratio logic.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c4f88a42b5. 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-11193-test-expand-Image-Crop-E2E-and-fix-loading-overlay-deadlock-3416d73d365081eb99dae577c939baa9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-17 20:22:05 -07:00
Benjamin Lu
ecb7fd4796 feat: add frontend subscription success recovery (#11286)
Improving our subscription detection system. Optimal will have to come
after BE team brings personal billing to cloud repo off of comfy api.

## Summary
- replace the dialog-local focus poller with a frontend checkout tracker
stored in `localStorage`
- recover pending subscription checkouts from app boot plus global page
lifecycle (`pageshow`, `visibilitychange`) with bounded retries only
while an attempt is pending
- emit `subscription_success` through GTM with frontend-derived metadata
once subscription state reaches the expected target tier/cycle

## Why
This is the frontend-only 80/20 path. It fixes the brittle "old tab must
regain focus" behavior without adding new backend endpoints or backend
event storage. The browser records one pending checkout attempt when
checkout is opened, and any returning cloud tab can recover it later by
comparing current subscription state against the expected target plan.

## Tradeoffs
- browser-scoped, not backend-authoritative
- no server transaction id
- scheduled downgrades through the billing portal are intentionally not
inferred as immediate success events
- still best-effort compared with the backend outbox/WebSocket approach

## Validation
- `pnpm exec vitest run
src/platform/cloud/subscription/composables/useSubscription.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts`
- `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11286-feat-add-frontend-subscription-success-recovery-3436d73d3650814d9f74c89e6926aa84)
by [Unito](https://www.unito.io)
2026-04-17 22:49:49 +00:00
AustinMroz
e28c1e7e43 Show multitype slices of shared color (#11250)
A tiny update requested by the backend team.

Previously, multitype slot indicators would have inputs that resolve to
the same connection color display be combined into a single slice. For
example, both `INT` and `FLOAT` have the same color, so an `INT,FLOAT`
slot displays as a solid color instead of 2 semi-circles. This was a
conscientious decision to improve readability on slots that allow many
types, but meant that the more common cases (like `INT,FLOAT`) would
have no indicator at all. Since priority is given to types based on
order of listing, node authors can still control which types are elided
on a slot accepting many types.

<img width="430" height="320" alt="image"
src="https://github.com/user-attachments/assets/1fc7fb1c-a634-487c-bc03-711637aeef13"
/>

- I do not believe there are any core nodes affected by this change.
- The vue implementation of merging slot colors never functioned
properly, but is still removed.
- Vue was bugged to incorrectly pass slot types for widgets. This is
also fixed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11250-Show-multitype-slices-of-shared-color-3436d73d365081b6b484ea74423435a1)
by [Unito](https://www.unito.io)
2026-04-17 22:19:59 +00:00
Yourz
39dc8d896b feat(website): unified preview — cloud page, API & enterprise pages, use case images (#11273)
## Summary

Unified preview branch combining three feature PRs for the website
product pages.

> **Constituent PRs:** #11247, #11270, #11266

## Changes

- **Cloud page** (#11247): Add Cloud product page sections (Hero,
Reason, FAQ, AI Models, Audience, Pricing, ProductCards). Extract
`FAQSection` to `common/` and `ReasonSection` to `product/shared/` for
reuse across product pages. Add cloud-related i18n translations.
- **API & Enterprise pages** (#11270): Add API page (Hero, Steps,
Automation, Reason) and Enterprise page (Hero, Team, DataOwnership,
BYOKey, Orchestration, Reason). Add shared `CardGridSection`,
`FeatureShowcaseSection`, `CloudBannerSection`. Add all API/enterprise
i18n translations.
- **Use case images** (#11266): Replace placeholder divs with real
images in `UseCaseSection`. Add SVG blob clip-paths
(`objectBoundingBox`) and crossfade transitions on category switch. Use
`useId()` for unique clip-path IDs.

## Review Focus

- Shared component API design (`ReasonSection` slot/prop surface)
- Component placement: `common/` vs `product/shared/`
- Clip-path coordinate accuracy and crossfade transition smoothness

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-04-17 22:17:49 +00:00
Kelly Yang
f6f267b46d test: add unit tests for slotCalculations (#11302)
# PR Description

#11106 
**This PR only focus on `slotCalculations.ts`.**

Add unit tests for `slotCalculations.ts` — the centralized utility that
calculates input/output slot positions in graph coordinates. This file
had zero test coverage despite containing non-trivial branching logic
used by both the litegraph adapter and the Vue renderer layout system.

## What's covered

### `calculateInputSlotPosFromSlot`
- [x] **Collapsed nodes**: Returns the node origin shifted up by half
the title height.
- [x] **Hard-coded offsets**: Slots with specific `pos` offsets return
`nodeOrigin + pos` directly.
- [x] **Default vertical layout**: Covers first slot x/y, multi-slot
vertical ordering, `slotStartY` offset, exclusion of widget input slots,
and exclusion of fixed-position slots from index ordering.

### `getSlotPosition` (Legacy fallback path, `vueNodesMode` disabled)
- [x] **Coordinate derivation**: Input and output slot positions derived
correctly from `node.pos`.
- [x] **Collapsed state**: Collapsed input and output nodes use
`title-height` and `NODE_COLLAPSED_WIDTH` offsets.
- [x] **Boundary handling**: Out-of-range slot index falls back to node
origin.



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only changes that don’t affect runtime behavior; risk is limited
to potential brittleness if slot layout constants or expectations
change.
> 
> **Overview**
> Adds a new `slotCalculations.test.ts` suite covering
`calculateInputSlotPosFromSlot` and the legacy (`LiteGraph.vueNodesMode`
disabled) path of `getSlotPosition`.
> 
> Tests exercise key branches for collapsed nodes, hard-coded slot `pos`
overrides, default vertical slot ordering (including `slotStartY`), and
filtering of widget/fixed-position inputs, plus boundary behavior for
out-of-range slot indices.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
51c5318695. 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-11302-test-add-unit-tests-for-slotCalculations-3446d73d36508181a0ade81be05bd25f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-17 21:45:26 +00:00
Dante
e8d833bc54 test: cover useLazyPagination, useRangeEditor, useCurveEditor (#11326)
Closes coverage gaps in \`src/composables/\` as part of the unit-test
backfill.

## Testing focus

Three composables, each a different kind of test challenge: reactive
pagination state, DOM-track drag math, and SVG pointer interaction. No
third-party library is mocked.

### \`useLazyPagination\` (10 tests)

- Accepts both \`Ref<T[]>\` and plain \`T[]\` inputs.
- \`currentPage\` ceiling at \`totalPages\` (clamp behavior).
- Source-array replacement resets internal page state.
- \`loadedPages\` (Set) accumulates across navigation.
- **Observed source issue.** \`loadNextPage\` is declared \`async\` but
contains no \`await\` (the artificial delay is commented out).
Consequence: \`isLoading\` is never externally observable as \`true\`,
and the concurrent-call dedup in the design doesn't actually fire in
practice. Tests cover **observable** behavior only; the finding is noted
here as a candidate follow-up fix.

### \`useRangeEditor\` (11 tests)

- Drags each of \`min\` / \`max\` / \`midpoint\` handles; respects the
\`showMidpoint\` toggle (events on the midpoint are ignored when
hidden).
- Value clamping within \`[valueMin, valueMax]\`.
- \`denormalize\` receives the correct normalized position — verifies
the 0–1 mapping math, not just that it was called.
- \`trackRef.value === null\` → pointer events are no-ops (null-safety).
- **Real lifecycle.** Mounts a tiny \`defineComponent\` via
\`@testing-library/vue\`'s \`render\` and exercises cleanup through
\`unmount()\`. \`onBeforeUnmount\` only fires inside a component
instance — \`effectScope.stop()\` alone is insufficient.

### \`useCurveEditor\` (14 tests)

- \`curvePath\` empty when fewer than 2 points.
- Linear interpolation: \`M\` + \`L\` command sequence, points sorted by
x before drawing.
- Non-linear uses \`createInterpolator\` (our module → OK to mock and
assert call shape).
- Drag: dispatching \`pointermove\` updates \`modelValue\`; after
\`pointerup\`, a follow-up \`pointermove\` is a no-op.
- **happy-dom gaps polyfilled.** \`Element.setPointerCapture\` is
stubbed per-element and \`DOMPoint.prototype.matrixTransform\` is added
in \`beforeEach\`. Since the SVG has no CTM, \`DOMMatrix.inverse()\`
returns identity — so \`svgCoords\` maps \`clientX\`/\`clientY\`
directly into curve space, giving deterministic assertions without
brittle coordinate math.

## Principles applied

- No mocks of \`vue\`, \`@vueuse/core\`, or \`es-toolkit\`.
- Behavioral assertions only — no return-shape checks.
- All 35 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-17 21:41:09 +00:00
AustinMroz
3fd3c565ae Fix dropdown chevron color (#11335)
Updates the the color of the chevron on dropdown widgets to only have
the disabled color when the widget is disabled.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/25d35e78-9147-4397-be19-df9d6f87ac72"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/3bf3640d-fa14-42cb-afb4-7109eb878d1a"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11335-Fix-dropdown-chevron-color-3456d73d3650819e99c7d15173f11319)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-17 20:37:22 +00:00
Jedrzej Kosinski
ff4c812d08 feat: show sign-in button via server feature flag (#11298)
## Summary

Show the sign-in button in the frontend when the `show_signin_button`
server feature flag is set, without requiring a special desktop
distribution build.

## Changes

- Add `SHOW_SIGNIN_BUTTON` to `ServerFeatureFlag` enum
- Add `showSignInButton` getter in `useFeatureFlags` composable (returns
`boolean | undefined`)
- Update `WorkflowTabs.vue` to use `flags.showSignInButton ?? isDesktop`
- the server flag takes precedence when set, falls back to compile-time
`isDesktop` for legacy desktop support

## Related

- Comfy-Org/ComfyUI-Desktop-2.0-Beta#415
- Backend: Comfy-Org/ComfyUI `feature/generic-feature-flag-cli`
- Launcher: Comfy-Org/ComfyUI-Desktop-2.0-Beta#418

Co-authored-by: Amp <amp@ampcode.com>
2026-04-17 13:45:41 -07:00
Christian Byrne
836cab1b38 fix: deploy website previews via GitHub Actions instead of Vercel auto-deploy (#11289)
## Summary

Vercel's auto-deploy triggers on every PR because files outside
workspace packages (e.g. `browser_tests/`, `src/`) are treated as global
changes by the monorepo skip logic.

## Changes

- **What**: Replace Vercel's GitHub integration with a GitHub Action
(`ci-vercel-website-preview.yaml`) that uses `paths:` filtering to only
deploy when `apps/website/`, `packages/design-system/`, or
`packages/tailwind-utils/` change. Add `vercel.json` with
`github.enabled: false` to disable Vercel's automatic GitHub
integration.

## Setup required after merge

Three GitHub repo secrets are needed. All secrets are scoped per-project
using the `VERCEL_WEBSITE_*` prefix. Future Vercel projects would follow
the same convention (e.g. `VERCEL_DOCS_*`).

### Step 1: Create a Vercel API Token

1. Go to [vercel.com/account/tokens](https://vercel.com/account/tokens)
2. Click **Create Token**
3. Fill in the form:
   - **Token Name**: `github-actions-website`
- **Scope**: Select the **Comfy-Org** team (not "Full Account" — scope
it to the team that owns the project)
- **Expiration**: Choose **No Expiration** (or set a long expiration
like 1 year — if it expires the workflow will silently fail)
4. Click **Create**
5. **Copy the token immediately** — it is only shown once

### Step 2: Get Vercel Org ID and Project ID

1. Go to
[vercel.com/comfyui/website-frontend/settings](https://vercel.com/comfyui/website-frontend/settings)
2. Scroll down to the **Project ID** field — copy this value
3. Go to
[vercel.com/teams/comfyui/settings](https://vercel.com/teams/comfyui/settings)
(Team Settings → General)
4. Find the **Vercel ID** field (also called Team ID / Org ID) — copy
this value

### Step 3: Add secrets to GitHub

1. Go to
[github.com/Comfy-Org/ComfyUI_frontend/settings/secrets/actions](https://github.com/Comfy-Org/ComfyUI_frontend/settings/secrets/actions)
2. Click **New repository secret** and add each of the three secrets:

| Secret name | Value |
|---|---|
| `VERCEL_WEBSITE_TOKEN` | The token from Step 1 |
| `VERCEL_WEBSITE_ORG_ID` | The team/org ID from Step 2 |
| `VERCEL_WEBSITE_PROJECT_ID` | The project ID from Step 2 |

> **Note:** The `vercel.json` added by this PR (`github.enabled: false`)
automatically disables Vercel's built-in auto-deploy — no dashboard
changes needed.

## Review Focus

- Verify the `paths:` filter covers all dependencies of `apps/website`
- Confirm the PR comment logic is sound (creates once, updates on
subsequent pushes)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-17 17:54:26 +00:00
Dante
7ffaff7e1b test: cover useBillingPlans and tierBenefits (#11318)
Closes coverage gaps in `src/platform/cloud/subscription/` as part of
the unit-test backfill.

## Testing focus

`useBillingPlans` holds **module-scoped refs** (`plans`,
`currentPlanSlug`, `isLoading`, `error`). If state leaks between tests,
failures get masked as false-green. The suite uses `vi.resetModules()` +
dynamic `import()` in every test to get a fresh instance — state
isolation is the primary design constraint here.

### `useBillingPlans` (12 tests)

- **Concurrent-call dedup.** The \`isLoading\` guard is validated by
creating a pending promise, firing a second \`fetchPlans()\` while the
first is in-flight, and asserting the mock is called **exactly once**.
- **Error branching.** \`Error\` instance → \`.message\` captured.
Non-Error rejection → fallback string (\`'Failed to fetch plans'\`).
Both paths also verify \`console.error\` logging via a spy.
- **Error-reset invariant.** After a failure, a subsequent success must
null out \`error.value\` — order-dependent and easy to regress.
- **Shared-state invariant.** Two separate \`useBillingPlans()\` calls
return refs pointing at the same module-level state.
- **Computed filtering.** \`monthlyPlans\` / \`annualPlans\` partition
by duration — assertions on distinct output, not input re-assertion.

### \`tierBenefits\` (7 tests)

- Table-driven across all \`TierKey\` values for \`maxDuration\`,
\`addCredits\`, \`customLoRAs\` branches.
- \`monthlyCredits\` free-tier path including the
\`remoteConfig.free_tier_credits\` null fallback.
- Translator/formatter forwarding verified by spy.

## Principles applied

- No mocks of \`vue\`, \`pinia\`, or \`@vueuse/core\` — only our own
\`workspaceApi\`.
- Behavioral assertions only — no return-shape checks.
- All 19 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-17 13:55:49 +00:00
jaeone94
5d04df7b2c fix: prevent duplicate prepareForSave and conflicting is_new telemetry on self-overwrite Save As (#11329)
## Summary

Follow-up to PR #10816. Fixes a telemetry semantic bug in
`saveWorkflowAs` that emitted two conflicting events for a single user
action.

### What changed

- `saveWorkflowAs` self-overwrite branch now calls
`workflowStore.saveWorkflow` directly instead of delegating to the
`saveWorkflow()` wrapper. The wrapper would run `prepareForSave` a
second time and emit `trackWorkflowSaved({ is_new: false })`, which then
conflicted with the outer `saveWorkflowAs`'s `trackWorkflowSaved({
is_new: true })` for the same user action.
- Added regression tests asserting a single `trackWorkflowSaved` call
with `{ is_new: true }` and a single `prepareForSave` invocation on both
the self-overwrite and copy paths.

### Issues fixed

- Fixes #10819

### Why no E2E test

The bug and fix are entirely about observability (how many telemetry
events are emitted and with what payload). There is no user-visible
change — the file is saved correctly in both pre- and post-fix cases,
and `is_new` values are never rendered in the UI. Playwright tests
cannot directly verify `trackWorkflowSaved` call counts/payloads without
intercepting outbound analytics traffic, which is not a pattern used
elsewhere in `browser_tests/`. Unit tests at the service boundary are
the appropriate level for this contract: they mock `useTelemetry` and
can assert exact call count and payload deterministically.

### Test plan

- [x] `pnpm test:unit
src/platform/workflow/core/services/workflowService.test.ts` — 56 tests
pass (including 2 new regression tests + 1 expanded assertion)
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11329-fix-prevent-duplicate-prepareForSave-and-conflicting-is_new-telemetry-on-self-overwrite-3456d73d36508192875ed5e70ab9c359)
by [Unito](https://www.unito.io)
2026-04-17 09:29:03 +00:00
Dante
2d50cc2d76 feat: show success toast after ComfyHub publish (#11316)
## Summary

Adds a success toast in the ComfyHub publish flow so users get explicit
confirmation that the workflow was published before the dialog closes.

## Changes

- **What**: `ComfyHubPublishDialog.handlePublish()` calls `toast.add({
severity: 'success', ... })` after `submitToComfyHub()` resolves and
before `onClose()` runs. Adds two i18n keys (`publishSuccessTitle`,
`publishSuccessDescription`) and an assertion in the existing
success-path test.

## Review Focus

- This is the lightweight stop-gap discussed in [Slack
thread](https://comfy-organization.slack.com/archives/C0AEPRS8N74/p1776370871654139?thread_ts=1776362591.237159&cid=C0AEPRS8N74)
while the larger published-state design is still pending phase-2 work.
Symmetric with the existing `publishFailedTitle/Description` error
toast.
- `submitToComfyHub` is synchronous (asset uploads happen inside it), so
a successful resolve means the workflow is live.
- `<Toast>` is mounted in `GlobalToast.vue`, so it persists after
`onClose()` destroys the dialog.

## Screenshots (if applicable)
<img width="1135" height="634" alt="Screenshot 2026-04-17 at 8 11 34 AM"
src="https://github.com/user-attachments/assets/a71400a7-2055-4c2a-a761-9298cfa24e9a"
/>

n/a — toast text only.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11316-feat-show-success-toast-after-ComfyHub-publish-3446d73d365081a7bbb3ca29ca3bb618)
by [Unito](https://www.unito.io)
2026-04-16 23:32:36 +00:00
Kelly Yang
89c11c9aa9 test: add unit test suite for apps/desktop-ui (#11275)
## Summary

This is a follow-up PR of #11102

| Requirement | Status | Implementation |
| :--- | :--- | :--- |
| Add vitest configuration for desktop-ui workspace |  Done | Added
`apps/desktop-ui/vitest.config.mts` with `happy-dom` environment, `@`
alias, and `setupFiles` pointing to `src/test/setup.ts` (registers
`@testing-library/jest-dom` matchers) |
| Add test:unit script to package.json |  Done | Added `"test:unit":
"vitest run --config vitest.config.mts"` to
`apps/desktop-ui/package.json` |
| stores/maintenanceTaskStore.ts |  Done | 34 tests covering task state
machine, IPC integration, executeTask flow, and error handling via
`@pinia/testing` |
| utils/electronMirrorCheck.ts |  Done | 5 tests covering URL
validation, canAccessUrl delegation, and true/false return logic |
| utils/refUtil.ts (useMinLoadingDurationRef) |  Done | 7 tests
covering initial state, timing behavior using `vi.useFakeTimers`, and
computed ref input |
| utils/envUtil.ts |  Done | 7 tests covering electronAPI detection and
fallback behavior |
| constants/desktopDialogs.ts |  Done | 8 tests covering dialog
structure and field contracts |
| constants/desktopMaintenanceTasks.ts |  Done | 5 tests covering
`pythonPackages.execute` success/failure return values, and URL-opening
tasks calling `window.open` |
| composables/bottomPanelTabs/useTerminal.ts |  Done | 7 tests covering
key event handler: Ctrl/Meta+C with/without selection, Ctrl/Meta+V,
non-keydown events, and unrelated keys — mocked xterm with Vitest
v4-compatible function constructors |
| composables/bottomPanelTabs/useTerminalBuffer.ts |  Done | 2 tests
for `copyTo`: verifies serialized buffer content is written to
destination terminal |
| utils/validationUtil.ts |  Skipped | The current file contains only a
`ValidationState` enum with no logic. There is no behavior to test
without writing a change-detector test (asserting enum values), which
violates project testing guidelines |

**Additional config changes (not in issue but required to make tests
work):**

| Change | Reason |
| :--- | :--- |
| Added `"vitest.config.mts"` to `apps/desktop-ui/tsconfig.json` include
| Required for ESLint's TypeScript parser to process the config file
without a parsing error |
| Removed 6 redundant test devDependencies from
`apps/desktop-ui/package.json` | `vitest`, `@testing-library/*`,
`@pinia/testing`, `happy-dom` are already declared at the root and
hoisted by pnpm — re-declaring them in the sub-package is unnecessary |

## Changes
- Add vitest.config.mts with happy-dom environment and path aliases
- Add src/test/setup.ts to register @testing-library/jest-dom matchers
- Add test:unit script to package.json
- Add vitest.config.mts to tsconfig.json include for ESLint
compatibility
- Remove redundant test devDependencies already declared at root
- Add 132 tests across 16 files covering stores, composables, utils, and
constants

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test- and config-only changes; main risk is CI/build instability from
new Vitest configuration or brittle mocks, with no runtime behavior
changes shipped to users.
> 
> **Overview**
> Adds a dedicated Vitest setup for `apps/desktop-ui` (new
`vitest.config.mts` using `happy-dom`, aliases, and a `jest-dom` setup
file) and wires it into the workspace via a new `test:unit` script plus
`tsconfig.json` inclusion.
> 
> Introduces a broad set of new unit tests for desktop UI components,
composables, constants, utilities, and the `maintenanceTaskStore`
(mocking Electron/PrimeVue/Xterm as needed) to validate state
transitions, validation flows, and key UI behaviors without changing
production logic.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0a96ffb37c. 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-11275-test-add-unit-test-suite-for-apps-desktop-ui-3436d73d36508145ae1fe99ec7a3a4fa)
by [Unito](https://www.unito.io)
2026-04-16 22:20:00 +00:00
Kelly Yang
29d6263fb9 test: add Preview3D execution flow E2E tests (#11014)
## Summary
Adds Playwright coverage for `Preview3D execution` and persistence :
real queue execution against a `Load3D → Preview3D` workflow, plus `save
/ full reload / reopen` from the sidebar.

## What these tests do
**Fixture** (every test)
Turns on Vue Nodes, uses the sidebar for workflows, loads a Load3D →
Preview3D workflow, waits for nodes, then clears saved workflows after
the test so runs stay isolated.

**Test 1 — execution updates Preview3D**
Uploads `cube.obj`(the existing test file in the merged version) to
Load3D, runs `Queue Prompt`, then checks that Preview3D’s model_file and
Last Time Model File match and the canvas has non-zero size. No 3D
screenshots (GPU flakiness).

**Test 2 — persistence after reload**
Same upload + queue, then saves the workflow, reloads the page,
re-applies the same UI settings, opens the saved workflow, and checks
the same model path and camera state (with a small numeric tolerance).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new slow, WebGL-dependent E2E tests and fixtures, which can
increase CI runtime and introduce flakiness due to timing/graphics
variability, but does not change production logic.
> 
> **Overview**
> Adds a new `Load3D → Preview3D` workflow asset and a dedicated
Playwright fixture (`Preview3DPipelineFixture`) to drive real queue
execution, upload a 3D model, and interact with the 3D canvases (orbit
drags) while asserting `model_file`/`Last Time Model File` and camera
state via node properties.
> 
> Introduces camera-state comparison helpers with explicit numeric
tolerances, and adds a new `preview3dExecution.spec.ts` suite that
validates (1) Preview3D updates from execution output and (2) model +
camera persistence across save, full page reload, and reopening the
workflow from the sidebar.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5f54b0f650. 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-11014-test-add-Preview3D-execution-flow-E2E-tests-33e6d73d3650811fa298c364ae196606)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-16 18:08:04 -04:00
jaeone94
a1e6fb36d2 refactor: harden ChangeTracker lifecycle with self-defending API (#10816)
## Summary

Harden the `ChangeTracker` lifecycle to eliminate the class of bugs
where an inactive workflow's tracker silently captures the wrong graph
state. Renames `checkState()` to `captureCanvasState()` with a
self-defending assertion, introduces `deactivate()` and
`prepareForSave()` lifecycle methods, and closes a latent undo-history
corruption bug discovered during code review.

## Background

ComfyUI supports multiple workflows open as tabs, but only one canvas
(`app.rootGraph`) exists at a time. When the user switches tabs, the old
workflow's graph is unloaded and the new one is loaded into this shared
canvas.

The old `checkState()` method serialized `app.rootGraph` into
`activeState` to track changes for undo/redo. It had no awareness of
*which* workflow it belonged to -- if called on an inactive tab's
tracker, it would capture the active tab's graph data and silently
overwrite the inactive workflow's state. This caused permanent data loss
(fixed in PR #10745 with caller-side `isActive` guards).

The caller-side guards were fragile: every new call site had to remember
to add the guard, and forgetting would reintroduce the same silent data
corruption. Additionally, `beforeLoadNewGraph` only called `store()`
(viewport/outputs) without `checkState()`, meaning canvas state could be
stale if a tab switch happened without a preceding mouseup event.

### Before (fragile)

```
saveWorkflow(workflow):
  if (isActive(workflow))              <-- caller must remember this guard
    workflow.changeTracker.checkState()      <-- name implies "read", actually writes
  ...

beforeLoadNewGraph():
  activeWorkflow.changeTracker.store()      <-- only saves viewport, NOT graph state
```

### After (self-defending)

```
saveWorkflow(workflow):
  workflow.changeTracker.prepareForSave()   <-- handles active/inactive internally
  ...

beforeLoadNewGraph():
  activeWorkflow.changeTracker.deactivate() <-- captures graph + viewport together
```

## Changes

- Rename `checkState` to `captureCanvasState` with active-tracker
assertion
- Add `deactivate()` and `prepareForSave()` lifecycle methods
- Fix undo-history corruption: `captureCanvasState()` guarded by
`_restoringState`
- Fix viewport regression during undo: `deactivate()` skips
`captureCanvasState()` during undo/redo but always calls `store()` to
preserve viewport (regression from PR #10247)
- Log inactive tracker warnings unconditionally at warn level (not
DEV-only)
- Deprecated `checkState()` wrapper for extension compatibility
- Rename `checkState` to `captureCanvasState` in
`useWidgetSelectActions` composable
- Add `appModeStore.ts` to manual call sites documentation
- Add `checkState()` deprecation note to architecture docs
- Add 16 unit tests covering all guard conditions, lifecycle methods,
and undo behavior
- Add E2E test: "Undo preserves viewport offset"

## New ChangeTracker Public API

| Method | Caller | Purpose |
|--------|--------|---------|
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots
canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()`
(skipped during undo/redo) + `store()`. Freezes state for tab switch. |
| `prepareForSave()` | Save paths only | Active: `captureCanvasState()`.
Inactive: no-op. |
| `checkState()` | **Deprecated** -- extensions only | Wrapper that
delegates to `captureCanvasState()` with deprecation warning. |
| `store()` | Internal to `deactivate()` | Saves viewport, outputs,
subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs,
subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks as
"clean"). |

## Test plan

- [x] Unit tests: 16 tests covering all guard conditions, state capture,
undo queue behavior
- [x] E2E test: "Undo preserves viewport offset" verifies no viewport
drift on undo
- [x] E2E test: "Prevents captureCanvasState from corrupting workflow
state during tab switch"
- [x] Existing E2E: "Closing an inactive tab with save preserves its own
content"
- [ ] Manual: rapidly switch tabs during undo/redo, verify no viewport
drift
- [ ] Manual: verify extensions calling `checkState()` see deprecation
warning in console
2026-04-16 12:54:12 +00:00
jaeone94
394e36984f fix: re-sync collapsed node slot positions after subgraph fitView (#11240)
## Summary

Fix collapsed node connection links rendering at wrong positions when
entering a subgraph for the first time. `fitView()` (added in #10995)
changes canvas scale/offset, invalidating cached slot positions for
collapsed nodes.

## Changes

- **What**: Schedule `requestSlotLayoutSyncForAllNodes()` on the next
frame after `fitView()` in `restoreViewport()` so collapsed node slot
positions are re-measured against the updated transform. Inner RAF
guarded against mid-frame graph changes.
- **Test coverage**:
- Unit tests in `subgraphNavigationStore.viewport.test.ts` verify the
RAF chain calls `requestSlotLayoutSyncForAllNodes` after `fitView`, and
skip the re-sync when the active graph changes between frames.
- E2E screenshot test (`@screenshot` tag) validates correct link
rendering on first subgraph entry using a new fixture with a
pre-collapsed inner node.

## Review Focus

The nested `requestAnimationFrame` is intentional: the outer RAF runs
`fitView()`, which updates `ds.scale`/`ds.offset` and triggers a CSS
transform update on `TransformPane`. The inner RAF ensures the DOM has
reflowed with the new transform before
`requestSlotLayoutSyncForAllNodes()` measures `getBoundingClientRect()`
on slot elements.

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-16 12:38:01 +00:00
Dante
19fff29204 test: backfill e2e coverage gaps for toolkit widgets, minimap, mask editor, painter (#11183)
## Summary

Backfills missing e2e test coverage identified in the [FixIt
Burndown](https://www.notion.so/comfy-org/FixIt-Burndown-32e6d73d365080609a81cdc9bc884460)
audit. Adds 39 new behavioral tests across 5 spec files with zero
test-code overlap.

## Changes

- **What**: New e2e specs for Image Crop (6 tests) and Curve Widget (6
tests). Deepened coverage for Minimap (+6), Mask Editor (+10), Painter
(+11).
- **New fixtures**: `curve_widget.json`, updated
`image_crop_widget.json`

## Test Inventory

| Spec | New tests | Coverage area |
|---|---|---|
| `imageCrop.spec.ts` | 6 | Empty state, bounding box inputs, ratio
selector/presets, lock toggle, programmatic value update |
| `curveWidget.spec.ts` | 6 | SVG render, click-to-add point,
drag-to-reshape, Ctrl+click remove, interpolation mode switch, min-2
guard |
| `minimap.spec.ts` | +6 | Click-to-pan, drag-to-pan, zoom viewport
shrink, node count changes, workflow reload, pan state reflection |
| `maskEditor.spec.ts` | +10 | Brush drawing, undo/redo, clear, cancel,
invert, Ctrl+Z, tool panel/switching, brush settings, save with mock,
eraser |
| `painter.spec.ts` | +11 | Clear, eraser, control visibility toggle,
brush size slider, stroke width comparison, canvas dimensions,
background color, multi-stroke accumulate, color picker, opacity,
partial erase |

## Review Focus

- Mask editor tests use `.maskEditor_toolPanelContainer` class selectors
— may need test-id hardening later
- Painter slider interaction tests could be flaky if slider layout
changes
- All canvas pixel-count assertions use `expect.poll()` with timeouts
for reliability

## Test plan
- [ ] CI passes all new/modified specs
- [ ] No duplicate coverage with existing tests (verified via grep
before writing)
- [ ] No `waitForTimeout` usage (confirmed)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11183-test-backfill-e2e-coverage-gaps-for-toolkit-widgets-minimap-mask-editor-painter-3416d73d3650819ca33edd1f27b9651a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-16 09:48:13 +00:00
Johnpaul Chiwetelu
b3b895a2a9 refactor(test): use canvasOps.clickEmptySpace in copyPaste spec (#10991)
## Summary

Replace two hardcoded blank-canvas click positions in
`copyPaste.spec.ts` with the existing
`comfyPage.canvasOps.clickEmptySpace()` helper.

## Changes

- **What**: Both `{ x: 50, y: 500 }` click literals in the `Copy paste
node, image paste onto LoadImage, image paste on empty canvas` test now
use `canvasOps.clickEmptySpace()` (which wraps
`DefaultGraphPositions.emptySpaceClick = { x: 35, y: 31 }`). Redundant
`await nextFrame()` calls dropped — the helper already awaits a frame
internally.

## Review Focus

Draft PR — need CI to confirm `(35, 31)` is a valid blank-canvas click
for the `load_image_with_ksampler` workflow used by this test. The
workflow places `LoadImage` at `[50, 50]` and `KSampler` at `[500, 50]`,
so `(35, 31)` should be clear of both. Locally the test was already
failing on `main` (pre-existing, unrelated), so CI is the source of
truth here. If CI fails, the fallback is to add a dedicated named
constant `emptyCanvasClick: { x: 50, y: 500 }` to
`DefaultGraphPositions` as originally proposed in the issue.

Fixes #10330

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10991-refactor-test-use-canvasOps-clickEmptySpace-in-copyPaste-spec-33d6d73d3650817aa3ccea44cb48c0ae)
by [Unito](https://www.unito.io)
2026-04-16 09:44:06 +00:00
Dante
e5c81488e4 fix: include focusMode in splitter refresh key to prevent panel resize (#11295)
## Summary

When the properties panel is open, toggling focus mode on then off
causes the panel to resize unexpectedly. The root cause is that
`splitterRefreshKey` in `LiteGraphCanvasSplitterOverlay.vue` does not
include `focusMode`, so the PrimeVue Splitter component instance is
reused across focus mode transitions and restores stale panel sizes from
localStorage.

Fix: add `focusMode` to `splitterRefreshKey` so the Splitter is
recreated when focus mode toggles.

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for focus mode toggle resizing properties
panel` | 🔴 Red | Proves the test catches the bug |
| `fix: include focusMode in splitter refresh key to prevent panel
resize` | 🟢 Green | Proves the fix resolves the bug |

## demo

### AS IS


https://github.com/user-attachments/assets/95f6a9e3-e4c7-4aba-8e17-0eee11f70491


### TO BE


https://github.com/user-attachments/assets/595eafcd-6a80-443d-a6f3-bb7605ed0758



## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] E2E regression test added in
`browser_tests/tests/focusMode.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11295-fix-include-focusMode-in-splitter-refresh-key-to-prevent-panel-resize-3446d73d365081b7bc3ac65338e17a8f)
by [Unito](https://www.unito.io)
2026-04-16 13:43:02 +09:00
Christian Byrne
5c07198acb fix: add validation to E2E coverage shard merge (#11290)
## Summary

Add a validation step after merging E2E coverage shards to detect data
loss and improve observability.

## Changes

- **What**: After `lcov -a` merges shard LCOVs, a new step parses merged
+ per-shard stats (source files, lines hit) and writes them to the
**GitHub Actions job summary** as a markdown table. If merged `LH`
(lines hit) is less than any single shard's `LH`, an error annotation is
emitted — this invariant should never be violated since merging should
only add coverage.
- Helps diagnose the 68% → 42% E2E coverage drop after sharding was
introduced.

## Review Focus

The step is informational — it emits `::error::` annotations but does
not `exit 1`, so it won't block the workflow. We can make it a hard
failure once we're confident the merge is stable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11290-fix-add-validation-to-E2E-coverage-shard-merge-3446d73d365081c8a942e92deba92006)
by [Unito](https://www.unito.io)
2026-04-15 21:39:51 -07:00
Terry Jia
6fb90b224d fix(load3d): restore missed hover state when viewer init is async (#11265)
## Summary
followup https://github.com/Comfy-Org/ComfyUI_frontend/pull/9520
mouseenter fires before load3d is created during async init
(getLoad3dAsync), so the STATUS_MOUSE_ON_VIEWER flag is never set.
This causes isActive() to return false after INITIAL_RENDER_DONE,
stopping the animation loop from calling controlsManager.update() and
making OrbitControls unresponsive on first open.

Track hover state in the composable and sync it to load3d after
creation.
2026-04-15 22:34:57 -04:00
pythongosssss
a8e1fa8bef test: add regression test for WEBP RIFF padding (#8527) (#11267)
## Summary

Add a regression test for #8527 (handle RIFF padding for odd-sized WEBP
chunks). The fix added + (chunk_length % 2) to the chunk-stride
calculation in getWebpMetadata so EXIF chunks following an odd-sized
chunk are still located correctly. There was no existing unit test
covering getWebpMetadata, so without a regression test the fix could
silently break in a future
  refactor. 

## Changes

- **What**: 
- New unit test file src/scripts/pnginfo.test.ts covering
getWebpMetadata's RIFF chunk traversal.
- Helpers build a minimal in-memory WEBP with one VP8 chunk of
configurable length followed by an EXIF chunk encoding workflow:<json>.
- Odd-length case (regression for #8527): without the % 2 padding
adjustment, the parser walks one byte short and returns {}.
- Even-length case: guards against an over-correction that always adds
1.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11267-test-add-regression-test-for-WEBP-RIFF-padding-8527-3436d73d36508117a66edf3cb108ded0)
by [Unito](https://www.unito.io)
2026-04-15 18:14:49 +00:00
pythongosssss
83ceef8cb3 test: add regression test for non-string serverLogs (#8460) (#11268)
## Summary

Add a regression test for #8460 (handle non-string `serverLogs` in error
report). The fix added `typeof error.serverLogs === 'string' ? ... :
JSON.stringify(...)` in `errorReportUtil.ts` so object-shaped logs no
longer render as `[object Object]`. There was no existing unit test for
`generateErrorReport`, so this regression could silently return.

## Changes

- **What**: New unit test file `src/utils/errorReportUtil.test.ts`
covering `generateErrorReport`'s `serverLogs` rendering.
- String case: verifies plain-string logs still appear verbatim and
`[object Object]` is absent.
- Object case (regression for #8460): verifies object logs are
JSON-stringified instead of coerced to `[object Object]`.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11268-test-add-regression-test-for-non-string-serverLogs-8460-3436d73d36508195a32fc559ab7ce5bb)
by [Unito](https://www.unito.io)
2026-04-15 18:14:17 +00:00
Christian Byrne
4885ef856c [chore] Update Comfy Registry API types from comfy-api@113318d (#11261)
## Automated API Type Update

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

- API commit: 113318d
- Generated on: 2026-04-15T04:26:33Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11261-chore-Update-Comfy-Registry-API-types-from-comfy-api-113318d-3436d73d3650816784d4efd98d6a665a)
by [Unito](https://www.unito.io)

Co-authored-by: bigcat88 <13381981+bigcat88@users.noreply.github.com>
2026-04-15 11:16:10 -07:00
Christian Byrne
873a75d607 test: add unit tests for usePainter composable (#11137)
## Summary

Add 25 behavioral unit tests for `usePainter` composable, bringing
coverage from 0% to ~35% lines / ~57% functions.

## Changes

- **What**: New test file `src/composables/painter/usePainter.test.ts`
covering widget sync, settings persistence, canvas sizing, brush display
scaling, serialization, restore, pointer event guards, and cursor
visibility.

## Review Focus

- Mock patterns: singleton factory mocks for stores, wrapper component
for lifecycle hooks
- Test coverage prioritization: focused on mount-time sync, reactive
watchers, and computed behavior rather than canvas pixel output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11137-test-add-unit-tests-for-usePainter-composable-33e6d73d36508147bde7e9c349c743ca)
by [Unito](https://www.unito.io)
2026-04-15 11:13:31 -07:00
pythongosssss
ecb6fbe8fb test: Add waitForWorkflowIdle & remove redundant nextFrame (#11264)
## Summary

More cleanup and reliability

## Changes

- **What**: 
- Add wait for idle
- Remove redundant nextFrames

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11264-test-Add-waitForWorkflowIdle-remove-redundant-nextFrame-3436d73d3650812c837ac7503ce0947b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-15 16:52:41 +00:00
Alexander Brown
52ccd9ed1a refactor: internalize nextFrame() into fixture/helper methods (#11166)
## Summary

Internalize `nextFrame()` calls into fixture/helper methods so spec
authors don't need to remember to call it after common operations.
`nextFrame()` waits for one `requestAnimationFrame` (~16ms) — an extra
call is always safe, making this a low-risk refactor.

## Changes

### Phase 1: `SettingsHelper.setSetting()`
`setSetting()` now calls `nextFrame()` internally. Removed 15 redundant
calls across 7 files.

### Phase 2: `CommandHelper.executeCommand()`
`executeCommand()` now calls `nextFrame()` internally. Removed 15
redundant calls across 7 files, including the now-redundant call in
`AppModeHelper.toggleAppMode()`.

### Phase 3: `WorkflowHelper.loadGraphData()`
New helper wraps `page.evaluate(loadGraphData)` + `nextFrame()`.
Migrated `SubgraphHelper.serializeAndReload()` and `groupNode.spec.ts`.

### Phase 4: `NodeReference` cleanup
Removed redundant `nextFrame()` from `copy()`, `convertToGroupNode()`,
`resizeNode()`, `dragTextEncodeNode2()`, and
`convertDefaultKSamplerToSubgraph()`. Removed 6 spec-level calls after
`node.click('title')`.

### Phase 5: `KeyboardHelper.press()` and `delete()`
New convenience methods that press a key and wait one frame. Converted
40 `canvas.press(key)` + `nextFrame()` pairs across 13 spec files.

### Phase 6: `ComfyPage.expectScreenshot()`
New helper combines `nextFrame()` + `toHaveScreenshot()`. Converted 45
pairs across 12 spec files.

## Total impact
- **~130 redundant `nextFrame()` calls eliminated** across ~35
spec/helper files
- **3 new helper methods** added (`loadGraphData`, `press`/`delete`,
`expectScreenshot`)
- **2 existing methods** enhanced (`setSetting`, `executeCommand`)

## What was NOT changed
- `performance.spec.ts` frame-counting loops (intentional)
- `ComfyMouse.ts` / `CanvasHelper.ts` (already internalized)
- `SubgraphHelper.packAllInteriorNodes()` (deliberate orchestration)
- Builder helpers (already internalized)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11166-refactor-internalize-nextFrame-into-fixture-helper-methods-33f6d73d3650817bb5f6fb46e396085e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-15 15:25:47 +00:00
Kelly Yang
92ad6fc798 test: address review nits for image compare E2E (#11260)
## Summary
A follow-up PR of #11196.

| # | Nit | Action | Reason |
| :--- | :--- | :--- | :--- |
| 1 | Replace `page.on('pageerror')` with request-wait | **Left as-is**
| The `pageErrors` array is an accumulator checked at the end via
`expect(pageErrors).toHaveLength(0)` – the goal is to assert that broken
image URLs don't surface as uncaught JS exceptions during the test run.
A request-wait can't substitute for that behavioral assertion, so the
listener pattern is intentional here. |
| 2 | Move helpers to a `vueNodes.getImageCompareHelper()` subclass |
**Left as-is** | Helpers such as `setImageCompareValue` and
`moveToPercentage` are only used in this file, making local
encapsulation enough. Extracting them to a page object would increase
the file/interface surface area and violate YAGNI; additionally,
`AGENTS.md` clearly states to "minimize the exported values of each
module. |
| 3 | Use `TestIds` enum for test ID strings | **Fixed** – added
`imageCompare` section to `TestIds` in `selectors.ts`; replaced all 8
inline string IDs in `imageCompare.spec.ts` with
`TestIds.imageCompare.*` references | The project already has a
`TestIds` convention for centralizing test IDs. Inline strings create
drift risk between the Vue component and the test file. |
| 4 | Move `expect.poll` bounding box check to helper/page object |
**Left as-is** | This logic already lives inside `moveToPercentage`,
which is a local helper. Moving it further to a page object is the same
refactor as #2 above. |
| 5 | Remove `// ---` style section header comments | **Fixed** –
removed all 8 divider blocks from `imageCompare.spec.ts` | Consistent
with project guidelines and your explicit preference. Test names already
describe what each block does. |
| 6 | Name magic numbers `400` and `350` | **Fixed** – introduced
`minWidth = 400` and `minHeight = 350` constants in the test |
Descriptive names make the constraint self-documenting and easier to
update if the workflow asset changes. |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E test code and shared
selector constants, with no production logic impacted.
> 
> **Overview**
> **E2E Image Compare tests now use centralized selectors.** Adds an
`imageCompare` section to `TestIds` and updates `imageCompare.spec.ts`
to reference `TestIds.imageCompare.*` instead of inline `data-testid`
strings.
> 
> Cleans up the spec by removing divider comments and naming the minimum
size magic numbers (`minWidth`, `minHeight`) used in the node sizing
assertion.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ece25be5cc. 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-11260-test-address-review-nits-for-image-compare-E2E-3436d73d365081a69cacc1fff390035a)
by [Unito](https://www.unito.io)
2026-04-15 10:50:44 -04:00
pythongosssss
06686a1f50 test: App mode - additional app mode coverage (#11194)
## Summary

Adds additional test coverage for empty state/welcome screen/connect
outputs/vue nodes auto switch

## Changes

- **What**: 
- add tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11194-test-App-mode-additional-app-mode-coverage-3416d73d365081ca91d0ed61de19f840)
by [Unito](https://www.unito.io)
2026-04-15 11:42:22 +00:00
jaeone94
693b8383d6 fix: missing-asset correctness follow-ups from #10856 (#11233)
Follow-up to #10856. Four correctness issues and their regression tests.

## Bugs fixed

### 1. ErrorOverlay model count reflected node selection

`useErrorGroups` exposed `filteredMissingModelGroups` under the public
name `missingModelGroups`. `ErrorOverlay.vue` read that alias to compute
its model count label, so selecting a node shrank the overlay total. The
overlay must always show the whole workflow's errors.

Exposed both shapes explicitly: `missingModelGroups` /
`missingMediaGroups` (unfiltered totals) and
`filteredMissingModelGroups` / `filteredMissingMediaGroups`
(selection-scoped). `TabErrors.vue` destructures the filtered variant
with an alias.


Before 


https://github.com/user-attachments/assets/eb848c5f-d092-4a4f-b86f-d22bb4408003

After 


https://github.com/user-attachments/assets/75e67819-c9f2-45ec-9241-74023eca6120



### 2. Bypass → un-bypass dropped url/hash metadata

Realtime `scanNodeModelCandidates` only reads widget values, so
un-bypass produced a fresh candidate without the url that
`enrichWithEmbeddedMetadata` had previously attached from
`graphData.models`. `MissingModelRow`'s download/copy-url buttons
disappeared after a bypass/un-bypass cycle.

Added `enrichCandidateFromNodeProperties` that copies
`url`/`hash`/`directory` from the node's own `properties.models` — which
persists across mode toggles — into each scanned candidate. Applied to
every call site of the per-node scan. A later fix in the same branch
also enforces directory agreement to prevent a same-name /
different-directory collision from stamping the wrong metadata.

Before 


https://github.com/user-attachments/assets/39039d83-4d55-41a9-9d01-dec40843741b

After 


https://github.com/user-attachments/assets/047a603b-fb52-4320-886d-dfeed457d833



### 3. Initial full scan surfaced interior errors of a muted/bypassed
subgraph container

`scanAllModelCandidates`, `scanAllMediaCandidates`, and the JSON-based
missing-node scan only check each node's own mode. Interior nodes whose
parent container was bypassed passed the filter.

Added `isAncestorPathActive(rootGraph, executionId)` to
`graphTraversalUtil` and post-filter the three pipelines in `app.ts`
after the live rootGraph is configured. The filter uses the execution-ID
path (`"65:63"` → check node 65's mode) so it handles both
live-scan-produced and JSON-enrichment-produced candidates.

Before


https://github.com/user-attachments/assets/3032d46b-81cd-420e-ab8e-f58392267602

After 


https://github.com/user-attachments/assets/02a01931-951d-4a48-986c-06424044fbf8




### 4. Bypassed subgraph entry re-surfaced interior errors

`useGraphNodeManager` replays `graph.onNodeAdded` for each existing
interior node when the Vue node manager initializes on subgraph entry.
That chain reached `scanSingleNodeErrors` via
`installErrorClearingHooks`' `onNodeAdded` override. Each interior
node's own mode was active, so the caller guards passed and the scan
re-introduced the error that the initial pipeline had correctly
suppressed.

Added an ancestor-activity gate at the top of `scanSingleNodeErrors`,
the single entry point shared by paste, un-bypass, subgraph entry, and
subgraph container activation. A later commit also hardens this guard
against detached nodes (null execution ID → skip) and applies the same
ancestor check to `isCandidateStillActive` in the realtime verification
callback.

Before


https://github.com/user-attachments/assets/fe44862d-f1d6-41ed-982d-614a7e83d441

After


https://github.com/user-attachments/assets/497a76ce-3caa-479f-9024-4cd0f7bd20a4



## Tests

- 6 unit tests for `isAncestorPathActive` (root, active,
immediate-bypass, deep-nested mute, unresolvable ancestor, null
rootGraph)
- 4 unit tests for `enrichCandidateFromNodeProperties` (enrichment,
no-overwrite, name mismatch, directory mismatch)
- 1 unit test for `scanSingleNodeErrors` ancestor guard (subgraph entry
replaying onNodeAdded)
- 2 unit tests for `useErrorGroups` dual export + ErrorOverlay contract
- 4 E2E tests:
- ErrorOverlay model count stays constant when a node is selected (new
fixture `missing_models_distinct.json`)
- Bypass/un-bypass cycle preserves Copy URL button (uses
`missing_models_from_node_properties`)
- Loading a workflow with bypassed subgraph suppresses interior missing
model error (new fixture `missing_models_in_bypassed_subgraph.json`)
- Entering a bypassed subgraph does not resurface interior missing model
error (shares the above fixture)

`pnpm typecheck`, `pnpm lint`, 206 related unit tests passing.

## Follow-up

Several items raised by code review are deferred as pre-existing tech
debt or scope-avoided refactors. Tracked via comments on #11215 and
#11216.

---
Follows up on #10856.
2026-04-15 10:58:24 +00:00
Christian Byrne
033b3dad3a feat: add Slack notification workflow for coverage improvements (#10977)
## Summary

Adds a GitHub Actions workflow + TypeScript script that posts to Slack
when a merged PR improves unit or E2E test coverage.

## Changes

- **What**: New `coverage-slack-notify.yaml` workflow triggered on push
to main. Compares current coverage against previous baselines, generates
Slack Block Kit payload with progress bars and milestone celebrations,
posts to `#p-frontend-automated-testing`.
- **Script**: `scripts/coverage-slack-notify.ts` — parses lcov files,
computes deltas, detects milestone crossings (every 5%), builds Slack
payload. Pure functions exported for testability.
- **Tests**: 26 unit tests in `scripts/coverage-slack-notify.test.ts`
covering all pure functions including edge cases (malformed lcov, exact
boundaries, zero coverage).

### Security hardening
- All `${{ }}` expressions moved from `run:` blocks to `env:` variables
- `SLACK_BOT_TOKEN` passed via env var, not inline
- Unique heredoc delimiter (timestamp-based) prevents payload injection
- `parseInt` fallback (`|| 0`) guards against malformed lcov
- PR regex anchored to first line of commit message

### Robustness
- `continue-on-error: true` on Slack post step (outage does not fail the
job)
- Baseline save guarded by `steps.unit-tests.outcome == success`
(prevents corrupt baselines on test failure)
- Channel ID commented for maintainability
- Top-level `text` field added for Slack mobile push notifications
- Author linked to GitHub profile instead of bare `@username`

## Review Focus

- Workflow step ordering and conditional logic
- Security of expression handling and secret management
- Slack payload structure and Block Kit formatting

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10977-feat-add-Slack-notification-workflow-for-coverage-improvements-33d6d73d3650819c8950f483c83f297c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-14 20:58:47 -07:00
Kelly Yang
66e8d570e7 test: expand Image Compare E2E and stabilize widget selectors (#11196)
## Summary

This PR extends **Image Compare** browser tests, adds **stable
`data-testid` hooks** on `WidgetImageCompare.vue`, and aligns slider
interactions with the **same viewport element** used by
`useMouseInElement`, with **layout polling** to reduce flakes.

## What this PR does

- [x] Adds **`data-testid="image-compare-viewport"`** on the compare
area root (the `containerRef` div) so E2E targets the real slider hit
region instead of a long Tailwind class chain or the `<img>` box alone.
- [x] Adds **`data-testid="image-compare-empty"`** on the no-images
branch so the empty state can be asserted **without hard-coded English**
UI text.
- [x] Adds a **smoke** test that the widget **shows both images and the
drag handle** after value injection, with **`waitForImagesLoaded`** (no
extra full-node screenshot to avoid duplicating the default-50 golden).
- [x] Extends slider coverage to **clamp at both edges** (**0%** and
**~100%**) using the viewport locator and **`expect.poll`** until
**`boundingBox()`** is valid before reading coordinates.
- [x] Updates **`moveToPercentage`** to **`expect.poll`** until the
target locator has a **non-empty layout box** before moving the mouse.
- [x] Routes **hover**, **preserve position**, and **25% / 75%
screenshot** mouse moves through **`image-compare-viewport`**
(consistent with `containerRef`).
- [x] Adds an E2E assertion that the **workflow ImageCompare node** size
is **at least 400×350** (matches the widget workflow asset).
- [x] Hardens the **broken image** case: **`page.on('pageerror')`** /
**`page.off`** in **`finally`**, **`http://127.0.0.1:1/...`** URLs for
fast failure, **`expect.soft`** on key UI invariants, and a hard
assertion that **no page errors** were recorded.
- [x] Extends the **large batch (20×20)** test to **page to the last
index** and assert **counters `20 / 20`**, **previous enabled**, and
**next disabled** on both sides.
- [x] Renames the clamp test title to use an **ASCII hyphen** (`0-100%`)
for easier grepping.

## Out of scope (unchanged in this PR)

- [ ] Replacing **`setImageCompareValue`**’s **`page.evaluate`** setup
with a full UI-driven path (would be a larger follow-up).

## Suggested title

`test: expand Image Compare E2E and stabilize widget selectors`

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily test and selector-hook changes; low production risk, with
only minor DOM attribute additions that could affect external test
tooling if relied upon.
> 
> **Overview**
> Improves Image Compare Playwright coverage and reduces flakiness by
driving slider interactions through a new stable
`data-testid="image-compare-viewport"` hook and polling for a valid
layout box before mouse moves.
> 
> Updates assertions to avoid localized text (new
`data-testid="image-compare-empty"`), adds smoke coverage that
images/handle render after value injection, validates slider clamping at
both 0% and ~100%, and extends screenshot tests to use the same viewport
target.
> 
> Hardens edge-case tests by ensuring broken image loads don’t raise
uncaught `pageerror`s, adds a minimum node size assertion, and extends
large-batch navigation checks through the final index and
disabled/enabled nav states.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ce3f7fbf8c. 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-11196-test-expand-Image-Compare-E2E-and-stabilize-widget-selectors-3416d73d3650814d8d2bf207943d6205)
by [Unito](https://www.unito.io)
2026-04-14 21:53:13 -04:00
Yourz
121ceda66d feat: implement website layout and homepage (#11168)
## Summary

Implement the website layout system and homepage with all sections,
reusable components, scroll-driven animations, and routing.

## Changes

- **What**:
- Reorganize components into `common/`, `home/`, `company/`, `product/`
directories
  - Add `BaseLayout` with shared `SiteNav` and `SiteFooter`
- Implement homepage sections: Hero, SocialProofBar, ProductShowcase,
UseCase, GetStarted, ProductCards, CaseStudySpotlight, BuildWhat
- Add reusable components: BrandButton, NodeBadge, ProductCard,
FooterLinkColumn, NavDesktopLink, MobileMenu
  - Add PPFormula font family, client logos, and icon assets
  - Add hero/footer logo frame sequences for scroll-driven animations
- Add `useFrameScrub` composable and `smoothScroll` (Lenis + GSAP
ScrollTrigger)
  - Add route config, nav config, and placeholder pages for all routes
  - Add Playwright e2e tests for homepage and navigation
- **Dependencies**: gsap, lenis, @astrojs/check

desktop
![Kapture 2026-04-11 at 19 36
10](https://github.com/user-attachments/assets/e4de2b1d-dea7-4c0b-9f76-134e9437998c)

mobile
![Kapture 2026-04-11 at 19 45
02](https://github.com/user-attachments/assets/4f83deba-229d-40e7-9476-2b3aa74894d1)



## Review Focus

- Component structure and naming conventions under `apps/website/`
- Scroll-driven animation approach (GSAP ScrollTrigger + Lenis smooth
scroll)
- Mobile responsive behavior (MobileMenu, ScrollTrigger matchMedia)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-15 09:25:41 +08:00
Dante
634d57809b test: add E2E tests for bottom panel core behaviors (#10814)
## Summary
- Add `browser_tests/tests/bottomPanel.spec.ts` with tests for behaviors
not covered by existing `bottomPanelLogs` and `bottomPanelShortcuts`
specs
- Tests cover: close button (X), tab persistence on re-open, resize
gutter visibility and drag, canvas interaction when panel is closed,
cross-panel switching (terminal <-> shortcuts), and registered tab
enumeration
- Extend `BottomPanel` fixture with `closeButton` and `resizeGutter`
locators

## Test plan
- [ ] `pnpm test:browser:local` passes all new tests in
`bottomPanel.spec.ts`
- [ ] Existing `bottomPanelLogs.spec.ts` and
`bottomPanelShortcuts.spec.ts` are unaffected
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10814-test-add-E2E-tests-for-bottom-panel-core-behaviors-3366d73d365081ea9b90c643897845fa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante <dante@danteui-MacStudio.local>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-14 23:33:11 +00:00
Dante
5807e03c74 test: expand E2E coverage for toolbox actions (#10968)
## Summary
- Adds `selectionToolboxMoreActions.spec.ts` with E2E coverage for
previously untested toolbox actions
- Covers: pin/unpin, minimize/expand, adjust size, copy, duplicate,
refresh button, align (top/left), distribute (horizontal/vertical),
alignment options hidden for single selection, multi-node bypass toggle
- Part of the FixIt Burndown test coverage initiative (toolbox actions)

## Test plan
- [ ] All new tests pass in CI
- [ ] No regressions in existing selectionToolbox tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10968-test-expand-E2E-coverage-for-toolbox-actions-33c6d73d3650811286cefdd0eb4f5242)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-14 23:20:45 +00:00
Christian Byrne
5c6be4ed66 chore: add .editorconfig for cross-editor consistency (#11128)
## Summary

Add `.editorconfig` for cross-editor formatting consistency, matching
existing oxfmt settings.

## Changes

- **What**: New `.editorconfig` with 2-space indent, LF line endings,
UTF-8, trailing whitespace trimming (except `.md`)

Fixes #11073

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11128-chore-add-editorconfig-for-cross-editor-consistency-33e6d73d365081faabf6fb996144940d)
by [Unito](https://www.unito.io)
2026-04-14 21:36:11 +00:00
Dante
6847c7ba2d fix: store promoted widget values per SubgraphNode instance (#10849)
## Summary

- Multiple SubgraphNode instances of the same blueprint share inner
nodes, causing promoted widget values to collide — the last configured
instance overwrites all previous values
- Add per-instance value storage (`_instanceWidgetValues`) on
SubgraphNode so each instance preserves its own promoted widget values
independently
- Restore `widgets_values` from serialized data into this per-instance
map after promoted views are created during configure

- Fixes #10146

## Root Cause

When loading a workflow with multiple SubgraphNode instances of the same
blueprint:

1. `LGraph.configure()` creates ONE shared Subgraph per blueprint (line
2625)
2. Each SubgraphNode instance calls `configure(instanceData)`
sequentially
3. `PromotedWidgetView.value` setter writes to the **shared inner node's
widget** (`promotedWidgetView.ts:199`)
4. The last instance's `configure()` overwrites all previous instances'
values

**Regression**: Introduced by PR #8594 (WidgetValueStore, v1.41.3) which
centralized widget state without per-instance scoping for shared
blueprints.

## Fix

- **SubgraphNode**: Add `_instanceWidgetValues` Map and
`_pendingWidgetsValues` for configure-time restoration
- **PromotedWidgetView getter**: Check instance map first before falling
back to widget store / inner node
- **PromotedWidgetView setter**: Write to instance map to avoid shared
inner node mutation
- **_internalConfigureAfterSlots**: Apply serialized `widgets_values` to
per-instance map after promoted views are created

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing tests for multi-instance subgraph widget value
collision` | 🔴 Red | Proves widget values collide across
instances |
| `fix: store promoted widget values per SubgraphNode instance` |
🟢 Green | Per-instance storage prevents collision |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit test: `preserves promoted widget values after configure with
different widgets_values`
- [x] All 253 existing subgraph tests pass
- [ ] Manual: load workflow from issue image → verify 3 subgraph
instances produce different results

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10849-fix-store-promoted-widget-values-per-SubgraphNode-instance-3386d73d3650815a8544f54adcc0d504)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante <dante@danteui-MacStudio.local>
2026-04-14 21:22:52 +00:00
AustinMroz
988a546721 Add missing dialog tests (#11133)
Leveraging the fancy coverage functionality of #10930, this PR aims to
add coverage to missing dialogue models.

This has proven quite constructive as many of the dialogues have since
been shown to be bugged.
- The APINodes sign in dialog that displays when attempting to run a
workflow containing Partner nodes while not logged in was intended to
display a list of nodes required to execute the workflow. The import for
this component was forgotten in the original commit (#3532) and the
backing component was later knipped
- Error dialogs resulting are intended to display the file responsible
for the error, but the prop was accidentally left out during the
refactoring of #3265
- ~~The node library migration (#8548) failed to include the 'Edit
Blueprint' button, and had incorrect sizing and color on the 'Delete
Blueprint' button.~~
- On request, the library button changes were spun out to a separate PR

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11133-Add-missing-dialog-tests-33e6d73d3650812cb142d610461adcd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-14 14:31:31 -07:00
AustinMroz
25ac047b58 Fix node library action buttons (#11232)
The node library migration (#8548) failed to include the 'Edit
Blueprint' button, and had incorrect sizing and color on the 'Delete
Blueprint' button.

- Re-add edit blueprint which was missed in the migration
- Fix incorrect sizing on delete blueprint
- Fix color (lucide uses background, not text)
- Migrate all action buttons use our capital 'B' Button component and
use standardized variants where possible

Spun out of #11133

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11232-Fix-node-library-action-buttons-3426d73d365081339cafc7457c0b5421)
by [Unito](https://www.unito.io)
2026-04-14 14:25:09 -07:00
pythongosssss
165984fe4c test: Improve speed of app mode input corruption test (#11236)
## Summary

Speeds up test that was timing out
https://9b579efd.comfyui-playwright-chromium.pages.dev/#?testId=b97e313f05078cede9be-5e6b75a76880fb6a5d96

## Changes

- **What**:
- load prebuilt workflows to reduce test time (17s -> 11s)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11236-test-Improve-speed-of-app-mode-input-corruption-test-3426d73d3650815b9475ec96dfbd7ad5)
by [Unito](https://www.unito.io)
2026-04-14 18:36:04 +00:00
pythongosssss
34a02a29c9 test: Remove unnecessary setup, UseNewMenu and waitForNodes calls (#11237)
## Summary

More simplification

## Changes

- **What**: 
- Remove more UseNewMenu settings calls
- Remove `await comfyPage.setup()`
- Remove `waitForNodes` in vue node tagged tests

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11237-test-Remove-unnecessary-setup-UseNewMenu-and-waitForNodes-calls-3426d73d36508198a100c218420d479c)
by [Unito](https://www.unito.io)
2026-04-14 17:40:23 +00:00
Alexander Brown
4bd655f625 feat: add PreToolUse hooks to enforce pnpm scripts (#11201)
## Summary

Add Claude Code PreToolUse hooks to block agents from bypassing
package.json scripts with raw tool invocations.

## Changes

- **What**: 15 PreToolUse hooks in `.claude/settings.json` that
intercept `npx`/`pnpx`/bare invocations of tsc, vitest, eslint,
prettier, oxlint, stylelint, and knip — redirecting agents to the
correct `pnpm` script (`pnpm typecheck`, `pnpm test:unit`, `pnpm lint`,
etc.)
- Also removes stale `permissions.allow` entries left over from a
debugging session

## Review Focus

- Pattern coverage: are there common agent invocations we're missing?
- The `if` field only supports simple `*` globs (no alternation), so
each pattern needs its own hook entry

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11201-feat-add-PreToolUse-hooks-to-enforce-pnpm-scripts-3416d73d365081a59a38c86ee4669aee)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-14 17:38:15 +00:00
pythongosssss
a09bb81b98 test: Auto wait for nodes after loadWorkflow in vue-node tests (#11238)
## Summary

Updates tests to auto wait for vue-nodes when loading a workflow in a
test with the vue-nodes tag

## Changes

- **What**: 
- If tag includes vue-nodes, wait 
- Remove all load->wait calls

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11238-test-Auto-wait-for-nodes-after-loadWorkflow-in-vue-node-tests-3426d73d3650810e8760c5601186fde8)
by [Unito](https://www.unito.io)
2026-04-14 17:30:49 +00:00
Christian Byrne
aeedb60628 fix(ci): resolve pnpm version in release workflow for frontend/ checkout path (#11224)
The release workflow checks out to `frontend/` subdirectory, but
`pnpm/action-setup` looks for `package.json` at the repo root by
default. This causes `No pnpm version is specified` failures.

Adds `package_json_file: frontend/package.json` so the action can read
the `packageManager` field. Same pattern used in #10972 for the
version-bump workflow.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11224-fix-ci-resolve-pnpm-version-in-release-workflow-for-frontend-checkout-path-3426d73d365081c28d16cb01bf8218ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 21:41:22 -07:00
Christian Byrne
43fb5a8b19 ops: add patch release support to ComfyUI release workflow (#11223)
## Summary

Add `release_type` input (`minor`/`patch`) to the release workflow so
patch releases can target the current production branch instead of
always preferring the next minor.

## Problem

When a patch release is needed for `core/1.42` but `core/1.43` already
exists, the resolver always prefers `1.43`. There was no way to do a
patch release with PyPI publish + ComfyUI PR for the current production
version.

## Changes

- Rename workflow from "Release: Bi-weekly ComfyUI" → "Release: ComfyUI"
(serves both cadences)
- Add `release_type` choice input: `minor` (default, bi-weekly) vs
`patch` (hotfix for current production version)
- Update `resolve-comfyui-release.ts` to read `RELEASE_TYPE` env var for
branch targeting
- Scheduled runs continue to work as before (default to `minor`)

## Usage

```bash
# Bi-weekly minor release (or just let the schedule run)
gh workflow run release-biweekly-comfyui.yaml --ref main

# Patch release for current production version
gh workflow run release-biweekly-comfyui.yaml --ref main --field release_type=patch
```

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11223-ops-add-patch-release-support-to-ComfyUI-release-workflow-3426d73d365081498c15ff978a7f1116)
by [Unito](https://www.unito.io)
2026-04-13 21:10:17 -07:00
Christian Byrne
c484c3984f fix: remove appendTo override from FormDropdown to restore correct positioning (#11147)
## Problem

PR #10338 added `useTransformCompatOverlayProps()` to FormDropdown and
FormDropdownMenuActions, which sets `appendTo: 'self'` in graph mode.
This breaks PrimeVue Popover positioning inside CSS-transformed
containers — the dropdown appears at incorrect Y positions.

## Root Cause

PrimeVue Popover with `appendTo: 'self'` renders the overlay inside the
component's DOM, inheriting parent CSS transforms. This causes the
popover to miscalculate its position when the parent has `transform:
scale()` or `translate()`.

## Fix

Remove the `appendTo` override from both FormDropdown and
FormDropdownMenuActions. PrimeVue defaults to `appendTo: 'body'`, which
teleports the popover to `<body>` — correctly positioning it outside any
CSS transform context.

- **Graph mode**: restores pre-#10338 behavior (`appendTo: 'body'`
default)
- **App mode**: unaffected — `'body'` is exactly what app mode needs
(prevents sidebar overflow clipping)

## Testing

- Existing unit tests pass (5/5)
- Typecheck clean
- Lint clean
- **E2E test rationale**: No E2E test added — this is a pure removal of
a prop override (reverting to PrimeVue defaults). The positioning bug
requires CSS transforms at specific viewport scales which are
impractical to assert reliably in Playwright. The existing
`subgraph-dom-widget-clipping` perf test exercises dropdown rendering in
transformed contexts and shows no regression.

Fixes #10499
Supersedes #11001 (temporary hotfix for backport)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 20:02:39 -07:00
Dante
2524846f5c fix: guard progress_text before canvas init (#11174)
## Summary
Prevent early `progress_text` websocket events from throwing before the
graph canvas is initialized.

## Changes
- **What**: Guard `handleProgressText()` until `canvasStore.canvas`
exists, and add a regression test for a startup-time `progress_text`
event arriving before `GraphCanvas` finishes initialization.

## Review Focus
Confirm this is the right guard point for the startup race between
`GraphView` websocket binding and `GraphCanvas` async setup, and that
progress text behavior is unchanged once the canvas is ready.

## Validation
- `pnpm exec eslint src/stores/executionStore.ts
src/stores/executionStore.test.ts`
- `pnpm exec vitest run src/stores/executionStore.test.ts -t "should
ignore progress_text before the canvas is initialized"`
- `pnpm test:unit -- --run src/stores/executionStore.test.ts` still
reports one unrelated isolated-file failure in
`nodeLocatorIdToExecutionId` on current `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11174-fix-guard-progress_text-before-canvas-init-3406d73d3650813dad23d511fb51add5)
by [Unito](https://www.unito.io)
2026-04-13 23:47:14 +00:00
Comfy Org PR Bot
12f578870e 1.44.4 (#11177)
Patch version increment to 1.44.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11177-1-44-4-3416d73d365081c0a2e0def7071c1441)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-13 23:19:20 +00:00
Alexander Brown
72eed86cea test: remove redundant setup/settings now handled by @vue-nodes fixture (#11195)
## Summary

Follow-up cleanup for #11184 — removes redundant test setup calls that
the `@vue-nodes` fixture now handles.

## Changes

- **What**: Remove 40 lines of redundant `setSetting`, `setup()`, and
`waitForNodes()` calls across 11 test files
  - `UseNewMenu: 'Top'` calls (already fixture default)
- `setup()` + `waitForNodes()` on default workflow (fixture already does
this for `@vue-nodes`)
- Page reload in `subgraphZeroUuid` (fixture applies VueNodes.Enabled
server-side before navigation)

## Review Focus

Each removal was verified against the fixture's `setupSettings()`
defaults (ComfyPage.ts:420-442) and the `@vue-nodes` auto-setup (lines
454-456). Tests that call `setup()`/`waitForNodes()` after
`loadWorkflow()` or `page.evaluate()` were intentionally kept.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11195-test-remove-redundant-setup-settings-now-handled-by-vue-nodes-fixture-3416d73d36508154827df116a97e9130)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-13 22:15:45 +00:00
Benjamin Lu
719ed16d32 fix: track workspace subscription success on immediate subscribe (#11130)
## Summary

Track GTM `subscription_success` when a workspace subscription completes
synchronously in the dialog. The async billing-operation path already
emitted telemetry; the missing gap was the immediate `subscribed`
response.

## Changes

- **What**: Add the missing GTM success emission to both synchronous
workspace subscribe success branches while preserving the existing
toast, billing refresh, and dialog close behavior.

## Review Focus

Verify the synchronous `response.status === "subscribed"` workspace
dialog paths are the only missing frontend success emissions, while the
async billing-operation telemetry path remains unchanged.

This PR intentionally stays minimal. It does not add new browser
coverage yet; the previous component-level unit test was more
implementation-coupled than this fix justified, and a better long-term
test would be a higher-level workspace billing flow test once we have a
cleaner harness.
2026-04-13 14:38:03 -07:00
pythongosssss
5899a9392e test: Simplify vue node/menu test setup (#11184)
## Summary
Simplifies test setup for common settings

## Changes

- **What**: 
- add vue-nodes tag to auto enable nodes 2.0
- remove UseNewMenu Top as this is default

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11184-test-Simplify-vue-node-menu-test-setup-3416d73d3650815487e0c357d28761fe)
by [Unito](https://www.unito.io)
2026-04-13 20:43:25 +00:00
Christian Byrne
e39468567a fix: check server feature flags for progress_text binary format (#10996)
## Problem

API node generation status text (sent via `progress_text` WebSocket
binary messages) was not showing on local ComfyUI, but worked on cloud.

## Root Cause

The binary decoder for `progress_text` messages (eventType 3) checked
`getClientFeatureFlags()?.supports_progress_text_metadata` — the
**client's own flags** — to decide whether to parse the new format with
`prompt_id`. Since the client always advertises
`supports_progress_text_metadata: true`, it always tried to parse the
new wire format:

```
[4B event_type][4B prompt_id_len][prompt_id][4B node_id_len][node_id][text]
```

But the backend PR that adds `prompt_id` to the binary message
([ComfyUI#12540](https://github.com/Comfy-Org/ComfyUI/pull/12540)) was
**closed without merging**, so local ComfyUI still sends the legacy
format:

```
[4B event_type][4B node_id_len][node_id][text]
```

The decoder misinterpreted the `node_id_len` as `prompt_id_len`,
consuming the actual node_id bytes as a prompt_id, then producing
garbled `nodeId` and `text` — silently dropping all progress text
updates via the catch handler.

Cloud worked because the cloud backend supports and echoes the feature
flag.

## Fix

One-line change: check `serverFeatureFlags.value` (what the server
echoed back) instead of `getClientFeatureFlags()` (what the client
advertises).

## Tests

Added 3 tests covering:
- Legacy format parsing when server doesn't support the flag
- New format parsing when server does support the flag  
- Corruption regression test: client advertises support but server
doesn't

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10996-fix-check-server-feature-flags-for-progress_text-binary-format-33d6d73d365081449a0dc918358799de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 18:22:30 +00:00
pythongosssss
a373633ab2 refactor: fix lint errors in tests (#11182)
## Summary

Fix tests failing lint

## Changes

- **What**:
- Fix relative imports
- Fix test not using comfyPage

## Review Focus

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

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

## Screenshots (if applicable)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11182-refactor-fix-lint-errors-in-tests-3416d73d3650812fbf1bc88554c57de2)
by [Unito](https://www.unito.io)
2026-04-13 15:50:11 +00:00
jaeone94
521019d173 fix: exclude muted/bypassed nodes from missing asset detection (#10856)
## Summary

Muted and bypassed nodes are excluded from execution but were still
triggering missing model/media/node warnings. This PR makes the error
system mode-aware: muted/bypassed nodes no longer produce missing asset
errors, and all error lifecycle events (mode toggle, deletion, paste,
undo, tab switch) are handled consistently.

- Fixes Comfy-Org/ComfyUI#13256

## Behavioral notes

- **Tab switch overlay suppression (intentional)**: Switching back to a
workflow with missing assets no longer re-shows the error overlay. This
reverses the behavior introduced in #10190. The error state is still
restored silently in the errors tab — users can access it via the
properties panel without being interrupted by the overlay on every tab
switch.

## Changes

### 1. Scan filtering

- `scanAllModelCandidates`, `scanAllMediaCandidates`,
`scanMissingNodes`: skip nodes with `mode === NEVER || BYPASS`
- `collectMissingNodes` (serialized data): skip error reporting for
muted/bypassed nodes while still calling `sanitizeNodeName` for safe
`configure()`
- `collectEmbeddedModelsWithSource`: skip muted/bypassed nodes;
workflow-level `graphData.models` only create candidates when active
nodes exist
- `enrichWithEmbeddedMetadata`: filter unmatched workflow-level models
when all referencing nodes are inactive

### 2. Realtime mode change handling

- `useErrorClearingHooks.ts` chains `graph.onTrigger` to detect
`node:property:changed` (mode)
- Deactivation (active → muted/bypassed): remove missing
model/media/node errors for the node
- Activation (muted/bypassed → active): scan the node and add confirmed
errors, show overlay
- Subgraph container deactivation: remove all interior node errors
(execution ID prefix match)
- Subgraph container activation: scan all active interior nodes
recursively
- Subgraph interior mode change: resolve node via
`localGraph.getNodeById()` then compute execution ID from root graph

### 3. Node deletion

- `graph.onNodeRemoved`: remove missing model/media/node errors for the
deleted node
- Handle `node.graph === null` at callback time by using
`String(node.id)` for root-level nodes

### 4. Node paste/duplicate

- `graph.onNodeAdded`: scan via `queueMicrotask` (deferred until after
`node.configure()` restores widget values)
- Guard: skip during `ChangeTracker.isLoadingGraph` (undo/redo/tab
switch handled by pipeline)
- Guard: skip muted/bypassed nodes

### 5. Workflow tab switch optimization

- `skipAssetScans` option in `loadGraphData`: skip full pipeline on tab
switch
- Cache missing model/media/node state per workflow via
`PendingWarnings`
- `beforeLoadNewGraph`: save current store state to outgoing workflow's
`pendingWarnings`
- `showPendingWarnings`: restore cached errors silently (no overlay),
always sync missing nodes store (even when null)
- Preserve UI state (`fileSizes`, `urlInputs`) on tab switch by using
`setMissingModels([])` instead of `clearMissingModels()`
- `MissingModelRow.vue`: fetch file size on mount via
`fetchModelMetadata` memory cache

### 6. Undo/redo overlay suppression

- `silentAssetErrors` option propagated through pipeline →
`surfaceMissingModels`/`surfaceMissingMedia` `{ silent }` option
- `showPendingWarnings` `{ silent }` option for missing nodes overlay
- `changeTracker.ts`: pass `silentAssetErrors: true` on undo/redo

### 7. Error tab node filtering

- Selected node filters missing model/media card contents (not just
group visibility)
- `isAssetErrorInSelection`: resolve execution ID → graph node for
selection matching
- Missing nodes intentionally unfiltered (pack-level scope)
- `hasMissingMediaSelected` added to `RightSidePanel.vue` error tab
visibility
- Download All button: show only when 2+ downloadable models exist

### 8. New store functions

- `missingModelStore`: `addMissingModels`, `removeMissingModelsByNodeId`
- `missingMediaStore`: `addMissingMedia`, `removeMissingMediaByNodeId`
- `missingNodesErrorStore`: `removeMissingNodesByNodeId`
- `missingModelScan`: `scanNodeModelCandidates` (extracted single-node
scan)
- `missingMediaScan`: `scanNodeMediaCandidates` (extracted single-node
scan)

### 9. Test infrastructure improvements

- `data-testid` on `RightSidePanel.vue` tabs (`panel-tab-{value}`)
- Error-related TestIds moved from `dialogs` to `errorsTab` namespace in
`selectors.ts`
- Removed unused `TestIdValue` type
- Extracted `cleanupFakeModel` to shared `ErrorsTabHelper.ts`
- Renamed `openErrorsTabViaSeeErrors` → `loadWorkflowAndOpenErrorsTab`
- Added `aria-label` to pencil edit button and subgraph toggle button

## Test plan

### Unit tests (41 new)

- Store functions: `addMissing*`, `removeMissing*ByNodeId`
- `executionErrorStore`: `surfaceMissing*` silent option
- Scan functions: muted/bypassed filtering, `scanNodeModelCandidates`,
`scanNodeMediaCandidates`
- `workflowService`: `showPendingWarnings` silent, `beforeLoadNewGraph`
caching

### E2E tests (17 new in `errorsTabModeAware.spec.ts`)

**Missing nodes**
- [x] Deleting a missing node removes its error from the errors tab
- [x] Undo after bypass restores error without showing overlay

**Missing models**
- [x] Loading a workflow with all nodes bypassed shows no errors
- [x] Bypassing a node hides its error, un-bypassing restores it
- [x] Deleting a node with missing model removes its error
- [x] Undo after bypass restores error without showing overlay
- [x] Pasting a node with missing model increases referencing node count
- [x] Pasting a bypassed node does not add a new error
- [x] Selecting a node filters errors tab to only that node

**Missing media**
- [x] Loading a workflow with all nodes bypassed shows no errors
- [x] Bypassing a node hides its error, un-bypassing restores it
- [x] Pasting a bypassed node does not add a new error
- [x] Selecting a node filters errors tab to only that node

**Subgraph**
- [x] Bypassing a subgraph hides interior errors, un-bypassing restores
them
- [x] Bypassing a node inside a subgraph hides its error, un-bypassing
restores it

**Workflow switching**
- [x] Does not resurface error overlay when switching back to workflow
with missing nodes
- [x] Restores missing nodes in errors tab when switching back to
workflow

# Screenshots


https://github.com/user-attachments/assets/e0a5bcb8-69ba-4120-ab7f-5c83e4cfc3c5



## Follow-up work

- Extract error-detection computed properties from `RightSidePanel.vue`
into a composable (e.g. `useErrorsTabVisibility`)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 12:51:19 +00:00
Kelly Yang
bd82c855e0 test: add minimap E2E tests for graph content and click-to-navigate (#10738)
## Summary

Adds Playwright E2E tests verifying that 
1. the minimap canvas renders node content
2. clears when the graph is empty
3. correctly navigates the main canvas on click

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10738-test-add-minimap-E2E-tests-for-graph-content-and-click-to-navigate-3336d73d365081eb955ce711b3efc57f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to adding `data-testid` attributes to
the minimap UI and expanding Playwright E2E assertions, with no
production behavior changes expected.
> 
> **Overview**
> Strengthens minimap E2E coverage by switching existing assertions from
CSS selectors to new `data-testid`-based selectors and adding helper
utilities for canvas/overlay interactions.
> 
> Adds new Playwright tests that verify the minimap canvas renders
content when nodes exist, clears when the graph is emptied, and that
clicking the minimap pans the main canvas (including a
post-`fitViewToSelectionAnimated` tolerance check).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
06e7542af1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 04:28:04 +00:00
Kelly Yang
5b7ef3fe21 test: Painter Widget E2E Test Plan (#10846)
### Summary of Improvements

* **Custom Test Coverage Extension**: Enhanced the Painter widget E2E
test suite by refactoring logic for better maintainability and
robustness.
* **Stable Component Targeting**: Introduced
`data-testid="painter-dimension-text"` to `WidgetPainter.vue`, providing
a reliable, non-CSS-dependent locator for canvas size verification.
* **Improved Test Organization**: Reorganized existing test scenarios
into logical categories using `test.describe` blocks (Drawing, Brush
Settings, Canvas Size Controls, etc.).
* **Asynchronous Helper Integration**: Converted `hasCanvasContent` to
an asynchronous helper and unified its usage across multiple test cases
to eliminate redundant pixel-checking logic.
* **Locator Resilience**: Updated Reka UI slider interaction logic to
use more precise targeting (`:not([data-slot])`), preventing ambiguity
and improving test stability.
* **Scenario Refinement**: Updated the `pointerup` test logic to
accurately reflect pointer capture behavior when interactions occur
outside the canvas boundaries.
* **Enhanced Verification Feedback**: Added descriptive error messages
to `expect.poll` assertions to provide clearer context on potential
failure points.
* **Standardized Tagging**: Restored the original tagging strategy
(including `@smoke` and `@screenshot` tags) to ensure tests are
categorized correctly for CI environments.

### Red-Green Verification

| Commit | CI Status | Purpose |
| :--- | :--- | :--- |
| `test: refactor painter widget e2e tests and address review findings`
| 🟢 Green | Addresses all E2E test quality and stability issues from
review findings. |

### Test Plan

- [x] **Quality Checks**: `pnpm format`, `pnpm lint`, and `pnpm
typecheck` verified as passing.
- [x] **Component Integration**: `WidgetPainter.vue` `data-testid`
correctly applied and used in tests.
- [x] **Helper Reliability**: `hasCanvasContent` correctly identifies
colored pixels and returns a promise for `expect.poll`.
- [x] **Locator Robustness**: Verified Reka slider locators correctly
exclude internal thumb spans.
- [x] **Boundary Interaction**: Verified `pointerup` correctly ends
strokes when triggered outside the viewport.
- [x] **Tagging Consistency**: Verified `@smoke` and `@screenshot` tags
are present in the final test suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10846-test-Painter-Widget-E2E-Test-Plan-3386d73d365081deb70fe4afbd417efb)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily adds/refactors Playwright E2E tests and stable `data-testid`
hooks, with no changes to Painter drawing logic. Risk is limited to
potential test brittleness or minor UI attribute changes.
> 
> **Overview**
> Expands the Painter widget Playwright suite with new grouped scenarios
covering drawing/erasing behavior, tool switching, brush inputs, canvas
resizing (including preserving drawings), clear behavior, and
serialization/upload flows (including failure toast).
> 
> Refactors the tests to use a shared `@e2e/helpers/painter` module
(`drawStroke`, `hasCanvasContent`, `triggerSerialization`), improves
stability via role/testid-based locators and clearer `expect.poll`
messaging, and adds `data-testid` attributes (e.g.,
`painter-clear-button`, `painter-*-row`, `painter-dimension-text`) to
`WidgetPainter.vue` to avoid CSS-dependent selectors.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
053a8e9ed2. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 00:13:04 -04:00
Kelly Yang
85de833776 test: add E2E tests for ImageCompare widget (#10767)
## Summary
Add E2E tests for ImageCompare widget
Covers slider interaction, batch navigation, single-image modes, visual
regression screenshots, and edge cases for the ImageCompare Vue node
widget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10767-test-add-E2E-tests-for-ImageCompare-widget-3346d73d365081c6bfc6fbd97fa04e4d)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Adds Playwright E2E coverage and screenshot assertions only; main risk
is increased CI runtime/flakiness due to additional image-loading and
hover/position polling.
> 
> **Overview**
> Adds a new Playwright E2E suite for the ImageCompare Vue widget
(tagged `@widget`) that programmatically sets widget values and asserts
rendering for empty, single-image, and dual-image states.
> 
> Expands coverage to **slider behavior** (default 50%, hover movement,
clamping, persistence) using polling on inline `clip-path`/handle
position, and adds **batch navigation** tests for multi-image
before/after sets.
> 
> Introduces **visual regression screenshots** at default and specific
slider positions, plus edge-case tests for broken URLs, rapid updates
resetting batch index, legacy string values, and custom alt text.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2c65440384. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 00:09:50 -04:00
Kelly Yang
cab46567c0 test: add E2E tests for ImageCropV2 widget (#10737)
## Summary
Adds Playwright E2E tests for the ImageCropV2 widget covering 
1. the empty state (no source image)
2. default control rendering
3. source image display with crop overlay
4. drag-to-reposition behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10737-test-add-E2E-tests-for-ImageCropV2-widget-3336d73d365081b28ed9db63e5df383e)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: primarily adds Playwright E2E coverage and introduces
`data-testid` attributes for more stable selectors, with no changes to
core crop behavior.
> 
> **Overview**
> Adds new Playwright E2E coverage for the `ImageCropV2` Vue-node
widget, including workflows/fixtures for a disconnected input and a
`LoadImage -> ImageCropV2 -> PreviewImage` pipeline.
> 
> Tests validate the empty state and default controls, verify the crop
overlay renders after execution with screenshot assertions, and exercise
drag-to-reposition by dispatching pointer events and asserting the
widget’s crop value updates.
> 
> Updates `WidgetImageCrop.vue` to add `data-testid` hooks (empty
state/icon and crop overlay) to make the E2E selectors stable.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9f29272742. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-12 23:58:01 -04:00
Comfy Org PR Bot
63435bdb34 1.44.3 (#11170)
Patch version increment to 1.44.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11170-1-44-3-3406d73d365081799aa4e189009d123b)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-12 23:11:20 +00:00
Kelly Yang
20255da61f feat(load3d): add optional HDRI environment lighting to 3D preview nodes (#10818)
## Summary

Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel

## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model

## Screenshots



https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
> 
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
> 
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
> 
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-12 05:55:48 -04:00
Christian Byrne
c2dba8f4ee chore(#11080): consolidate duplicate rgbToHSL — use shared colorUtil (#11134)
## Summary

Consolidate duplicate `rgbToHSL` implementation — mask editor now uses
the shared `colorUtil.ts` version instead of its own copy.

## Changes

- Export `rgbToHsl` from `src/utils/colorUtil.ts` (was private)
- Replace 30-line local `rgbToHSL` in `useCanvasTools.ts` with a 2-line
wrapper that imports from `colorUtil.ts` and scales the return values
from 0-1 to degree/percentage

## Testing

### Automated

- All 176 existing tests pass (`colorUtil.test.ts` + `maskeditor/`
suite)
- No new tests needed — behavior is identical

### E2E Verification Steps

1. Open any image in the mask editor
2. Select the magic wand / color picker tool
3. Use HSL-based color matching — results should be identical to before

## Review Focus

The canonical `rgbToHsl` returns normalized 0-1 values while the mask
editor needs degree/percentage scale (h: 0-360, s: 0-100, l: 0-100). The
local wrapper handles this conversion.

Fixes #11080

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11134-chore-11080-consolidate-duplicate-rgbToHSL-use-shared-colorUtil-33e6d73d36508120bbd8f444f5cc94b6)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-12 01:40:55 +00:00
Alexander Brown
6f579c5992 fix: enable playwright/no-force-option lint rule (#11164)
## Summary

Enable the previously disabled `playwright/no-force-option` lint rule at
error level and resolve all 29 violations across 10 files.

## Changes

### Lint rule
- `.oxlintrc.json`: `playwright/no-force-option` changed from `off` to
`error`

### Shared utility
- `CanvasHelper.ts`: Add `mouseClickAt()` and `mouseDblclickAt()`
methods that convert canvas-element-relative positions to absolute page
coordinates and use `page.mouse` APIs, avoiding Playwright's locator
actionability checks that fail when Vue DOM overlays sit above the
`<canvas>` element

### Force removal (20 violations)
- `selectionToolboxActions.spec.ts`: Remove `force: true` from 8 toolbox
button clicks (the `pointer-events: none` splitter overlay does not
intercept `elementFromPoint()`)
- `selectionToolboxSubmenus.spec.ts`: Remove `force: true` from 2
popover menu item clicks
- `BuilderSelectHelper.ts`: Remove `force: true` from 2 widget/node
clicks (builder mode does not disable pointer events)
- `linkInteraction.spec.ts`: Remove `force: true` from 3 slot `dragTo()`
calls (`::after` pseudo-elements do not intercept `elementFromPoint()`)
- `SidebarTab.ts`: Remove `force: true` from toast dismissal (`.catch()`
already handles failures)
- `nodeHelp.spec.ts`: Remove `force: true` from info button click
(preceding `toBeVisible()` assertion is sufficient)

### Rewrites (3 violations)
- `integerWidget.spec.ts`: Replace force-clicking disabled buttons with
`toBeDisabled()` assertions
- `Topbar.ts`: Replace force-click with `waitFor({ state: 'visible' })`
after hover

### Canvas coordinate clicks (9 violations)
- `litegraphUtils.ts`: Convert `NodeReference.click()` and
`navigateIntoSubgraph()` to use
`canvasOps.mouseClickAt()`/`mouseDblclickAt()`
- `subgraphPromotion.spec.ts`: Convert 3 right-click canvas calls to
`canvasOps.mouseClickAt()`
- `selectionToolboxSubmenus.spec.ts`: Convert 1 canvas dismiss-click to
`canvasOps.mouseClickAt()`

## Rationale

The original `force: true` usages were added defensively based on
incorrect assumptions about the `z-999 pointer-events: none` splitter
overlay intercepting Playwright's actionability checks. In reality,
`elementFromPoint()` skips elements with `pointer-events: none`, so the
overlay is transparent to Playwright's hit-test.

For canvas coordinate clicks, `force: true` on a locator does not tunnel
through DOM overlays — it only skips Playwright's preflight checks.
`page.mouse.click()` is the correct API for coordinate-based canvas
interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11164-fix-enable-playwright-no-force-option-lint-rule-33f6d73d365081e78601c6114121d272)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 19:59:34 +00:00
Dante
e729e5edb8 fix: place cloned node above original in Vue renderer (#10361)
## Summary

Cloned/pasted nodes in Node 2.0 (Vue renderer) mode now appear above the
original node instead of behind it.

## Root Cause

The legacy LiteGraph canvas renderer uses array ordering for z-ordering:
nodes are stored in `graph._nodes` and drawn sequentially, so newly
added nodes (appended to the end) are automatically drawn on top. There
is no explicit z-index.

The Vue renderer (Node 2.0) uses explicit CSS `z-index` for node
ordering. New nodes default to `zIndex: 0` in `layoutMutations.ts`. When
a node has been interacted with, `bringNodeToFront` raises its z-index.
A cloned node at z-index 0 therefore appears behind any previously
interacted node.

The alt-click clone path in `LGraphNode.vue` already handles this
correctly by calling `bringNodeToFront()` after cloning. However, the
menu clone and keyboard paste paths go through `_deserializeItems` in
`LGraphCanvas.ts`, which does not set z-index for new nodes.

| Clone method | Legacy renderer | Vue renderer (before fix) | Vue
renderer (after fix) |
|---|---|---|---|
| Alt-click drag | On top (array order) | On top (`bringNodeToFront`
called) | On top |
| Right-click menu Clone | On top (array order) | Behind original
(z-index 0) | On top |
| Ctrl+C / Ctrl+V | On top (array order) | Behind original (z-index 0) |
On top |

## Steps to Reproduce

1. Enable Node 2.0 mode (Vue renderer) in settings
2. Add any node to the canvas
3. Click or drag the node (raises its z-index via `bringNodeToFront`)
4. Right-click the node and select "Clone"
5. **Expected**: Cloned node appears above the original, immediately
draggable
6. **Actual**: Cloned node appears behind the original; user must move
the original to access the clone

## Changes

After `batchUpdateNodeBounds` in `_deserializeItems`, calls
`bringNodeToFront` for each newly created node so they receive a z-index
above all existing nodes.

## Side Effect Analysis

Checked all call sites of `_deserializeItems`:

1. **Initial graph load / workflow open**: `loadGraphData` in `app.ts`
does NOT call `_deserializeItems`. Workflow loading goes through
`LGraph.configure()` which directly adds nodes and links. The layout
store is initialized separately via `initializeFromLiteGraph`. No side
effect.

2. **Paste from clipboard (Ctrl+V)**: Both `usePaste.ts` (line 52) and
`pasteFromClipboard` (line 4080) call `_deserializeItems`. Pasted nodes
appearing on top is the correct and desired behavior. No issue.

3. **Undo/Redo**: `ChangeTracker.updateState()` calls
`app.loadGraphData()`, which does a full graph reconfigure -- it does
NOT go through `_deserializeItems`. No side effect.

4. **Subgraph blueprint addition**: `litegraphService.ts` (line 906)
calls `_deserializeItems` when adding subgraph blueprints from the node
library. These are freshly placed nodes that should appear on top.
Desired behavior.

5. **Alt-click clone in LGraphNode.vue**: This path calls
`LGraphCanvas.cloneNodes()` -> `_deserializeItems()`, then separately
calls `bringNodeToFront()` again on line 433 of `LGraphNode.vue`. The
second call is now redundant (the node is already at max z-index), but
harmless -- `bringNodeToFront` finds the current max, adds 1, and sets.
The z-index will increment from N to N+1 on the second call. This is a
minor redundancy, not a bug.

6. **Performance**: `bringNodeToFront` iterates all nodes in the layout
store once per call (O(m)) to find max z-index. For n new nodes, the
total cost is O(n*m). In practice, clone/paste operations involve a
small number of nodes (typically 1-10), so this is negligible. For
extremely large pastes (100+ nodes), each call also increments the max
by 1, so z-indices will be sequential (which is actually a reasonable
stacking order).

7. **layoutStore availability**: `layoutStore` is a module-level
singleton (`new LayoutStoreImpl()`) -- not a Pinia store -- so it is
always available. The `useLayoutMutations()` composable is a plain
function returning an object of closures over `layoutStore`. It does not
require Vue component context. No risk of runtime errors.

8. **Legacy renderer (non-Vue mode)**: When Node 2.0 mode is disabled,
the layout store still exists but is not used for rendering. Calling
`bringNodeToFront` will update z-index values in the Yjs document that
are never read. This is harmless.

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `6894b99` `test:` | RED | Test asserts cloned node z-index > original.
Fails with `expected 0 to be greater than 5`. |
| `3567469` `fix:` | GREEN | Calls `bringNodeToFront` for each new node
in `_deserializeItems`. Test passes. |

Fixes #10307

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 12:12:37 +00:00
Alexander Brown
3043b181d7 refactor: extract composables from VTU holdout components, complete VTL migration (#10966)
## Summary

Extract internal logic from the 2 remaining VTU holdout components into
composables, enabling full VTL migration.

## Changes

- **What**: Extract `useProcessedWidgets` from `NodeWidgets.vue`
(486→135 LOC) and `useWidgetSelectItems`/`useWidgetSelectActions` from
`WidgetSelectDropdown.vue` (563→170 LOC). Rewrite both component test
files as composable unit tests + slim behavioral VTL tests. Remove
`@vue/test-utils` devDependency.
- **Dependencies**: Removes `@vue/test-utils`

## Review Focus

- Composable extraction is mechanical — no logic changes, just moving
code into testable units
- `useProcessedWidgets` handles widget deduplication, promotion border
styling, error detection, and identity resolution (~290 LOC)
- `useWidgetSelectItems` handles the full computed chain from widget
values → dropdown items including cloud asset mode and multi-output job
resolution (~350 LOC)
- `useWidgetSelectActions` handles selection resolution and file upload
(~120 LOC)
- 40 new composable-level unit tests replace 13 `wrapper.vm.*` accesses
across the 2 holdout files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10966-refactor-extract-composables-from-VTU-holdout-components-complete-VTL-migration-33c6d73d36508148a3a4ccf346722d6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:04:16 -07:00
Alexander Brown
8c9328c1b2 feat: add eslint-plugin-playwright via oxlint JS plugins (#11136)
## Summary

Add eslint-plugin-playwright as an oxlint JS plugin scoped to
browser_tests/, enforcing Playwright best practices at lint time.

## Changes

- **What**: Configure eslint-plugin-playwright@2.10.1 via oxlint's alpha
`jsPlugins` field (`.oxlintrc.json` override scoped to
`browser_tests/**/*.ts`). 18 recommended rules +
`prefer-native-locators` + `require-to-pass-timeout` at error severity.
All 173 initial violations resolved (config, auto-fix, manual fixes).
`no-force-option` set to off — 28 violations need triage (canvas overlay
workarounds vs unnecessary force) in a dedicated PR.
- **Dependencies**: `eslint-plugin-playwright@^2.10.1` (devDependency,
required by oxlint jsPlugins at runtime)

## Review Focus

- `.oxlintrc.json` override structure — this is the first use of
oxlint's JS plugins alpha feature in this repo
- Manual fixes in spec files: `waitForSelector` → `locator.waitFor`,
deprecated page methods → locator equivalents, `toPass()` timeout
additions
- Compound CSS selectors replaced with `.and()` (Playwright native
locator composition) to avoid `prefer-native-locators` suppressions
- Lint script changes in `package.json` to include `browser_tests/` in
oxlint targets

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-11 01:25:14 +00:00
Christian Byrne
577f373cde fix: auto fit-to-view on first subgraph entry (#10995)
## Summary

Auto-fit viewport to subgraph content on first entry so interior nodes
are immediately visible.

## Changes

- **What**: On cache miss in `restoreViewport()`, call `fitView()` via
`requestAnimationFrame` instead of silently returning. Existing
cache-hit path (revisiting a subgraph) is unchanged.

## Review Focus

The `anyItemOverlapsRect` guard in `app.ts` (workflow load path) is
intentionally **not** touched — it serves a different purpose
(respecting `extra.ds` on workflow load). This fix only affects subgraph
navigation transitions where there is no saved viewport to respect.

Fixes #8173

## Screenshots (if applicable)

N/A — viewport positioning fix; before: empty canvas on subgraph entry,
after: nodes visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10995-fix-auto-fit-to-view-on-first-subgraph-entry-33d6d73d365081f3a9b3cc2124979624)
by [Unito](https://www.unito.io)
2026-04-11 00:45:38 +00:00
1133 changed files with 75563 additions and 11460 deletions

View File

@@ -1,9 +1,86 @@
{
"permissions": {
"allow": [
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
"Bash(pnpx vitest run \"draftCacheV2.property\")",
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -0,0 +1,695 @@
---
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

@@ -0,0 +1,123 @@
# 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

@@ -0,0 +1,160 @@
# 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

@@ -0,0 +1,94 @@
# 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

@@ -0,0 +1,99 @@
# 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.

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

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

View File

@@ -0,0 +1,65 @@
name: Find Workflow Run
description: Finds a workflow run for a given commit SHA and outputs its status and run ID.
inputs:
workflow-id:
description: The workflow filename (e.g., 'ci-size-data.yaml')
required: true
head-sha:
description: The commit SHA to find runs for
required: true
not-found-status:
description: Status to output when no run exists
required: false
default: pending
token:
description: GitHub token for API access
required: true
outputs:
status:
description: One of 'ready', 'pending', 'failed', or the not-found-status value
value: ${{ steps.find.outputs.status }}
run-id:
description: The workflow run ID (only set when status is 'ready')
value: ${{ steps.find.outputs.run-id }}
runs:
using: composite
steps:
- name: Find workflow run
id: find
uses: actions/github-script@v8
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
script: |
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', process.env.NOT_FOUND_STATUS);
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));

View File

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

View File

@@ -0,0 +1,79 @@
name: Upsert Comment Section
description: >
Manage a consolidated PR comment with independently-updatable sections.
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
Valid section names: "e2e", "preview", "screenshot-update".
inputs:
pr-number:
description: PR number to comment on
required: true
section-name:
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
required: true
section-content:
description: Markdown content for this section
required: true
comment-marker:
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
required: true
token:
description: GitHub token with pull-requests write permission
required: true
runs:
using: composite
steps:
- uses: actions/github-script@v8
env:
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
INPUT_SECTION_NAME: ${{ inputs.section-name }}
INPUT_SECTION_CONTENT: ${{ inputs.section-content }}
INPUT_COMMENT_MARKER: ${{ inputs.comment-marker }}
with:
github-token: ${{ inputs.token }}
script: |
const prNumber = Number(process.env.INPUT_PR_NUMBER)
const sectionName = process.env.INPUT_SECTION_NAME
const sectionContent = process.env.INPUT_SECTION_CONTENT
const commentMarker = process.env.INPUT_COMMENT_MARKER
const sectionStart = `<!-- section:${sectionName}:start -->`
const sectionEnd = `<!-- section:${sectionName}:end -->`
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
// Escape special regex characters in delimiter strings
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: prNumber }
)
const existing = comments.find(
(c) =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes(commentMarker)
)
if (!existing) {
return github.rest.issues.createComment({
...context.repo,
issue_number: prNumber,
body: `${commentMarker}\n${sectionBlock}`
})
}
const body = existing.body ?? ''
const sectionRegex = new RegExp(
`${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
)
const updated = sectionRegex.test(body)
? body.replace(sectionRegex, sectionBlock)
: body.trimEnd() + '\n\n' + sectionBlock
return github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body: updated
})

View File

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

View File

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

View File

@@ -0,0 +1,147 @@
name: 'CI: E2E Coverage'
on:
workflow_run:
workflows: ['CI: Tests E2E']
types:
- completed
concurrency:
group: e2e-coverage-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
merge:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download all shard coverage data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: e2e-coverage-shard-.*
name_is_regexp: true
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Install lcov
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
path: coverage/playwright/
retention-days: 30
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

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

View File

@@ -7,7 +7,6 @@ on:
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
workflow_dispatch:
@@ -16,7 +15,36 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect whether e2e-relevant files changed. Required checks see "skipped"
# (which counts as passing) when only docs/apps/storybook files are touched,
# avoiding the stall that paths-ignore would cause.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
steps:
- name: Checkout repository
if: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@v6
- name: Check for e2e-relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
e2e:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'
setup:
needs: changes
if: ${{ needs.changes.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -87,6 +115,7 @@ jobs:
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
- name: Upload blob report
uses: actions/upload-artifact@v6
@@ -96,6 +125,15 @@ jobs:
path: blob-report/
retention-days: 1
- name: Upload shard coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-shard-${{ matrix.shardIndex }}
path: coverage/playwright/
retention-days: 1
if-no-files-found: warn
playwright-tests:
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15
@@ -154,9 +192,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [playwright-tests-chromium-sharded]
needs: [changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -185,14 +223,38 @@ jobs:
path: ./playwright-report/
retention-days: 30
# Gate job — single required check that passes whether the matrix ran or was
# skipped. Branch rulesets require this instead of the individual matrix-
# expanded check names so PRs with no e2e-relevant changes aren't stuck.
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: >-
${{
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
steps:
@@ -211,9 +273,15 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
needs: [changes, playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: >-
${{
always() &&
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
contents: read

View File

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

View File

@@ -27,10 +27,20 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
with:
name: unit-coverage
path: coverage/lcov.info
retention-days: 30
if-no-files-found: warn
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View File

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

260
.github/workflows/ci-website-e2e.yaml vendored Normal file
View File

@@ -0,0 +1,260 @@
name: 'CI: Website E2E'
on:
push:
branches: [main, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
website-e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: read
outputs:
test-outcome: ${{ steps.tests.outcome }}
report-url: ${{ steps.deploy.outputs.url }}
screenshot-failures: ${{ steps.failures.outputs.screenshot }}
other-failures: ${{ steps.failures.outputs.other }}
# Evaluated at job level (not from a step) — static expression.
is-pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
id: tests
run: pnpm --filter @comfyorg/website test:e2e
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-playwright-report
path: apps/website/playwright-report/
retention-days: 30
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g;s/--*/-/g;s/^-\|-$//g')
DEPLOY_OK=false
for i in 1 2 3; do
echo "Deployment attempt $i of 3..."
OUTPUT=$(npx wrangler@^4.0.0 pages deploy apps/website/playwright-report \
--project-name=comfyui-website-e2e \
--branch="$BRANCH" 2>&1) && { DEPLOY_OK=true; break; } || echo "$OUTPUT"
[ $i -lt 3 ] && sleep 10
done
echo "$OUTPUT"
if [ "$DEPLOY_OK" != "true" ]; then
echo "::error::All 3 deployment attempts failed"
exit 1
fi
URL=$(echo "$OUTPUT" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
echo "url=${URL}" >> $GITHUB_OUTPUT
- name: Categorize failures
id: failures
if: always() && !cancelled() && steps.tests.outcome != 'success'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs')
const report = JSON.parse(fs.readFileSync('apps/website/results.json', 'utf8'))
function isFailed(t) { return t.status === 'unexpected' || t.status === 'flaky' }
function isVisual(spec) {
return spec.file?.includes('visual') ||
spec.tests?.some(t => t.results?.some(r => r.error?.message?.includes('toHaveScreenshot')))
}
function specsOf(suite) {
return [
...(suite.specs || []),
...(suite.suites || []).flatMap(specsOf)
]
}
// True: Visual
// False: Other
const failed = specsOf(report)
.flatMap(spec => (spec.tests || [])
.filter(isFailed)
.map(() => isVisual(spec)))
const screenshotFailures = failed.filter(Boolean).length
core.setOutput('screenshot', screenshotFailures)
core.setOutput('other', failed.length - screenshotFailures)
- name: Write job summary
if: always() && !cancelled()
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ steps.tests.outcome }}
REPORT_URL: ${{ steps.deploy.outputs.url }}
SCREENSHOT_FAILURES: ${{ steps.failures.outputs.screenshot }}
OTHER_FAILURES: ${{ steps.failures.outputs.other }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
await core.summary.addRaw(lines.join('\n')).write()
post-starting-comment:
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🌐 Website E2E
<!-- WEBSITE_E2E_STATUS -->
> [!NOTE]
> Tests are running… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: website-e2e
if: always() && !cancelled() && needs.website-e2e.outputs.is-pr == 'true'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build e2e section content
id: content
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ needs.website-e2e.outputs.test-outcome }}
REPORT_URL: ${{ needs.website-e2e.outputs.report-url }}
SCREENSHOT_FAILURES: ${{ needs.website-e2e.outputs.screenshot-failures }}
OTHER_FAILURES: ${{ needs.website-e2e.outputs.other-failures }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '<!-- WEBSITE_E2E_STATUS -->', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
if (screenshotFailures > 0) {
const s = screenshotFailures === 1 ? '' : 's'
lines.push('', `- [ ] Update website screenshots (${screenshotFailures} screenshot diff${s})`)
}
if (otherFailures > 0) {
lines.push(
'',
'> [!WARNING]',
`> ${otherFailures} non-screenshot failure${otherFailures === 1 ? '' : 's'} — these require manual review.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -0,0 +1,149 @@
name: 'Coverage: Slack Notification'
on:
workflow_run:
workflows: ['CI: Tests Unit']
branches: [main]
types:
- completed
permissions:
contents: read
actions: read
pull-requests: read
jobs:
notify:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download current unit coverage
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: unit-coverage
path: coverage
- name: Download previous unit coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: unit-coverage-baseline
path: temp/coverage-baseline
if_no_artifact_found: warn
- name: Download latest E2E coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: ci-tests-e2e-coverage.yaml
name: e2e-coverage
path: temp/e2e-coverage
if_no_artifact_found: warn
- name: Download previous E2E coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: e2e-coverage-baseline
path: temp/e2e-coverage-baseline
if_no_artifact_found: warn
- name: Resolve merged PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const sha = context.payload.workflow_run.head_sha;
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: sha,
});
const message = commit.commit.message ?? '';
const firstLine = message.split('\n')[0];
const match = firstLine.match(/\(#(\d+)\)\s*$/);
if (!match) {
core.setOutput('skip', 'true');
core.info('No PR number found in commit message — skipping.');
return;
}
const prNumber = match[1];
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('skip', 'false');
core.setOutput('number', prNumber);
core.setOutput('url', pr.html_url);
core.setOutput('author', pr.user.login);
- name: Generate Slack notification
if: steps.pr-meta.outputs.skip != 'true'
id: slack-payload
env:
PR_URL: ${{ steps.pr-meta.outputs.url }}
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
run: |
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
--pr-url="$PR_URL" \
--pr-number="$PR_NUMBER" \
--author="$PR_AUTHOR")
if [ -n "$PAYLOAD" ]; then
echo "has_payload=true" >> "$GITHUB_OUTPUT"
DELIM="SLACK_PAYLOAD_$(date +%s)"
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
echo "$DELIM" >> "$GITHUB_OUTPUT"
else
echo "has_payload=false" >> "$GITHUB_OUTPUT"
fi
- name: Post to Slack
if: steps.slack-payload.outputs.has_payload == 'true'
continue-on-error: true
env:
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: |
# Channel: #p-deprecated-frontend-automated-testing
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
curl -sf -X POST \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" \
-o /dev/null \
https://slack.com/api/chat.postMessage
- name: Save unit coverage baseline
if: always() && hashFiles('coverage/lcov.info') != ''
uses: actions/upload-artifact@v6
with:
name: unit-coverage-baseline
path: coverage/lcov.info
retention-days: 90
if-no-files-found: warn
- name: Save E2E coverage baseline
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-baseline
path: temp/e2e-coverage/coverage.lcov
retention-days: 90
if-no-files-found: warn

View File

@@ -2,7 +2,7 @@ name: 'PR: Unified Report'
on:
workflow_run:
workflows: ['CI: Size Data', 'CI: Performance Report']
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
types:
- completed
@@ -30,110 +30,25 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
uses: ./.github/actions/resolve-pr-from-workflow-run
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
- name: Find size workflow run for this commit
- name: Find size workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-size
uses: actions/github-script@v8
uses: ./.github/actions/find-workflow-run
with:
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-size-data.yaml',
head_sha: headSha,
per_page: 1,
});
workflow-id: ci-size-data.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Find perf workflow run for this commit
- name: Find perf workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-perf
uses: actions/github-script@v8
uses: ./.github/actions/find-workflow-run
with:
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-perf-report.yaml',
head_sha: headSha,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
workflow-id: ci-perf-report.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download size data (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
@@ -154,6 +69,25 @@ jobs:
path: temp/size-prev
if_no_artifact_found: warn
- name: Find coverage workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-coverage
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-e2e-coverage.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
not-found-status: skip
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download coverage data
if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: e2e-coverage
run_id: ${{ steps.find-coverage.outputs.run-id }}
path: temp/coverage
if_no_artifact_found: warn
- name: Download perf metrics (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
@@ -189,9 +123,10 @@ jobs:
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >
node scripts/unified-report.js
pnpm exec tsx scripts/unified-report.ts
--size-status=${{ steps.find-size.outputs.status }}
--perf-status=${{ steps.find-perf.outputs.status }}
--coverage-status=${{ steps.find-coverage.outputs.status }}
> pr-report.md
- name: Remove legacy separate comments

View File

@@ -0,0 +1,239 @@
name: 'PR: Update Website Screenshots'
on:
pull_request:
types: [labeled]
issue_comment:
types: [created, edited]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: false
jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
outputs:
pr-number: ${{ steps.pr-info.outputs.pr-number }}
update-outcome: ${{ steps.update-screenshots.outcome }}
has-changes: ${{ steps.commit.outputs.has-changes }}
changed-count: ${{ steps.commit.outputs.changed-count }}
steps:
- name: Verify sender permissions
if: >
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->')
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username: context.actor
})
if (!['admin', 'write'].includes(data.permission)) {
core.setFailed(`User ${context.actor} does not have write access`)
}
- name: Get PR info
id: pr-info
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ github.event.number || github.event.issue.number }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER)
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: prNumber
})
core.setOutput('pr-number', prNumber)
core.setOutput('branch', pr.head.ref)
- uses: actions/checkout@v6
with:
ref: ${{ steps.pr-info.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
id: update-screenshots
run: pnpm --filter @comfyorg/website test:visual:update
continue-on-error: true
- name: Commit updated screenshots
id: commit
if: steps.update-screenshots.outcome == 'success'
run: |
git config --global --add safe.directory "$(pwd)"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
CHANGED=$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/ | wc -l)
echo "changed-count=${CHANGED}" >> $GITHUB_OUTPUT
if [ "$CHANGED" -eq 0 ]; then
echo "No screenshot changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "has-changes=true" >> $GITHUB_OUTPUT
git add apps/website/e2e/
git commit -m "[automated] Update website screenshot expectations"
git push origin ${{ steps.pr-info.outputs.branch }}
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-screenshot-update-report
path: apps/website/playwright-report/
retention-days: 14
- name: Remove label
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
with:
script: |
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: Number(process.env.PR_NUMBER),
name: 'Update Website Screenshots'
})
} catch (e) {
// Label may already be removed
}
post-starting-comment:
# Runs in parallel with update-screenshots to show "in progress" immediately.
# ⚠️ This condition is duplicated from `update-screenshots` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.number || github.event.issue.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.number || github.event.issue.number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 📸 Screenshot Update
> [!NOTE]
> Updating screenshots… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: update-screenshots
if: always() && !cancelled() && needs.update-screenshots.result != 'skipped'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ needs.update-screenshots.outputs.pr-number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build screenshot-update section content
id: content
uses: actions/github-script@v8
env:
UPDATE_OUTCOME: ${{ needs.update-screenshots.outputs.update-outcome }}
HAS_CHANGES: ${{ needs.update-screenshots.outputs.has-changes }}
CHANGED_COUNT: ${{ needs.update-screenshots.outputs.changed-count }}
with:
script: |
const outcome = process.env.UPDATE_OUTCOME
const hasChanges = process.env.HAS_CHANGES === 'true'
const changedCount = parseInt(process.env.CHANGED_COUNT) || 0
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const lines = ['## 📸 Screenshot Update', '']
if (outcome !== 'success') {
lines.push(
'> [!CAUTION]',
`> Screenshot update failed. [View workflow run](${runUrl})`
)
} else if (!hasChanges) {
lines.push(
'> [!TIP]',
'> All screenshots are already up to date.'
)
} else {
const s = changedCount === 1 ? '' : 's'
lines.push(
'> [!TIP]',
`> Updated ${changedCount} screenshot${s} and pushed to the branch.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -0,0 +1,70 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: read
# Uses head_branch as proxy for PR number (unavailable at job-level in workflow_run).
# Preview and E2E comment writes are NOT mutually serialized — the race window is
# small and self-healing on next push.
concurrency:
group: website-pr-comment-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Read preview URLs
if: steps.pr-meta.outputs.skip != 'true'
id: urls
run: |
echo "stable-url=$(cat temp/vercel-preview/stable-url.txt)" >> "$GITHUB_OUTPUT"
echo "unique-url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
echo "short-sha=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT"
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
- name: Post preview comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
section-name: preview
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🔗 Website Preview
**Website Preview:** ${{ steps.urls.outputs.stable-url }}
<sub>This commit: ${{ steps.urls.outputs.unique-url }}</sub>
<sub>Last updated: ${{ github.event.workflow_run.updated_at }} for `${{ steps.urls.outputs.short-sha }}`</sub>

View File

@@ -1,14 +1,23 @@
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases
name: 'Release: Bi-weekly ComfyUI'
# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR.
# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases.
name: 'Release: ComfyUI'
on:
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
# Bi-weekly schedule: Monday at 20:00 UTC
schedule:
- cron: '0 20 * * 1'
# Allow manual triggering (bypasses bi-weekly check)
# Manual trigger for both on-demand minor and patch/hotfix releases
workflow_dispatch:
inputs:
release_type:
description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version'
required: true
default: 'minor'
type: choice
options:
- minor
- patch
comfyui_fork:
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
required: false
@@ -41,10 +50,11 @@ jobs:
- name: Summary
run: |
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -76,6 +86,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
package_json_file: frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -89,6 +101,8 @@ jobs:
- name: Resolve release information
id: resolve
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
run: |
set -euo pipefail

View File

@@ -117,13 +117,60 @@
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error"
"unicorn/no-empty-file": "error",
"playwright/consistent-spacing-between-blocks": "error",
"playwright/expect-expect": [
"error",
{
"assertFunctionNames": [
"recordMeasurement",
"logMeasurement",
"builderSaveAs"
],
"assertFunctionPatterns": [
"^expect",
"^assert",
"^verify",
"^searchAndExpect",
"waitForOpen",
"waitForClosed",
"waitForRequest"
]
}
],
"playwright/max-nested-describe": "error",
"playwright/no-duplicate-hooks": "error",
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",
"playwright/no-unsafe-references": "error",
"playwright/no-unused-locators": "error",
"playwright/no-useless-await": "error",
"playwright/no-useless-not": "error",
"playwright/no-wait-for-navigation": "error",
"playwright/no-wait-for-selector": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/prefer-hooks-on-top": "error",
"playwright/prefer-locator": "error",
"playwright/prefer-to-have-count": "error",
"playwright/prefer-to-have-length": "error",
"playwright/prefer-web-first-assertions": "error",
"playwright/prefer-native-locators": "error",
"playwright/require-to-pass-timeout": "error",
"playwright/valid-expect": "error",
"playwright/valid-expect-in-promise": "error",
"playwright/valid-title": "error"
}
}
]

View File

@@ -67,6 +67,7 @@
"ignoreFiles": [
"node_modules/**",
"dist/**",
"**/dist/**",
"playwright-report/**",
"public/**",
"src/lib/litegraph/**"

View File

@@ -1,16 +1,12 @@
{
"recommendations": [
"antfu.vite",
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"oxc.oxc-vscode",

View File

@@ -44,6 +44,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
@@ -311,7 +312,7 @@ When referencing Comfy-Org repos:
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`
- NEVER use `:class="[]"` to merge class names
- Always use `import { cn } from '@/utils/tailwindUtil'`
- Always use `import { cn } from '@comfyorg/tailwind-utils'`
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes

View File

@@ -5,12 +5,14 @@
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",
"@vueuse/core": "catalog:",

View File

@@ -32,7 +32,7 @@ import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { cn } from '@/utils/tailwindUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()

View File

@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
})
const ProgressBarStub = {
props: ['mode', 'value', 'showValue'],
template:
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
}
function renderDisplay(
props: {
progressPercentage?: number
title?: string
statusText?: string
hideProgress?: boolean
fullScreen?: boolean
} = {}
) {
return render(StartupDisplay, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: { ProgressBar: ProgressBarStub }
}
})
}
describe('StartupDisplay', () => {
describe('progressMode', () => {
it('renders indeterminate mode when progressPercentage is undefined', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'indeterminate'
)
})
it('renders determinate mode when progressPercentage is provided', () => {
renderDisplay({ progressPercentage: 50 })
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'determinate'
)
})
it('passes progressPercentage as value to the progress bar', () => {
renderDisplay({ progressPercentage: 75 })
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
})
})
describe('hideProgress', () => {
it('hides the progress bar when hideProgress is true', () => {
renderDisplay({ hideProgress: true })
expect(screen.queryByTestId('progress-bar')).toBeNull()
})
it('shows the progress bar by default', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar')).toBeDefined()
})
})
describe('title', () => {
it('renders the title text when provided', () => {
renderDisplay({ title: 'Loading...' })
expect(screen.getByText('Loading...')).toBeDefined()
})
it('does not render h1 when title is not provided', () => {
renderDisplay()
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
})
})
describe('statusText', () => {
it('renders statusText with data-testid attribute', () => {
renderDisplay({ statusText: 'Starting server' })
expect(screen.getByTestId('startup-status-text').textContent).toContain(
'Starting server'
)
})
it('does not render statusText element when not provided', () => {
renderDisplay()
expect(screen.queryByTestId('startup-status-text')).toBeNull()
})
})
})

View File

@@ -0,0 +1,208 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
checkUrlReachable: vi.fn()
}))
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
import UrlInput from '@/components/common/UrlInput.vue'
import { ValidationState } from '@/utils/validationUtil'
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue', 'blur'],
template: `<input
data-testid="url-input"
:value="modelValue"
:data-invalid="invalid"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur')"
/>`
}
const InputIconStub = {
template: '<span data-testid="input-icon" />'
}
const IconFieldStub = {
template: '<div><slot /></div>'
}
function renderUrlInput(
modelValue = '',
validateUrlFn?: (url: string) => Promise<boolean>
) {
return render(UrlInput, {
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
}
describe('UrlInput', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('initial validation on mount', () => {
it('stays IDLE when modelValue is empty on mount', async () => {
renderUrlInput('')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(true)
renderUrlInput('https://example.com')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets INVALID state when URL is not reachable on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://unreachable.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
})
})
describe('input handling', () => {
it('resets validation state to IDLE on user input', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://bad.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
const user = userEvent.setup()
await user.type(screen.getByTestId('url-input'), 'x')
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
it('strips whitespace from typed input', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
const input = screen.getByTestId('url-input')
await user.type(input, 'htt ps')
expect((input as HTMLInputElement).value).not.toContain(' ')
})
})
describe('blur handling', () => {
it('emits update:modelValue on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
expect(onUpdate).toHaveBeenCalled()
})
it('normalizes URL on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
const emittedUrl = onUpdate.mock.calls[0]?.[0]
expect(emittedUrl).toBe('https://example.com/')
})
})
describe('custom validateUrlFn', () => {
it('uses custom validateUrlFn when provided', async () => {
const customValidator = vi.fn().mockResolvedValue(true)
renderUrlInput('https://custom.example', customValidator)
await waitFor(() => {
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
})
expect(checkUrlReachable).not.toHaveBeenCalled()
})
})
describe('state-change emission', () => {
it('emits state-change when validation state changes', async () => {
const onStateChange = vi.fn()
vi.mocked(checkUrlReachable).mockResolvedValue(true)
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onState-change': onStateChange
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
})
})
})
})

View File

@@ -0,0 +1,112 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
getPlatform: vi.fn().mockReturnValue('win32')
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
te: () => false,
st: (_key: string, fallback: string) => fallback
}))
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import GpuPicker from '@/components/install/GpuPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} }
})
const HardwareOptionStub = {
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
emits: ['click'],
template:
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
}
function renderPicker(device: TorchDeviceType | null = null) {
return render(GpuPicker, {
props: { device },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
HardwareOption: HardwareOptionStub,
Tag: {
props: ['value'],
template: '<span data-testid="recommended-tag">{{ value }}</span>'
}
}
}
})
}
describe('GpuPicker', () => {
describe('recommended badge', () => {
it('shows recommended badge for nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('shows recommended badge for amd', () => {
renderPicker('amd')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('does not show recommended badge for cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge for unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge when no device is selected', () => {
renderPicker(null)
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
})
describe('selection state', () => {
it('marks nvidia as selected when device is nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
})
it('marks cpu as selected when device is cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
})
it('marks unsupported as selected when device is unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
})
it('no option is selected when device is null', () => {
renderPicker(null)
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
})
})
describe('gpu options on non-darwin platform', () => {
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
renderPicker(null)
expect(screen.getByTestId('NVIDIA')).toBeDefined()
expect(screen.getByTestId('AMD')).toBeDefined()
expect(screen.getByTestId('CPU')).toBeDefined()
expect(screen.getByTestId('Manual Install')).toBeDefined()
})
})
})

View File

@@ -4,7 +4,7 @@
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
'hardware-option flex h-[190px] w-[170px] flex-col items-center rounded-3xl border-4 bg-neutral-900/70 p-5 transition-all duration-200',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@@ -12,13 +12,13 @@
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
class="icon-container flex h-[110px] w-[110px] shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
class="size-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
@@ -28,7 +28,7 @@
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div v-if="subtitle" class="mt-4 text-center">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { cn } from '@comfyorg/tailwind-utils'
interface Props {
imagePath?: string

View File

@@ -0,0 +1,223 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createI18n } from 'vue-i18n'
const mockValidateComfyUISource = vi.fn()
const mockShowDirectoryPicker = vi.fn()
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
validateComfyUISource: mockValidateComfyUISource,
showDirectoryPicker: mockShowDirectoryPicker
}))
}))
import MigrationPicker from '@/components/install/MigrationPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
install: {
migrationSourcePathDescription: 'Source path description',
migrationOptional: 'Migration is optional',
selectItemsToMigrate: 'Select items to migrate',
pathValidationFailed: 'Validation failed',
failedToSelectDirectory: 'Failed to select directory',
locationPicker: {
migrationPathPlaceholder: 'Enter path'
}
}
}
}
})
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue'],
template: `<input
data-testid="source-input"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>`
}
const CheckboxStub = {
props: ['modelValue', 'inputId', 'binary'],
emits: ['update:modelValue', 'click'],
template: `<input
type="checkbox"
:data-testid="'checkbox-' + inputId"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
@click.stop="$emit('click')"
/>`
}
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
return render(MigrationPicker, {
props: { sourcePath, migrationItemIds },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: {
props: ['severity'],
template: '<div data-testid="error-msg"><slot /></div>'
}
}
}
})
}
describe('MigrationPicker', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('isValidSource', () => {
it('hides migration options when source path is empty', () => {
renderPicker('')
expect(screen.queryByText('Select items to migrate')).toBeNull()
})
it('shows migration options when source path is valid', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const { rerender } = renderPicker('')
await rerender({ sourcePath: '/valid/path' })
await waitFor(() => {
expect(screen.getByText('Select items to migrate')).toBeDefined()
})
})
it('shows optional message when no valid source', () => {
renderPicker('')
expect(screen.getByText('Migration is optional')).toBeDefined()
})
})
describe('validateSource', () => {
it('clears error when source path becomes empty', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
await user.clear(screen.getByTestId('source-input'))
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
it('shows error message when validation fails', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Path not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
})
it('shows no error when validation passes', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/valid/path')
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
})
describe('migrationItemIds watchEffect', () => {
it('emits all item IDs by default (all items start selected)', async () => {
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
migrationItemIds: [],
'onUpdate:migrationItemIds': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button />' },
Message: { template: '<div />' }
}
}
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
const emittedIds = onUpdate.mock.calls[0][0]
expect(Array.isArray(emittedIds)).toBe(true)
expect(emittedIds.length).toBeGreaterThan(0)
})
})
})
describe('browse path', () => {
it('opens directory picker on browse click', async () => {
mockShowDirectoryPicker.mockResolvedValue(null)
renderPicker()
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
})
it('updates source path when directory is selected', async () => {
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
'onUpdate:sourcePath': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: { template: '<div />' }
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
})
})
})
})

View File

@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import StatusTag from '@/components/maintenance/StatusTag.vue'
const TagStub = defineComponent({
name: 'Tag',
props: {
icon: String,
severity: String,
value: String
},
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
})
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
return render(StatusTag, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: { Tag: TagStub }
}
})
}
describe('StatusTag', () => {
describe('refreshing state', () => {
it('shows info severity when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
})
it('shows refreshing translation key when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
it('shows question icon when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
})
})
describe('error state', () => {
it('shows danger severity when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
})
it('shows error translation key when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
})
})
describe('OK state', () => {
it('shows success severity when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
})
it('shows OK translation key when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
})
})
describe('precedence', () => {
it('shows refreshing state when both refreshing and error are true', () => {
renderStatusTag({ error: true, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
})
})

View File

@@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskCard from '@/components/maintenance/TaskCard.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
shortDescription: 'Short description',
errorDescription: 'Error occurred',
execute: vi.fn().mockResolvedValue(true)
}
const cardStubs = {
Card: {
template: '<div data-testid="card"><slot name="content"></slot></div>'
},
Button: { template: '<button />' }
}
function renderCard(
state: 'OK' | 'error' | 'warning' | 'skipped',
task: MaintenanceTask = baseTask
) {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskCard, {
props: { task },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: cardStubs
}
})
}
describe('TaskCard', () => {
describe('description computed', () => {
it('shows errorDescription when task state is error', () => {
renderCard('error')
expect(screen.getByText('Error occurred')).toBeDefined()
})
it('shows shortDescription when task state is OK', () => {
renderCard('OK')
expect(screen.getByText('Short description')).toBeDefined()
})
it('shows shortDescription when task state is warning', () => {
renderCard('warning')
expect(screen.getByText('Short description')).toBeDefined()
})
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
const taskWithoutErrorDesc: MaintenanceTask = {
...baseTask,
errorDescription: undefined
}
renderCard('error', taskWithoutErrorDesc)
expect(screen.getByText('Short description')).toBeDefined()
})
})
})

View File

@@ -64,7 +64,7 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@/utils/tailwindUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()

View File

@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
button: { text: 'Fix', icon: 'pi pi-check' },
execute: vi.fn().mockResolvedValue(true)
}
const ButtonStub = {
props: ['severity', 'label', 'icon', 'loading'],
template:
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
}
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskListItem, {
props: { task: baseTask },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
Button: ButtonStub,
Popover: { template: '<div />' },
TaskListStatusIcon: { template: '<span />' }
}
}
})
}
describe('TaskListItem', () => {
describe('severity computed', () => {
it('uses primary severity for error state', () => {
renderItem('error')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses primary severity for warning state', () => {
renderItem('warning')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses secondary severity for OK state', () => {
renderItem('OK')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
it('uses secondary severity for skipped state', () => {
renderItem('skipped')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
})
})

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
function renderIcon(state: TaskState, loading?: boolean) {
return render(TaskListStatusIcon, {
props: { state, loading },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
ProgressSpinner: {
template: '<div data-testid="spinner" />'
}
}
}
})
}
describe('TaskListStatusIcon', () => {
describe('loading / no state', () => {
it('renders spinner when state is undefined', () => {
renderIcon(undefined)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('renders spinner when loading is true', () => {
renderIcon('OK', true)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('hides spinner when state is defined and not loading', () => {
renderIcon('OK', false)
expect(screen.queryByTestId('spinner')).toBeNull()
})
})
})

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
() => {
const mockTerminal = {
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
hasSelection: vi.fn<[], boolean>(),
resize: vi.fn(),
cols: 80,
rows: 24
}
const MockTerminal = vi.fn(function () {
return mockTerminal
})
const mockFitAddon = {
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
}
const MockFitAddon = vi.fn(function () {
return mockFitAddon
})
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
}
)
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
import { withSetup } from '@/test/withSetup'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
function getKeyHandler(): (event: KeyboardEvent) => boolean {
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
}
describe('useTerminal key event handler', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTerminal.hasSelection.mockReturnValue(false)
const element = ref<HTMLElement | undefined>(undefined)
withSetup(() => useTerminal(element))
})
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle copy when text is selected (Meta+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not pass copy to browser when no text is selected', () => {
mockTerminal.hasSelection.mockReturnValue(false)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('allows browser to handle paste (Ctrl+V)', () => {
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle paste (Meta+V)', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not intercept non-keydown events', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keyup',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('passes through unrelated key combinations', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: false,
key: 'Enter'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
})

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
const mockSerialize = vi.fn<[], string>()
const MockSerializeAddon = vi.fn(function () {
return { serialize: mockSerialize }
})
return { mockSerialize, MockSerializeAddon }
})
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn(function () {
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
})
}))
vi.mock('@xterm/addon-serialize', () => ({
SerializeAddon: MockSerializeAddon
}))
import type { Terminal } from '@xterm/xterm'
import { withSetup } from '@/test/withSetup'
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
describe('useTerminalBuffer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSerialize.mockReturnValue('')
})
describe('copyTo', () => {
it('writes serialized buffer content to the destination terminal', () => {
mockSerialize.mockReturnValue('hello world')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('hello world')
})
it('writes empty string when buffer is empty', () => {
mockSerialize.mockReturnValue('')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('')
})
})
})

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
describe('getDialog', () => {
it('returns the matching dialog for a valid ID', () => {
const result = getDialog('reinstallVenv')
expect(result.id).toBe('reinstallVenv')
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
})
it('returns invalidDialog for an unknown string ID', () => {
const result = getDialog('unknownDialog')
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog when given an array of strings', () => {
const result = getDialog(['reinstallVenv', 'other'])
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog for empty string', () => {
const result = getDialog('')
expect(result.id).toBe('invalidDialog')
})
it('returns a deep clone — mutations do not affect the original', () => {
const result = getDialog('reinstallVenv')
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
result.buttons[0].label = 'Mutated'
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
originalFirstLabel
)
})
it('every button has a returnValue', () => {
for (const id of Object.keys(DESKTOP_DIALOGS)) {
const result = getDialog(id)
for (const button of result.buttons) {
expect(button.returnValue).toBeDefined()
}
}
})
it('invalidDialog has a close/cancel button', () => {
const result = getDialog('invalidDialog')
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
})
})

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
setBasePath: vi.fn(),
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
uv: {
installRequirements: vi.fn<[], Promise<void>>(),
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
function findTask(id: string) {
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
if (!task) throw new Error(`Task not found: ${id}`)
return task
}
describe('desktopMaintenanceTasks', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(window, 'open').mockReturnValue(null)
mockElectron.reinstall.mockResolvedValue(undefined)
mockElectron.uv.clearCache.mockResolvedValue(undefined)
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
})
describe('pythonPackages', () => {
it('returns true when installation succeeds', async () => {
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
expect(await findTask('pythonPackages').execute()).toBe(true)
})
it('returns false when installation throws', async () => {
mockElectron.uv.installRequirements.mockRejectedValue(
new Error('install failed')
)
expect(await findTask('pythonPackages').execute()).toBe(false)
})
})
describe('URL-opening tasks', () => {
it('git execute opens the git download page', () => {
findTask('git').execute()
expect(window.open).toHaveBeenCalledWith(
'https://git-scm.com/downloads/',
'_blank'
)
})
it('uv execute opens the uv installation page', () => {
findTask('uv').execute()
expect(window.open).toHaveBeenCalledWith(
'https://docs.astral.sh/uv/getting-started/installation/',
'_blank'
)
})
it('vcRedist execute opens the VC++ redistributable download', () => {
findTask('vcRedist').execute()
expect(window.open).toHaveBeenCalledWith(
'https://aka.ms/vs/17/release/vc_redist.x64.exe',
'_blank'
)
})
})
})

View File

@@ -0,0 +1,288 @@
import { createTestingPinia } from '@pinia/testing'
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron, testTasks } = vi.hoisted(() => {
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
const basicTaskExecute = vi.fn().mockResolvedValue(true)
return {
mockElectron: {
Validation: {
validateInstallation: vi.fn()
}
},
testTasks: [
{
id: 'basicTask',
name: 'Basic Task',
execute: basicTaskExecute
},
{
id: 'terminalTask',
name: 'Terminal Task',
execute: terminalTaskExecute,
usesTerminal: true,
isInstallationFix: true
}
]
}
})
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: testTasks
}))
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
type PartialInstallValidation = Partial<InstallValidation> &
Record<string, unknown>
function makeUpdate(
overrides: PartialInstallValidation = {}
): InstallValidation {
return {
inProgress: false,
installState: 'installed',
...overrides
} as InstallValidation
}
function createStore() {
setActivePinia(createTestingPinia({ stubActions: false }))
return useMaintenanceTaskStore()
}
describe('useMaintenanceTaskStore', () => {
let store: ReturnType<typeof useMaintenanceTaskStore>
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
beforeEach(() => {
vi.resetAllMocks()
store = createStore()
})
describe('processUpdate', () => {
it('sets isRefreshing to true during in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.isRefreshing).toBe(true)
})
it('sets isRefreshing to false when update is complete', () => {
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
expect(store.isRefreshing).toBe(false)
})
it('updates runner state for tasks present in the final update', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).state).toBe('error')
})
it('sets task state to warning from update', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
expect(store.getRunner(basicTask).state).toBe('warning')
})
it('marks runners as refreshing when task id is absent from in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.getRunner(basicTask).refreshing).toBe(true)
})
it('marks task as skipped when absent from final update', () => {
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).state).toBe('skipped')
})
it('clears refreshing flag after final update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).refreshing).toBe(false)
})
it('stores lastUpdate and exposes unsafeBasePath', () => {
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
expect(store.unsafeBasePath).toBe(true)
})
it('exposes unsafeBasePathReason from the update', () => {
store.processUpdate(
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
)
expect(store.unsafeBasePathReason).toBe('oneDrive')
})
})
describe('anyErrors', () => {
it('returns true when any task has error state', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.anyErrors).toBe(true)
})
it('returns false when all tasks are OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
expect(store.anyErrors).toBe(false)
})
it('returns false when all tasks are warning', () => {
store.processUpdate(
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
)
expect(store.anyErrors).toBe(false)
})
})
describe('runner state transitions', () => {
it('marks runner as resolved when transitioning from error to OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
})
it('does not mark resolved for warning to OK transition', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
it('clears resolved flag when task returns to error', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('clearResolved', () => {
it('clears resolved flags on all runners', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
store.clearResolved()
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('execute', () => {
it('returns true when task execution succeeds', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
const result = await store.execute(basicTask)
expect(result).toBe(true)
})
it('returns false when task execution fails', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(false)
const result = await store.execute(basicTask)
expect(result).toBe(false)
})
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(true)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).toHaveBeenCalledOnce()
})
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(false)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('sets runner executing to true during task execution', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(basicTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(basicTask)
expect(store.getRunner(basicTask).executing).toBe(true)
resolveTask(true)
await executePromise
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('clears executing flag when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow('fail')
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('sets runner error message when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(
new Error('something broke')
)
await expect(store.execute(basicTask)).rejects.toThrow()
expect(store.getRunner(basicTask).error).toBe('something broke')
})
it('clears runner error on successful execution', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow()
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(store.getRunner(basicTask).error).toBeUndefined()
})
})
describe('isRunningTerminalCommand', () => {
it('returns true while a terminal task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningTerminalCommand).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningTerminalCommand).toBe(false)
})
it('returns false when no terminal tasks are executing', () => {
expect(store.isRunningTerminalCommand).toBe(false)
})
})
describe('isRunningInstallationFix', () => {
it('returns true while an installation-fix task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningInstallationFix).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningInstallationFix).toBe(false)
})
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View File

@@ -0,0 +1,16 @@
import { render } from '@testing-library/vue'
import { defineComponent } from 'vue'
export function withSetup<T>(composable: () => T): T {
let result!: T
render(
defineComponent({
setup() {
result = composable()
return {}
},
template: '<div />'
})
)
return result
}

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
NetWork: {
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
describe('checkMirrorReachable', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false for an invalid URL without calling canAccessUrl', async () => {
const result = await checkMirrorReachable('not-a-url')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
it('returns false when canAccessUrl returns false', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(false)
})
it('returns true when URL is valid and canAccessUrl returns true', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(true)
})
it('passes the mirror URL to canAccessUrl', async () => {
const url = 'https://pypi.org/simple/'
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
await checkMirrorReachable(url)
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
})
it('returns false for empty string', async () => {
const result = await checkMirrorReachable('')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isElectron, isNativeWindow } from '@/utils/envUtil'
describe('isElectron', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when window.electronAPI is an object', () => {
vi.stubGlobal('window', { ...window, electronAPI: {} })
expect(isElectron()).toBe(true)
})
it('returns false when window.electronAPI is undefined', () => {
vi.stubGlobal('window', { ...window, electronAPI: undefined })
expect(isElectron()).toBe(false)
})
it('returns false when window.electronAPI is absent', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isElectron()).toBe(false)
})
})
describe('isNativeWindow', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: true }
}
})
expect(isNativeWindow()).toBe(true)
})
it('returns false when not in Electron', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay.visible is false', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: false }
}
})
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay is absent', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: { ...window.navigator, windowControlsOverlay: undefined }
})
expect(isNativeWindow()).toBe(false)
})
})

View File

@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, ref } from 'vue'
import { withSetup } from '@/test/withSetup'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
describe('useMinLoadingDurationRef', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects false when source is initially false', () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(false)
})
it('reflects true when source is initially true', () => {
const source = ref(true)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(true)
})
it('becomes true immediately when source transitions to true', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('stays true within minDuration after source returns to false', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
expect(result.value).toBe(true)
})
it('becomes false after minDuration has elapsed', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(250)
await nextTick()
expect(result.value).toBe(false)
})
it('remains true while source is true even after minDuration elapses', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
vi.advanceTimersByTime(500)
await nextTick()
expect(result.value).toBe(true)
})
it('works with a computed ref as input', async () => {
const raw = ref(false)
const source = computed(() => raw.value)
const result = withSetup(() => useMinLoadingDurationRef(source))
raw.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('uses 250ms as default minDuration', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(249)
await nextTick()
expect(result.value).toBe(true)
vi.advanceTimersByTime(1)
await nextTick()
expect(result.value).toBe(false)
})
})

View File

@@ -1 +0,0 @@
export { cn } from '@comfyorg/tailwind-utils'

View File

@@ -13,7 +13,8 @@
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts",
"vite.config.mts"
"vite.config.mts",
"vitest.config.mts"
],
"references": []
}

View File

@@ -0,0 +1,22 @@
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(projectRoot, 'src'),
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
}
},
test: {
globals: true,
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
setupFiles: ['./src/test/setup.ts']
}
})

View File

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

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

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

View File

@@ -6,14 +6,19 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
build: {
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
vite: {
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
plugins: [tailwindcss()],
server: {
watch: {
ignored: ['**/playwright-report/**']
}
}
},
i18n: {
locales: ['en', 'zh-CN'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

View File

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

View File

@@ -0,0 +1,143 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /The full power of/i, level: 1 })
).toBeVisible()
await expect(
page.getByText(/The easiest way to start with ComfyUI/)
).toBeVisible()
})
test('HeroSection has CTA button linking to cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /TRY COMFY CLOUD FOR FREE/i })
await expect(cta).toBeVisible()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Powerful GPUs',
'All models',
'More control',
'Community workflows'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
await expect(heading).toBeVisible()
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {
const cta = page.getByRole('link', {
name: /EXPLORE WORKFLOWS/i
})
await expect(cta.first()).toBeVisible()
await expect(cta.first()).toHaveAttribute(
'href',
'https://comfy.org/workflows'
)
})
test('AudienceSection heading and cards are visible', async ({ page }) => {
await expect(page.getByText(/creators/i).first()).toBeVisible()
for (const label of ['CREATORS', 'TEAMS & STUDIOS']) {
await expect(page.getByText(label).first()).toBeVisible()
}
})
test('PricingSection heading and CTA are visible', async ({ page }) => {
await expect(page.getByText(/Simple, credit-based pricing/)).toBeVisible()
const cta = page.getByRole('link', { name: /SEE PRICING PLANS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', '/cloud/pricing')
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('FAQSection heading is visible with 15 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(15)
})
})
test.describe('Cloud FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
})

View File

@@ -0,0 +1,196 @@
import { devices, expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ browser }) => {
const context = await browser.newContext({ userAgent: WINDOWS_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Unlimited',
'Any model',
'Your machine',
'Free. Open Source'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('EcoSystemSection heading is visible', async ({ page }) => {
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('ProductCardsSection links to cloud, api, enterprise', async ({
page
}) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
test('FAQSection heading is visible with 8 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(8)
})
})
test.describe('FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeHidden()
})
test('clicking a collapsed FAQ expands it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
})
})
test.describe('Download page mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('CloudBannerSection is visible', async ({ page }) => {
await expect(page.getByText(/Need more power/)).toBeVisible()
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ browser }) => {
const context = await browser.newContext({
...devices['Pixel 5'],
userAgent: WINDOWS_UA
})
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()
await expect(githubBtn).toBeVisible()
await expect
.poll(async () => {
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
if (!downloadBox || !githubBox) return false
return githubBox.y > downloadBox.y
})
.toBe(true)
await context.close()
})
})

View File

@@ -0,0 +1,47 @@
import { fileURLToPath } from 'node:url'
import type { Route } from '@playwright/test'
import { test as base } from '@playwright/test'
function assetPath(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url))
}
const IMAGE_PLACEHOLDER = assetPath('../assets/placeholder-1x1.webp')
const VIDEO_PLACEHOLDER = assetPath('../assets/placeholder.webm')
const ANALYTICS_PATTERN = '**/va.vercel-scripts.com/**' as const
const MEDIA_PATTERN =
/^https:\/\/media\.comfy\.org\/.*\.(webp|webm|mp4|png|jpg|jpeg|vtt)(\?.*)?$/i
const VIDEO_PATTERN = /\.(webm|mp4)(\?|$)/i
const SUBTITLE_PATTERN = /\.vtt(\?|$)/i
function blockAnalytics(route: Route) {
return route.abort('blockedbyclient')
}
async function fulfillMedia(route: Route) {
const url = route.request().url()
if (VIDEO_PATTERN.test(url))
return route.fulfill({ path: VIDEO_PLACEHOLDER, status: 200 })
if (SUBTITLE_PATTERN.test(url))
return route.fulfill({
status: 200,
contentType: 'text/vtt',
body: 'WEBVTT\n'
})
await route.fulfill({ path: IMAGE_PLACEHOLDER, status: 200 })
}
export const test = base.extend<{ blockExternalMedia: void }>({
blockExternalMedia: [
async ({ page }, use) => {
await page.route(ANALYTICS_PATTERN, blockAnalytics)
await page.route(MEDIA_PATTERN, fulfillMedia)
await use()
},
{ auto: true }
]
})

View File

@@ -0,0 +1,180 @@
import { fileURLToPath } from 'node:url'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const caseStudyVideoPath = fileURLToPath(
new URL(
'../../../public/assets/images/cloud-subscription.webm',
import.meta.url
)
)
test.describe('Homepage @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Professional Control/i, level: 1 })
).toBeVisible()
})
test('SocialProofBar logos are visible', async ({ page }) => {
await expect(
page.locator('img[src*="/icons/clients/"]').first()
).toBeVisible()
})
test('ProductShowcase section is visible', async ({ page }) => {
await expect(page.getByText('HOW', { exact: true }).first()).toBeVisible()
await expect(
page.getByText(/Connect models, processing steps, and outputs/)
).toBeVisible()
})
test('UseCaseSection is visible', async ({ page }) => {
await expect(
page.getByText('Industries that create with ComfyUI')
).toBeVisible()
})
test('GetStartedSection with heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Get started in minutes' })
).toBeVisible()
})
test('ProductCardsSection has 4 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
})
test('CaseStudySpotlight section is visible', async ({ page }) => {
const section = page.locator('section', {
has: page.getByText('Customer Stories')
})
await expect(section).toBeVisible()
await expect(
section.getByRole('heading', { name: /See Comfy/i })
).toBeVisible()
})
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()
})
})
test.describe('Product showcase accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('first feature is active by default', async ({ page }) => {
await expect(
page.getByText(/Build powerful AI pipelines by connecting nodes/).first()
).toBeVisible()
})
test('clicking inactive feature expands it and collapses previous', async ({
page
}) => {
const secondFeature = page
.getByRole('button', { name: /App mode/i })
.first()
await secondFeature.scrollIntoViewIfNeeded()
await secondFeature.click()
await expect(
secondFeature.getByText(/If you are new to ComfyUI/)
).toBeVisible()
const firstFeature = page
.getByRole('button', { name: /Full Control with Nodes/i })
.first()
await expect(firstFeature).not.toHaveClass(/bg-primary-comfy-yellow/)
await expect(secondFeature).toHaveClass(/bg-primary-comfy-yellow/)
})
})
test.describe('Video player @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route(
'https://media.comfy.org/website/customers/blackmath/video.webm',
(route) =>
route.fulfill({
contentType: 'video/webm',
path: caseStudyVideoPath
})
)
await page.goto('/')
})
test('clicking play advances playback', async ({ page }) => {
const section = page.locator('section', {
has: page.getByText('Customer Stories')
})
const video = section.locator('video')
await expect
.poll(
async () =>
video.evaluate((element: HTMLVideoElement) => element.duration),
{ timeout: 15_000 }
)
.toBeGreaterThan(0)
await section.getByRole('button', { name: 'Play' }).click()
await expect
.poll(async () =>
video.evaluate((element: HTMLVideoElement) => element.currentTime)
)
.toBeGreaterThan(0)
})
})
test.describe('Product cards links @smoke', () => {
test('cards have correct hrefs', async ({ page }) => {
await page.goto('/')
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/download', '/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
})
test.describe('Get started section links @smoke', () => {
test('has download and cloud links', async ({ page }) => {
await page.goto('/')
const section = page.locator('section', {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Local' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')
const cloudLink = section.getByRole('link', { name: 'Launch Cloud' })
await expect(cloudLink).toBeVisible()
await expect(cloudLink).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})

View File

@@ -0,0 +1,163 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('logo links to homepage', async ({ page }) => {
const logo = page.getByRole('link', { name: 'Comfy home' })
await expect(logo).toBeVisible()
await expect(logo).toHaveAttribute('href', '/')
})
test('has all top-level nav items', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of [
'PRODUCTS',
'PRICING',
'COMMUNITY',
'RESOURCES',
'COMPANY'
]) {
await expect(desktopLinks.getByText(label).first()).toBeVisible()
}
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
).toBeVisible()
})
})
test.describe('Desktop dropdown @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hovering PRODUCTS shows dropdown items', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: /PRODUCTS/i
})
await productsButton.hover()
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Local',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
]) {
await expect(dropdown.getByText(item)).toBeVisible()
}
})
test('moving mouse away closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
await expect(comfyLocal).toBeHidden()
})
test('Escape key closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
await expect(comfyLocal).toBeHidden()
})
})
test.describe('Mobile menu @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger button is visible', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('clicking hamburger opens mobile menu with nav items', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(menu).toBeVisible()
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
await expect(menu.getByText(label).first()).toBeVisible()
}
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
})
test('CTA buttons visible in mobile menu', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})
})
test.describe('Footer @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('footer is visible with link sections', async ({ page }) => {
const footer = page.locator('footer')
await expect(footer).toBeVisible()
for (const heading of ['Products', 'Resources', 'Company']) {
await expect(
footer.getByRole('heading', { name: heading }).first()
).toBeVisible()
}
})
test('copyright text is visible', async ({ page }) => {
await expect(
page.locator('footer').getByText(/© \d{4} Comfy Org/)
).toBeVisible()
})
})

View File

@@ -0,0 +1,50 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop layout @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('navigation links visible and hamburger hidden', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await expect(desktopLinks.getByText('PRODUCTS').first()).toBeVisible()
await expect(desktopLinks.getByText('PRICING').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Toggle menu' })).toBeHidden()
})
test('product cards in grid layout', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
expect(firstBox, 'first card bounding box').not.toBeNull()
expect(secondBox, 'second card bounding box').not.toBeNull()
expect(firstBox!.y).toBeCloseTo(secondBox!.y, 0)
})
})
test.describe('Mobile layout @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger visible and desktop nav hidden', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('SocialProofBar shows two marquee rows on mobile', async ({ page }) => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
})

View File

@@ -0,0 +1,6 @@
export const VIEWPORTS = [
{ name: '1-sm', width: 393, height: 851 },
{ name: '2-md', width: 768, height: 1024 },
{ name: '3-lg', width: 1280, height: 800 },
{ name: '4-xl', width: 1536, height: 864 }
] as const

View File

@@ -0,0 +1,145 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
import { VIEWPORTS } from './viewports'
test.describe.configure({ timeout: 60_000 })
const SMALL_VIEWPORTS = VIEWPORTS.filter(
(v) => v.name === '1-sm' || v.name === '2-md'
)
async function assertNoOverflow(page: Page) {
await expect
.poll(
() =>
page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth
),
{ message: 'page has horizontal overflow', timeout: 5_000 }
)
.toBe(false)
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {
for (const vp of VIEWPORTS) {
test.describe(vp.name, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/')
})
test('product-cards screenshot', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`home-product-cards-${vp.name}.png`)
})
test('get-started screenshot', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Get started/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`home-get-started-${vp.name}.png`)
})
})
}
})
test.describe('Pricing', { tag: '@visual' }, () => {
for (const vp of VIEWPORTS) {
test(`pricing-tiers-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/cloud/pricing')
await assertNoOverflow(page)
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`pricing-tiers-${vp.name}.png`)
})
}
})
test.describe('Contact', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`form-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/contact')
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Create powerful workflows/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`contact-form-${vp.name}.png`)
})
}
})
test.describe('Gallery', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`gallery-grid-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/gallery')
const section = page.getByTestId('gallery-grid')
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`gallery-grid-${vp.name}.png`)
})
}
})
test.describe('About', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`hero-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/about')
const hero = page.locator('section', {
has: page.getByRole('heading', { name: /Build the tools/i })
})
await expect(hero).toBeVisible()
await hero.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`about-hero-${vp.name}.png`)
})
}
})
test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/pricing',
'/contact',
'/download',
'/gallery',
'/about',
'/careers'
]
for (const url of pages) {
for (const vp of VIEWPORTS) {
test(`${url} ${vp.name} no overflow`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto(url)
await assertNoOverflow(page)
})
}
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -5,21 +5,40 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"dev:no-toolbar": "cross-env NO_TOOLBAR=1 astro dev",
"build": "astro build",
"preview": "astro preview"
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"
"@vueuse/core": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@astrojs/vue": "catalog:",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
@@ -75,6 +94,32 @@
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}

View File

@@ -0,0 +1,57 @@
import type { PlaywrightTestConfig } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
? {
timeout: 30_000,
retries: 0,
workers: 1,
use: {
baseURL: 'http://localhost:4321',
trace: 'on',
video: 'on'
}
}
: {
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:4321',
trace: 'on-first-retry'
}
}
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.CI
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {
command: 'pnpm preview',
port: 4321,
reuseExistingServer: !process.env.CI
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'] },
grepInvert: /@mobile|@visual/
},
{
name: 'mobile',
use: { ...devices['Pixel 5'] },
grep: /@mobile/
},
{
name: 'visual',
use: { ...devices['Desktop Chrome'] },
grep: /@visual/,
fullyParallel: false
}
]
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1471_12658)">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z" fill="white"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z" fill="white"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z" fill="white"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1471_12658">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.0589 13.0316H16.014V19.1831H24.6589C24.5199 20.0537 24.2078 20.9101 23.7509 21.691C23.2273 22.5858 22.5801 23.2669 21.9167 23.7857C19.9294 25.3395 17.6126 25.6572 16.0035 25.6572C11.9389 25.6572 8.46594 22.9766 7.12148 19.3341C7.06723 19.2019 7.0312 19.0654 6.98733 18.9304C6.69023 18.0034 6.5279 17.0215 6.5279 16.001C6.5279 14.939 6.70369 13.9223 7.0242 12.9621C8.28844 9.17522 11.8397 6.34675 16.0064 6.34675C16.8445 6.34675 17.6516 6.44854 18.417 6.65159C20.1661 7.11561 21.4034 8.0295 22.1615 8.75237L26.7361 4.18101C23.9534 1.57756 20.3259 3.9369e-09 15.9988 3.9369e-09C12.5396 -7.59723e-05 9.34593 1.09971 6.72881 2.95838C4.60641 4.46571 2.86573 6.48384 1.69099 8.82767C0.598311 11.0009 0 13.4092 0 15.9986C0 18.5881 0.599225 21.0215 1.69191 23.1746V23.1891C2.84605 25.4749 4.5338 27.4431 6.58508 28.9435C8.3771 30.2543 11.5904 32 15.9988 32C18.534 32 20.7809 31.5336 22.7625 30.6595C24.192 30.029 25.4585 29.2066 26.6052 28.1496C28.1203 26.753 29.3069 25.0255 30.1168 23.038C30.9268 21.0505 31.36 18.8029 31.36 16.3662C31.36 15.2314 31.2483 14.0791 31.0589 13.0316Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="32" height="31" viewBox="0 0 32 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.3587 19.8988L22.9972 11.9646C23.5187 11.5757 24.2642 11.7274 24.5127 12.3316C25.8207 15.5179 25.2363 19.3471 22.634 21.9762C20.0318 24.6052 16.411 25.1818 13.1015 23.8686L9.48617 25.5598C14.6716 29.1406 20.9684 28.255 24.9032 24.277C28.0243 21.1237 28.9909 16.8255 28.0871 12.9496L28.0952 12.9578C26.7845 7.26377 28.4175 4.98781 31.7625 0.333808C31.8416 0.223459 31.9208 0.113108 32 0L27.5982 4.44709V4.4333L12.356 19.9016" fill="white"/>
<path d="M10.1634 21.8272C6.44151 18.2353 7.0832 12.6764 10.2589 9.47079C12.6072 7.09824 16.4546 6.12993 19.8133 7.55344L23.4204 5.87061C22.7706 5.3961 21.9377 4.88574 20.982 4.52709C16.6622 2.73116 11.4904 3.62499 7.97884 7.16997C4.60108 10.5825 3.53887 15.8297 5.36292 20.3071C6.72549 23.6535 4.49185 26.0204 2.24183 28.4096C1.44449 29.2564 0.644421 30.1034 0 31L10.1606 21.8299" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.1935 32L24.9858 15.5745L27.22 19.5198H31.4952L27.22 12.0998L29.4765 8.3063H12.2615L14.5178 4.36093L12.2615 0.332235L7.41455 8.3063H2.39172L11.6896 23.9422H7.41455L4.85911 27.9711H13.194L15.6878 32L15.6431 31.9985C15.4212 31.9838 15.2231 31.8597 15.1159 31.668L13.1938 28.3031H4.85911C4.6194 28.3031 4.40156 28.1756 4.2872 27.9711L2.03092 24.1776C1.85061 23.8609 1.8506 23.4694 2.03092 23.1527L3.95303 19.7881L0.135229 12.9026C-0.0450889 12.586 -0.0450637 12.1944 0.135229 11.8778L2.39151 8.08424C2.50586 7.87972 2.72371 7.75222 2.96341 7.75222H7.41455L11.6896 0.332235C11.8039 0.127714 12.0218 0 12.2615 0H16.7124C16.9521 0 17.17 0.127714 17.2843 0.332235L19.2064 3.69689H27.5411C27.7808 3.69689 27.9986 3.82439 28.113 4.02891L30.3693 7.82245C30.5496 8.13912 30.5496 8.53061 30.3693 8.84729L28.113 12.6408L31.6316 19.0976C31.8119 19.4143 31.8119 19.8058 31.6316 20.1224L29.3751 23.916C29.2608 24.1204 29.0431 24.248 28.8034 24.248H24.9856L20.7106 31.668C20.5962 31.8725 20.3784 32 20.1387 32H16.1935Z" fill="white"/>
<path d="M22.0609 13.703H10.6869L16.1935 23.001L22.0609 13.703Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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