Compare commits

..

116 Commits

Author SHA1 Message Date
Benjamin Lu
f67bb9022c ci: add aggregate e2e required check 2026-04-23 14:08:14 -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
979 changed files with 48945 additions and 5945 deletions

View File

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

View File

@@ -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

@@ -54,6 +54,33 @@ jobs:
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 "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
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
@@ -71,3 +98,50 @@ jobs:
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,40 +34,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get PR Number
- name: Resolve PR from workflow_run context
id: pr
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Test Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -72,7 +59,7 @@ jobs:
path: reports
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -85,6 +72,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -4,10 +4,8 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
workflow_dispatch:
@@ -16,7 +14,70 @@ concurrency:
cancel-in-progress: true
jobs:
detect-changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
run-app-e2e: ${{ steps.route.outputs.run-app-e2e }}
run-website-e2e: ${{ steps.route.outputs.run-website-e2e }}
steps:
- name: Route e2e suites
id: route
uses: actions/github-script@v8
with:
script: |
const runAll = context.eventName !== 'pull_request'
if (runAll) {
core.info(`${context.eventName} runs all e2e suites`)
core.setOutput('run-app-e2e', 'true')
core.setOutput('run-website-e2e', 'true')
return
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100
})
const changedFiles = files.map((file) => file.filename)
const sharedWebsiteFiles = new Set([
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'.github/workflows/ci-tests-e2e.yaml',
'.github/workflows/ci-website-e2e.yaml'
])
const affectsWebsite = (file) =>
file.startsWith('apps/website/') ||
file.startsWith('packages/design-system/') ||
file.startsWith('packages/tailwind-utils/') ||
file.startsWith('.github/actions/upsert-comment-section/') ||
sharedWebsiteFiles.has(file)
const isAppE2eExcluded = (file) =>
file.endsWith('.md') ||
file.startsWith('apps/desktop-ui/') ||
file.startsWith('apps/website/') ||
file.startsWith('docs/') ||
file.startsWith('.storybook/')
const runWebsiteE2e = changedFiles.some(affectsWebsite)
const runAppE2e = changedFiles.some((file) => !isAppE2eExcluded(file))
core.info(`Changed files: ${changedFiles.join(', ') || '(none)'}`)
core.info(`run-app-e2e=${runAppE2e}`)
core.info(`run-website-e2e=${runWebsiteE2e}`)
core.setOutput('run-app-e2e', String(runAppE2e))
core.setOutput('run-website-e2e', String(runWebsiteE2e))
setup:
needs: detect-changes
if: needs.detect-changes.outputs.run-app-e2e == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -50,7 +111,8 @@ jobs:
# Sharded chromium tests
playwright-tests-chromium-sharded:
needs: setup
needs: [detect-changes, setup]
if: needs.detect-changes.outputs.run-app-e2e == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
container:
@@ -109,7 +171,8 @@ jobs:
playwright-tests:
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15
needs: setup
needs: [detect-changes, setup]
if: needs.detect-changes.outputs.run-app-e2e == 'true'
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
@@ -164,9 +227,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [playwright-tests-chromium-sharded]
needs: [detect-changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.detect-changes.outputs.run-app-e2e == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -195,14 +258,302 @@ jobs:
path: ./playwright-report/
retention-days: 30
website-e2e:
needs: detect-changes
if: needs.detect-changes.outputs.run-website-e2e == 'true'
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 }}
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-website-comment:
needs: detect-changes
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
if: |
needs.detect-changes.outputs.run-website-e2e == 'true' &&
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-website-result-comment:
needs: [detect-changes, website-e2e]
if: |
always() &&
needs.detect-changes.outputs.run-website-e2e == 'true' &&
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
- 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 }}
e2e-required:
needs:
- detect-changes
- playwright-tests
- playwright-tests-chromium-sharded
- website-e2e
runs-on: ubuntu-latest
if: ${{ always() }}
steps:
- name: Verify required e2e jobs passed
env:
RUN_APP_E2E: ${{ needs.detect-changes.outputs.run-app-e2e }}
RUN_WEBSITE_E2E: ${{ needs.detect-changes.outputs.run-website-e2e }}
PLAYWRIGHT_TESTS_RESULT: ${{ needs.playwright-tests.result }}
SHARDED_TESTS_RESULT: ${{ needs.playwright-tests-chromium-sharded.result }}
WEBSITE_E2E_RESULT: ${{ needs.website-e2e.result }}
run: |
if [ "$RUN_APP_E2E" = "true" ] && [ "$PLAYWRIGHT_TESTS_RESULT" != "success" ]; then
echo "playwright-tests result: $PLAYWRIGHT_TESTS_RESULT"
exit 1
fi
if [ "$RUN_APP_E2E" = "true" ] && [ "$SHARDED_TESTS_RESULT" != "success" ]; then
echo "playwright-tests-chromium-sharded result: $SHARDED_TESTS_RESULT"
exit 1
fi
if [ "$RUN_WEBSITE_E2E" = "true" ] && [ "$WEBSITE_E2E_RESULT" != "success" ]; then
echo "website-e2e result: $WEBSITE_E2E_RESULT"
exit 1
fi
if [ "$RUN_APP_E2E" != "true" ] && [ "$RUN_WEBSITE_E2E" != "true" ]; then
echo "No e2e suites required for these changes."
exit 0
fi
echo "All required e2e suites 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: detect-changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: |
needs.detect-changes.outputs.run-app-e2e == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -212,18 +563,23 @@ jobs:
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.head_ref }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"$HEAD_REF" \
"starting"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
needs: [detect-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.detect-changes.outputs.run-app-e2e == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
@@ -243,9 +599,10 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.head_ref }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"$HEAD_REF" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

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

@@ -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"

View File

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

View File

@@ -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: true
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

@@ -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,3 +1,7 @@
dist/
.astro/
test-results/
playwright-report/
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

View File

@@ -6,9 +6,15 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
vite: {
plugins: [tailwindcss()]
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

@@ -1,4 +1,6 @@
import { expect, test } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -41,13 +43,11 @@ test.describe('Cloud page @smoke', () => {
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
await expect(
page.getByRole('heading', { name: /leading AI models/i })
).toBeVisible()
const heading = page.getByRole('heading', { name: /leading AI models/i })
await expect(heading).toBeVisible()
const grid = page.locator('.grid', {
has: page.getByText('Grok Imagine')
})
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)
})
@@ -100,38 +100,44 @@ test.describe('Cloud FAQ accordion @interaction', () => {
await page.goto('/cloud')
})
test('all FAQs are expanded by default', async ({ page }) => {
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
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 again', async ({ page }) => {
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)
).toBeHidden()
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

@@ -1,4 +1,9 @@
import { expect, test } from '@playwright/test'
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 }) => {
@@ -12,7 +17,7 @@ test.describe('Download page @smoke', () => {
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://app.comfy.org')
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
@@ -22,7 +27,11 @@ test.describe('Download page @smoke', () => {
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ page }) => {
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,
@@ -39,6 +48,8 @@ test.describe('Download page @smoke', () => {
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
@@ -93,40 +104,46 @@ test.describe('FAQ accordion @interaction', () => {
await page.goto('/download')
})
test('all FAQs are expanded by default', async ({ page }) => {
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeVisible()
).toBeHidden()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeHidden()
})
test('clicking an expanded FAQ collapses it', async ({ page }) => {
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)
).toBeHidden()
).toBeVisible()
})
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
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(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
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()
})
})
@@ -145,7 +162,14 @@ test.describe('Download page mobile @mobile', () => {
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ page }) => {
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,
@@ -155,13 +179,18 @@ test.describe('Download page mobile @mobile', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await downloadBtn.scrollIntoViewIfNeeded()
await expect(downloadBtn).toBeVisible()
await expect(githubBtn).toBeVisible()
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
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)
expect(downloadBox, 'download button bounding box').not.toBeNull()
expect(githubBox, 'github button bounding box').not.toBeNull()
expect(githubBox!.y).toBeGreaterThan(downloadBox!.y)
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

@@ -1,4 +1,15 @@
import { expect, test } from '@playwright/test'
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 }) => {
@@ -83,17 +94,56 @@ test.describe('Product showcase accordion @interaction', () => {
.first()
await secondFeature.scrollIntoViewIfNeeded()
await expect(async () => {
await secondFeature.click()
await expect(
page.getByText(/If you are new to ComfyUI/).first()
).toBeVisible({ timeout: 1000 })
}).toPass({ timeout: 10000 })
await secondFeature.click()
await expect(
page.getByText(/Build powerful AI pipelines by connecting nodes/).first()
).toBeHidden()
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)
})
})
@@ -125,6 +175,6 @@ test.describe('Get started section links @smoke', () => {
const cloudLink = section.getByRole('link', { name: 'Launch Cloud' })
await expect(cloudLink).toBeVisible()
await expect(cloudLink).toHaveAttribute('href', 'https://app.comfy.org')
await expect(cloudLink).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})

View File

@@ -1,4 +1,6 @@
import { expect, test } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {

View File

@@ -1,4 +1,6 @@
import { expect, test } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop layout @smoke', () => {
test.beforeEach(async ({ page }) => {

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: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 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: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -5,17 +5,22 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"dev:no-toolbar": "cross-env NO_TOOLBAR=1 astro dev",
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:e2e": "playwright test",
"test:e2e:local": "PLAYWRIGHT_LOCAL=1 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"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:"

View File

@@ -1,15 +1,36 @@
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,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:4321',
trace: process.env.CI ? 'on-first-retry' : 'on'
reporter: process.env.CI
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {
command: 'pnpm preview',
port: 4321,
@@ -19,12 +40,18 @@ export default defineConfig({
{
name: 'desktop',
use: { ...devices['Desktop Chrome'] },
grepInvert: /@mobile/
grepInvert: /@mobile|@visual/
},
{
name: 'mobile',
use: { ...devices['Pixel 5'] },
grep: /@mobile/
},
{
name: 'visual',
use: { ...devices['Desktop Chrome'] },
grep: /@visual/,
fullyParallel: false
}
]
})

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

@@ -1,5 +0,0 @@
<svg width="38" height="16" viewBox="0 0 38 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.6714 2.88536C26.1493 2.82951 26.7876 2.87631 27.2746 2.87552C29.4061 2.87217 29.7407 4.85107 31.0086 6.17285C31.3057 5.47951 31.7391 4.76441 32.1866 4.149C32.6673 3.48769 33.0911 3.10066 33.953 2.95519C34.7328 2.82353 36.8597 2.74599 37.4893 3.17876C37.6223 3.27022 37.7198 3.40009 37.7342 3.55864C37.7879 4.1504 34.524 8.38331 33.8977 9.14783C34.6224 10.5615 37.9453 14.0003 37.9993 15.2938C38.0058 15.4508 37.9689 15.5748 37.8512 15.6902C37.4841 16.0502 36.5552 15.9679 36.0583 15.9783C35.4029 15.9928 34.8918 16.0117 34.2446 15.9184C32.7906 15.7089 32.4029 14.8649 31.7106 13.8079C31.4542 13.4166 31.1547 13.018 30.868 12.641C29.8939 13.549 29.4433 14.7027 28.431 15.5215C27.7186 16.0978 24.0748 16.1659 23.6489 15.5402C23.3554 14.4588 26.2687 11.0941 26.96 10.1276C27.1912 9.80439 27.3923 9.66031 27.5531 9.23699L27.5618 9.21374C27.0682 8.21459 23.6269 4.06408 23.8836 3.31986C24.2686 2.90918 25.1029 2.92321 25.6714 2.88536Z" fill="white"/>
<path d="M13.5798 0.0217006C14.0245 0.0102399 17.731 -0.0733401 17.8671 0.188355C18.0783 0.594232 18.0745 2.39664 17.9571 2.91885C18.7829 2.90052 20.9815 2.73358 21.576 3.24255C21.9254 3.54165 21.8894 6.31329 21.4914 6.7223C20.9809 6.93681 18.6254 6.86654 17.9992 6.8141C18.2005 8.24188 17.6814 9.92179 18.1662 11.2688C18.2188 11.4149 18.4418 11.6515 18.5582 11.7758L18.5851 11.7807C19.4248 11.9253 20.4783 11.7921 21.4193 11.8541C21.5894 11.9423 21.7529 12.0279 21.8509 12.193C22.2597 12.8822 22.1381 14.3802 21.9594 15.1114C21.9191 15.307 21.8419 15.6435 21.6653 15.748C20.9367 16.1792 19.0596 15.9477 18.2447 15.8484C16.5346 15.6399 15.0424 15.373 13.9488 14.018C12.762 12.5472 12.9954 10.9199 12.9709 9.17573C12.9618 8.52672 12.9588 7.41995 13.0502 6.80867C12.4361 6.84166 10.8569 7.01497 10.4917 6.5846C10.1043 6.12824 10.3072 3.9303 10.3518 3.3296C10.9517 2.70004 12.1816 2.90197 13.0395 2.9117C13.0534 2.19219 12.7947 0.87298 13.1333 0.195009C13.203 0.055524 13.4145 0.0516102 13.5798 0.0217006Z" fill="white"/>
<path d="M1.66628 2.88823C2.58555 2.81214 4.10841 2.81874 4.945 3.20141C5.33248 5.15667 4.99029 7.60389 5.06565 9.60325C5.07579 9.8735 4.9874 11.6351 5.25399 11.7357C6.28618 12.1249 11.1291 11.506 11.8399 12.0455C12.163 12.56 12.0235 15.1134 11.8775 15.7598C11.3062 15.9103 11.0189 15.8876 10.4264 15.8899L5.78094 15.8919C3.59892 15.9082 2.36972 16.0953 0.208639 15.5943C0.116058 15.3247 0.0349184 14.7783 0.0303189 14.4959C-0.0272935 10.99 0.0150458 7.4781 0.0142792 3.97146C0.0140433 2.80644 0.604555 2.93016 1.66628 2.88823Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 13L7 7L1 0.999999" stroke="#211927" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11.5811L10.2582 18.0581L20 6.05811" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2871_8492)">
<path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 6.75V12H17.25" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_2871_8492">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 595.3 130">
<!-- Generator: Adobe Illustrator 28.7.1, SVG Export Plug-In . SVG Version: 1.2.0 Build 142) -->
<g>
<g id="Layer_1">
<g id="Group_458" fill="#C2BFB9">
<path id="Path_3625" d="M16.4,109.7c4.4,0,8.6-.3,12.8-11L62.4,16.5h9.6l31.8,79.8c4.1,10.2,7.6,13.5,13,13.5v6.9h-36.7v-6.4c9-.5,12.8-3.5,9.2-13l-6.3-16.2h-36.8l-6,15.4c-3.4,8.7-1.8,13.8,7.6,13.8v6.4h-31.5v-6.9ZM79.8,72.6l-15.1-39.6-15.3,39.6h30.4Z"/>
<path id="Path_3626" d="M126,113.8V26.4l-11.8-2.1v-4.9l20.8-8.3h3.7v47.2c7.8-9.3,13.9-13,22.8-13,16.7,0,28,13.1,28,32.7s-13.9,40.2-38.5,40.2c-8.5-.1-16.9-1.6-24.9-4.4M155.1,111.6c12.2,0,20.5-11.8,20.5-28.7s-7.5-27.1-20.6-27.1c-6.4,0-12.4,3-16.2,8.1v31.5c-.4,8.6,6.3,15.8,14.8,16.2.5,0,1,0,1.5,0"/>
<path id="Path_3627" d="M209,110.9l-7.9,6.7h-2.8l-1.7-25.2h6.1c5.8,12.8,13.8,18.3,23.7,18.3s12.8-3.7,12.8-10.2-2.1-8.3-9.6-11.2l-12.4-4.7c-11.9-4.6-17.7-10.4-17.7-19.7s10.1-19.7,22.3-19.7c6-.2,11.8,2.3,15.7,6.9l7.9-6.4h2.6v22.6h-6.1c-5-12.5-11.2-15.6-18.2-15.6s-12.5,4-12.5,9.6,2.6,7.5,9.3,9.9l13.6,4.9c11,4,17,10.4,17,20s-9.2,21.1-23.2,21.1c-7,0-13.8-2.6-18.9-7.3"/>
<path id="Path_3628" d="M271.6,97.6l.5-41.4h-11.9v-8.4h7.9l13.3-16.7h2.9v16.7h21.9v8.4h-21.9v38.5c0,8.7,4,13.3,11.6,13.3,4.4-.1,8.6-1.9,11.8-4.9l2.3,2.7c-3.8,7.1-10.9,11.7-18.9,12.2-11.6,0-19.6-6.3-19.4-20.5"/>
<path id="Path_3629" d="M327.4,100.5v-39l-10.4-2.1v-4.6l18.2-8.3h4.9v13.1c9.6-10.7,14.7-13.6,19.9-13.6,4.2-.4,7.9,2.7,8.2,6.9,0,.2,0,.4,0,.6-.3,4.1-2.7,7.8-6.4,9.6l-11-6.3c-4.1,1.9-7.7,4.7-10.7,8.1v37c0,6.7,2.1,8.4,10.7,8.4h2.3v6.1h-36.8v-6.1c8.9,0,11.2-1.7,11.2-9.9"/>
<path id="Path_3630" d="M409.6,106.5c-6.6,8.6-12.5,11.8-19.3,11.8-8.2.4-15.2-6-15.6-14.2,0-.5,0-.9,0-1.4,0-9,5.7-14.4,16.7-18.8l18-7v-6c0-9.3-4.4-14.8-13.1-14.8s-11.6,3.1-17.6,11.2l-5-3.4c8.6-13.9,18-18.5,27.4-18.5s21.1,8.9,21.1,23.7v33.3c0,3.5,1.2,6,5.2,6,2.6-.1,5-1.4,6.6-3.4l.9,3.8c-5.3,7.2-9,9.5-13.6,9.5-6.4-.2-11.5-5.4-11.6-11.8M396.6,107.9c4.8-.2,9.4-2.2,12.8-5.7v-17.3l-11.5,2.6c-7.3,1.8-10.7,6-10.7,11.6-.2,4.7,3.5,8.6,8.2,8.8.4,0,.7,0,1.1,0"/>
<path id="Path_3631" d="M436.7,84.5c-.3-21.1,16.6-38.5,37.7-38.8,0,0,0,0,0,0,11.6,0,18.9,5,18.9,11.2-.5,4.3-4,7.6-8.3,7.8l-13.1-12.7c-12.4,1.5-22.5,12.1-22.5,28.3s7,26.1,23.2,26.1c8.7.3,16.7-4.7,20.3-12.5l3.8,3.2c-6.7,15.3-17.6,21.3-29.5,21.3-18.3,0-30.7-15.6-30.7-33.8"/>
<path id="Path_3632" d="M513.3,97.6l.5-41.4h-11.9v-8.4h7.9l13.3-16.7h2.9v16.7h21.9v8.4h-21.9v38.5c0,8.7,4,13.3,11.6,13.3,4.4-.1,8.6-1.9,11.8-4.9l2.3,2.7c-3.8,7.1-10.9,11.7-18.9,12.2-11.6,0-19.6-6.3-19.4-20.5"/>
<path id="Path_3633" d="M562.1,110.5c0-4.6,3.8-8.4,8.4-8.4,4.6,0,8.4,3.8,8.4,8.4,0,4.6-3.7,8.3-8.3,8.4-4.6.1-8.4-3.5-8.6-8.1,0-.1,0-.2,0-.3"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,15 @@
<svg width="125" height="23" viewBox="0 0 125 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.624 13.5714H102.72L105.521 4.7427H109.112L104.492 17.6087C102.983 21.8406 101.427 22.4 99.2481 22.4C98.4106 22.4 97.6921 22.3513 97.2372 22.3027V19.5059L97.4957 19.5312C97.7832 19.562 98.1651 19.6032 98.7218 19.6032C99.8707 19.6032 100.326 19.1652 100.828 17.9005L100.996 17.5114L95.9202 4.7427H99.5359L102.624 13.5714Z" fill="#C2BFB9"/>
<path d="M6.34494 4.45081C10.3191 4.45087 11.8513 6.95585 12.3302 8.51244L9.07418 9.24216C8.79582 8.34683 8.13559 7.08628 6.50571 7.00832L6.34494 7.00484C4.57317 7.00484 3.42365 8.41543 3.42365 11.1881C3.42369 13.9606 4.5732 15.3713 6.34494 15.3713C8.16444 15.3713 8.85871 14.009 9.12207 12.866L12.5218 13.2795C11.9951 15.4197 10.3193 17.9248 6.34494 17.9249C2.41839 17.9249 6.38906e-05 15.3226 0 11.1881C0 7.05343 2.41834 4.45081 6.34494 4.45081Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.1848 4.4528C37.0563 4.53422 39.6265 7.17738 39.3201 11.9178H30.0783C30.1263 14.0337 31.2519 15.5173 32.9996 15.5173C34.8431 15.5172 35.5373 14.3496 35.8007 13.5227L39.2009 13.7903C38.6981 15.6144 37.0219 17.9248 32.9996 17.9249C29.121 17.9249 26.6547 15.2983 26.6547 11.2124C26.6547 7.0777 29.0491 4.45081 32.9996 4.45081L33.1848 4.4528ZM32.9996 6.81025C31.2279 6.81025 30.246 8.12338 30.1262 9.70432H35.8965C35.7767 8.12342 34.7953 6.81029 32.9996 6.81025Z" fill="#C2BFB9"/>
<path d="M71.9332 4.45081C74.878 4.45081 77.129 5.95871 77.5359 8.19622L74.4474 8.63406C74.1924 7.40883 73.2402 6.75409 72.049 6.71444L71.9332 6.71295C70.7363 6.71296 69.874 7.24781 69.874 8.22055C69.874 9.16907 70.8078 9.41244 72.1008 9.65567L73.3939 9.85026C75.6682 10.2394 77.8711 10.9204 77.8711 13.8146C77.8711 16.6116 75.357 17.9248 72.0529 17.9249C68.7253 17.9249 66.4501 16.3684 66.0193 13.8632L69.1561 13.4254L69.1854 13.5669C69.5113 15.0012 70.5693 15.6632 72.0769 15.6632C73.5135 15.6632 74.4474 15.0307 74.4474 13.9362C74.4474 13.0364 73.5135 12.6713 72.0769 12.4281L70.6881 12.2097C68.653 11.8692 66.4982 11.1391 66.4982 8.36649C66.4983 5.9101 68.9644 4.45082 71.9332 4.45081Z" fill="#C2BFB9"/>
<path d="M112.823 17.6871H109.232V14.0385H112.823V17.6871Z" fill="#C2BFB9"/>
<path d="M116.914 17.6871H113.323V14.039H116.914V17.6871Z" fill="#C2BFB9"/>
<path d="M124.6 17.6871H117.382V14.039H124.6V17.6871Z" fill="#C2BFB9"/>
<path d="M83.5192 4.7427H86.1531V7.02916H83.5192V13.693C83.5192 14.8604 83.9504 14.9821 85.0038 14.9821C85.339 14.9821 85.7696 14.9822 86.0569 14.9578V17.6087C85.7454 17.6573 84.6924 17.6573 83.8784 17.6573C81.2928 17.6573 80.1674 17.0981 80.1674 14.5686V7.02916H78.156V4.7427H80.1674V1.14323H83.5192V4.7427Z" fill="#C2BFB9"/>
<path d="M55.8226 4.45081C58.121 4.45081 60.0604 5.88579 60.0604 9.14486V17.633H56.7325V10.0205C56.7325 7.95318 55.9182 7.19943 54.5296 7.19943C53.2366 7.19953 52.0638 8.31817 52.0638 10.6043V17.633H48.7115V10.0205C48.7115 7.95321 47.9216 7.19945 46.533 7.19943C45.24 7.19943 44.0666 8.31807 44.0666 10.6043V17.633H40.7148V4.7427H43.9229V6.46971H44.0187C44.8807 5.2293 46.1261 4.45081 47.826 4.45081C49.3822 4.45087 50.795 5.10777 51.513 6.56701C52.5428 5.35093 53.7876 4.45086 55.8226 4.45081Z" fill="#C2BFB9"/>
<path d="M16.9677 6.44539H17.0635C17.9493 5.20501 19.2662 4.45085 21.0139 4.45081C23.3603 4.45081 25.3714 5.88579 25.3714 9.14486V17.6087H22.0436V10.0205C22.0436 7.95328 21.1817 7.19951 19.6496 7.19943C18.3088 7.19943 16.9677 8.46404 16.9677 10.7746V17.6087H13.6159V0H16.9677V6.44539Z" fill="#C2BFB9"/>
<path d="M65.0639 17.6087H61.7121V4.7427H65.0639V17.6087Z" fill="#C2BFB9"/>
<path d="M94.6844 4.45578C94.9211 4.46603 95.1545 4.49342 95.3338 4.54811V7.73407C95.0226 7.68543 94.5196 7.63677 94.0173 7.63677C92.1019 7.6368 90.6895 8.4884 90.6895 11.1151V17.6087H87.3372V4.7427H90.5458V6.71295H90.6411C91.3116 5.39959 92.5809 4.45081 94.4484 4.45081L94.6844 4.45578Z" fill="#C2BFB9"/>
<path d="M65.0639 2.94272H61.7121V0H65.0639V2.94272Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,7 @@
<svg width="105" height="15" viewBox="0 0 105 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.539 0.0285426C22.6544 -0.0351846 25.988 0.0279171 29.1212 0.0264352L32.1742 0.0199332C32.9185 0.018159 33.8039 -0.0152019 34.5256 0.11456C35.3509 0.266315 36.1211 0.634326 36.7577 1.18105C37.6967 1.97593 38.2784 3.11376 38.3729 4.3405C38.4839 5.82246 37.9664 7.19079 36.8332 8.15921C36.5415 8.40857 35.9614 8.79166 35.7424 9.04646C35.7383 9.43601 36.8893 11.0059 37.1467 11.4269C37.4643 11.9465 38.4111 11.7772 38.9199 11.7756C39.3943 11.7772 39.8685 11.793 40.342 11.8229C40.4037 12.5345 40.3634 13.9093 40.3517 14.664C39.0437 14.7314 37.3846 14.6838 36.0336 14.6838C34.657 14.6837 32.979 14.7319 31.644 14.6624C31.6103 14.2318 31.6332 13.4746 31.6327 13.02L31.6361 11.814C32.1247 11.7835 32.5976 11.8061 33.0846 11.7834C33.319 11.7725 33.4799 11.7405 33.5165 11.4949C33.4337 11.2769 33.1746 10.9388 33.0331 10.747C32.7459 10.3576 32.5042 9.90935 32.142 9.58871C31.967 9.43386 31.4996 9.39602 31.2878 9.39494C29.871 9.38766 28.4576 9.39504 27.0412 9.40171C26.4322 9.40545 25.8162 9.39617 25.2105 9.46037C25.198 9.95812 24.9581 11.8037 25.6817 11.7886C26.5399 11.7706 27.404 11.7843 28.2643 11.8099C28.2959 12.6677 28.2852 13.8088 28.2613 14.6625C27.0013 14.7337 25.3367 14.6856 24.0277 14.6841C22.6169 14.6825 20.913 14.7327 19.5435 14.6627C19.5121 13.7618 19.5376 12.7254 19.539 11.8145C20.0584 11.7729 21.8513 11.8658 22.1261 11.6122C22.2789 11.0808 22.2133 8.34042 22.2097 7.6769C22.206 6.9765 22.2904 3.60502 22.0911 3.13566C21.8255 2.99389 19.9813 3.03216 19.5159 3.00974C19.4729 2.62606 19.453 0.3621 19.539 0.0285426ZM25.1781 6.41333C25.7864 6.42789 26.403 6.43338 27.0112 6.4283C29.3214 6.40892 31.6425 6.48496 33.9495 6.40241C34.0841 6.39759 34.5121 6.16284 34.6315 6.08213C35.0228 5.71678 35.2992 5.35081 35.3173 4.7881C35.3308 4.32373 35.1606 3.87283 34.8436 3.53331C34.4239 3.08989 33.9742 2.99621 33.3906 2.99441C30.7387 2.98624 28.0784 3.00441 25.4273 3.02022C25.2542 3.04728 25.2743 3.01516 25.1877 3.12383C25.1324 3.75496 25.1485 5.73442 25.1781 6.41333Z" fill="#C2BFB9"/>
<path d="M50.2724 0.138631C51.6828 0.03579 53.2496 0.182878 54.6625 0.122495C56.7584 0.0329492 58.1473 0.23673 59.6382 1.82499C61.3824 3.68336 61.5311 5.19749 61.5316 7.61398C61.5332 8.59624 61.528 9.57851 61.5157 10.5607C61.5127 10.8878 61.4609 11.4889 61.627 11.7565C61.8023 11.9292 64.2099 11.9103 64.6493 11.9184C64.6509 12.866 64.6478 13.8136 64.6391 14.7611C63.7008 14.8175 62.2832 14.7743 61.2886 14.7733C59.5065 14.7717 57.6654 14.7977 55.8904 14.7644C55.8576 13.8841 55.8771 12.8157 55.892 11.9268C56.3996 11.897 58.1832 11.9618 58.435 11.7296C58.6047 11.3174 58.598 9.85935 58.4298 9.4396C58.1735 9.22562 57.086 9.31059 56.6785 9.31623C55.7853 9.3153 48.7631 9.24582 48.3863 9.39335C48.275 9.53964 48.2383 9.77249 48.2404 9.94037C48.2611 11.6716 47.8088 11.991 49.9209 11.8975C50.1037 11.8895 50.661 11.9185 50.8594 11.9251C50.9014 12.7546 50.8803 13.9221 50.861 14.7637C50.0044 14.8179 48.6992 14.7732 47.7992 14.7732L42.1365 14.7669C42.1124 13.8551 42.1244 12.8371 42.14 11.9221C42.7563 11.9001 44.5286 11.9586 44.9981 11.8351C45.043 11.8233 45.0865 11.8065 45.1277 11.7851C45.1698 11.7632 45.2281 11.6483 45.2321 11.6095C45.5513 8.48866 44.4939 5.07258 46.6263 2.39668C47.5759 1.20517 48.672 0.362 50.2724 0.138631ZM55.8069 6.37984C56.2334 6.38256 57.2317 6.32251 57.6008 6.39251C59.4224 6.73786 58.1325 4.41869 57.5218 3.82117C57.1583 3.46885 56.7077 3.22021 56.2155 3.10105C55.5844 2.94746 52.2657 2.99145 51.4515 3.00183C51.3418 3.00391 51.2319 3.00822 51.1221 3.01476C50.2804 3.08849 49.7204 3.39435 49.1449 4.0204C48.8448 4.34691 48.0275 5.86061 48.3348 6.28005C48.8624 6.47634 54.8163 6.38015 55.8069 6.37984Z" fill="#C2BFB9"/>
<path d="M66.1008 0.132078C67.4815 0.0948407 69.0088 0.126689 70.3982 0.126274L82.7239 0.124817C83.3335 0.124915 84.5481 0.0906206 85.1084 0.193006C85.2351 0.383285 85.1848 4.29567 85.1618 4.8387L82.2455 4.84347C82.1717 4.37331 82.2558 3.44598 82.0697 3.11536C82.0035 3.09915 81.9364 3.08718 81.8692 3.07952C81.153 2.99854 72.2869 2.97826 72.0639 3.06634C72.0337 3.07839 71.9855 3.12886 71.9578 3.15083C71.7579 3.52942 71.9947 4.54083 71.8983 5.04801C71.8814 5.21615 71.8947 5.68477 72.0527 5.78369C72.4966 6.06094 73.6589 5.94967 74.1921 5.9527L78.3526 5.97392C78.488 5.97259 78.6126 5.9569 78.7177 6.0434C78.8084 6.24682 78.7966 8.46194 78.7074 8.77012C78.2993 9.11434 72.3674 8.65905 72.0398 9.01363C71.8799 9.32283 71.8573 11.4816 71.9209 11.7506C72.1834 11.9303 74.4105 11.9116 74.8474 11.9209C74.8597 12.8671 74.8607 13.8133 74.8504 14.7595C73.9742 14.8203 72.4085 14.7764 71.4569 14.7732C69.7091 14.7674 67.8562 14.8074 66.1218 14.7628C66.0777 14.1145 66.0895 12.5701 66.1223 11.9225C66.6253 11.8922 68.4592 11.9381 68.774 11.7648C68.9098 11.2104 68.916 3.76877 68.7852 3.15653C68.5156 2.97876 66.5478 3.00636 66.091 2.9977C66.0746 2.15515 66.0464 0.952851 66.1008 0.132078Z" fill="#C2BFB9"/>
<path d="M0 3.97075C0.680008 2.4775 1.72004 1.09533 3.30995 0.49908C3.73281 0.343706 4.17582 0.249971 4.62537 0.220763C5.88343 0.131726 17.0718 0.0347761 17.432 0.19913C17.4429 0.204062 17.4534 0.209482 17.4639 0.215036C17.5863 0.617033 17.5561 4.23071 17.5217 4.84327L14.4665 4.84676C14.4511 4.57601 14.4351 3.33928 14.2915 3.1986C14.1017 3.13857 13.8405 3.10635 13.6413 3.10753C10.8734 3.12384 8.12437 3.13586 5.3552 3.16177C4.03315 3.17415 3.10523 4.28538 2.72969 5.46977C2.44858 6.35631 2.58788 7.38144 2.55855 8.30232C2.53008 9.63214 3.18983 11.1025 4.41563 11.7148C4.93376 11.9737 5.57212 11.9398 6.13799 11.9348C8.63538 11.9151 11.1334 11.9289 13.6307 11.9075C13.8341 11.9057 14.0499 11.8752 14.2419 11.8084C14.4871 11.6127 14.4224 10.4672 14.473 10.0944C14.9745 10.0805 17.1263 10.0064 17.4428 10.1774C17.6064 10.5158 17.5389 14.096 17.5232 14.7353C14.5846 14.7664 11.6458 14.7797 8.707 14.775C7.45695 14.7749 6.20151 14.7635 4.95241 14.7698C2.61924 14.7814 0.911018 13.0104 0 11.0108V3.97075Z" fill="#C2BFB9"/>
<path d="M105 0.127759V4.84265L102.537 4.8407C102.524 4.57401 102.556 3.11933 102.308 3.10405C101.561 3.05828 98.278 2.90385 97.7617 3.1579C97.63 3.63603 97.65 11.2212 97.7817 11.7547C98.077 11.9306 100.223 11.9104 100.712 11.9221C100.78 12.5649 100.734 14.0796 100.726 14.7638C99.8423 14.8242 98.1319 14.779 97.1696 14.7744C95.4085 14.766 93.4956 14.8141 91.7519 14.7607C91.7058 14.02 91.7155 12.6763 91.755 11.9299C92.2841 11.8996 94.2175 11.9179 94.5492 11.7937C94.7056 11.5281 94.6933 4.01885 94.6338 3.22394C94.4123 2.94404 90.2026 2.98239 89.9437 3.13285C89.7483 3.44251 89.7724 4.42536 89.7606 4.83963L86.826 4.84542C86.7767 3.83337 86.7685 1.13399 86.8301 0.135563C88.7768 0.0695231 91.0501 0.126784 93.0168 0.126836L105 0.127759Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,12 @@
<svg width="118" height="22" viewBox="0 0 118 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0298 0C13.03 1.33223 13.0823 3.05715 13.0203 4.35674C12.5297 4.40883 12.0611 4.39599 11.5697 4.39984C9.20775 4.41837 6.83709 4.37161 4.47602 4.40663C4.47935 4.47148 4.491 4.57304 4.47011 4.63014C4.50477 5.73869 4.52926 7.37998 4.47277 8.47402C4.47914 8.53726 4.49478 8.63703 4.48015 8.69487C6.52067 8.8156 8.81129 8.6622 10.8741 8.73886C10.9067 10.0161 10.9269 11.7943 10.8809 13.0738C8.91015 13.2295 6.50525 13.0238 4.47188 13.1369C4.47661 13.2056 4.4903 13.3077 4.46952 13.3687C4.50565 14.4103 4.52334 16.0974 4.47513 17.1432L4.48842 17.3416C4.92992 17.4348 6.22159 17.4012 6.73735 17.4004L10.6232 17.3942C11.3704 17.3932 12.2867 17.3668 13.0153 17.4272C13.0649 18.8228 13.0311 20.3622 13.0274 21.7683H0V0H13.0298Z" fill="#C2BFB9"/>
<path d="M28.9816 0C29.6882 0.472806 30.3169 0.809757 30.4166 1.78273C30.5559 3.14391 30.5099 5.12562 30.4644 6.50056C29.5433 6.55432 27.0931 6.59113 26.2083 6.48786C26.1383 6.55342 26.1721 6.54075 26.0761 6.5419C25.9394 6.30164 25.9933 4.72191 26.0566 4.41106C25.0069 4.34402 22.8858 4.37764 21.7937 4.4087C21.6985 5.48716 21.767 7.57495 21.7733 8.71672C23.3169 8.73062 24.8608 8.73205 26.4044 8.72085C27.8463 8.71837 29.7895 8.36939 30.3563 10.1581C30.4227 10.4794 30.4805 10.843 30.4827 11.1712C30.4998 13.7868 30.4908 16.4035 30.4871 19.0192C30.4866 19.3602 30.4337 19.6526 30.4033 19.9794C30.3023 21.063 29.7051 21.1984 28.9943 21.7683H18.9183C18.8277 21.6461 18.7602 21.6535 18.6522 21.5741C17.9823 21.0811 17.6231 20.832 17.4326 20.032C17.3996 19.8865 17.3935 19.8353 17.3003 19.7157C17.2138 18.6643 17.2794 16.6781 17.2867 15.594C17.2612 15.4679 17.2567 15.3394 17.3198 15.2326L17.4169 15.4618L17.478 15.4889L17.5471 15.1889C18.9397 15.2079 20.3325 15.2037 21.7249 15.1759C21.8084 15.9193 21.7461 16.6513 21.7775 17.3841C22.2776 17.3946 25.7281 17.4316 26.082 17.3706C25.9175 16.2884 26.1722 14.199 26.0188 13.0894C24.7334 13.1336 23.4596 13.123 22.1082 13.1151C21.0343 13.1088 19.1306 13.2867 18.1955 12.6202C17.9119 12.4182 17.4843 11.7548 17.4633 11.4065C17.4064 11.2039 17.2645 11.0063 17.2908 10.8145C17.2246 8.97126 17.3079 7.0791 17.2764 5.23098C17.2595 4.24575 17.2648 3.21037 17.2929 2.22649C17.2951 2.24676 17.4457 1.70297 17.4571 1.65843C17.4876 1.59418 17.5087 1.43756 17.5781 1.30029C17.8952 0.672774 18.3737 0.41943 18.8527 0H28.9816Z" fill="#C2BFB9"/>
<path d="M46.4198 0C47.6718 0.899999 47.8824 1.08409 47.7918 2.72901C47.731 3.83475 47.8975 5.22667 47.7691 6.30865L47.7328 6.48048C47.5838 6.54671 47.2595 6.56281 47.0971 6.55312C45.9297 6.48345 44.7504 6.58741 43.591 6.53451C43.5603 6.5326 43.5093 6.32802 43.4977 6.29064C43.5445 6.12646 43.5402 5.29844 43.5414 5.07184L43.5405 4.90797C43.5395 4.73649 43.5308 4.55004 43.4534 4.42464C43.3127 4.3605 39.6586 4.39856 39.2372 4.42022C39.2346 5.54116 39.3017 7.62032 39.2227 8.65767C39.8819 8.77575 40.8548 8.72141 41.5473 8.7306C43.044 8.75047 44.5873 8.68661 46.0811 8.74152C48.0574 9.07225 47.7898 10.6883 47.7912 12.2335L47.7927 15.4922L47.7906 18.4807C47.7898 19.0263 47.8107 19.7717 47.7369 20.2956C47.6406 20.9796 46.8796 21.3767 46.4304 21.7683H36.3121C36.2027 21.6149 36.1134 21.6327 35.9738 21.5357C35.4292 21.1576 35.1345 20.9382 34.9687 20.2924C34.6904 19.5446 34.7691 16.1426 34.8167 15.1904C36.2579 15.1823 37.6992 15.1826 39.1404 15.1919C39.2267 15.6996 39.3276 16.8692 39.1858 17.3694C40.237 17.4621 42.3595 17.3889 43.5119 17.4104C43.5949 16.2224 43.5334 14.3279 43.5243 13.1009C41.5274 13.1176 39.5169 13.1161 37.5218 13.1107C36.7968 13.0849 36.0163 13.0516 35.4972 12.4773C35.0872 12.0412 34.8084 11.3444 34.8046 10.7424C34.786 7.85839 34.7995 4.96898 34.796 2.08477C34.7955 1.55989 35.288 0.746369 35.7131 0.426639C35.9237 0.268216 36.1019 0.195041 36.308 0H46.4198Z" fill="#C2BFB9"/>
<path d="M65.3009 0C65.3014 1.3829 65.3416 2.98525 65.2911 4.35024C64.7422 4.42078 64.1999 4.39321 63.6492 4.40102C61.3208 4.43408 58.966 4.34497 56.6399 4.4152C56.5222 5.10504 56.5466 7.89558 56.6075 8.64675C58.371 8.91365 61.2956 8.57726 63.155 8.75451C63.1619 9.33031 63.2364 12.8048 63.09 12.9914C62.4245 13.2103 57.8158 13.0747 56.6399 13.1346C56.5319 13.8193 56.5356 16.6111 56.6157 17.339C57.2675 17.4335 58.3879 17.3993 59.0861 17.3983L63.2055 17.393C63.7303 17.3926 64.8035 17.3652 65.2864 17.4568C65.3277 18.8678 65.3055 20.3516 65.3032 21.7683H52.2658C52.2263 20.3012 52.2575 18.6949 52.2587 17.2176L52.2575 3.29826C52.2575 2.27844 52.2221 0.986496 52.2826 0H65.3009Z" fill="#C2BFB9"/>
<path d="M69.709 15.9277C69.7095 16.8548 69.8347 20.9048 69.6665 21.5699L69.6585 21.7683H69.577C69.5012 19.154 69.5705 16.1626 69.5699 13.5128L69.5764 0H69.7169L69.709 15.9277Z" fill="#C2BFB9"/>
<path d="M81.279 0C82.0553 0.525496 82.5295 0.826058 82.7116 1.85212C82.8281 2.50704 82.7847 3.43871 82.7824 4.12614L82.7721 7.6237L82.783 21.7683H78.3371C78.309 21.0093 78.3119 20.1751 78.3274 19.4155C78.4256 14.4355 78.2023 9.37025 78.3424 4.39689C77.0235 4.39391 75.3819 4.36073 74.0784 4.41726C73.9687 6.15742 74.0513 8.78053 74.0518 10.5718L74.0583 21.7683H69.8049C69.7745 20.9202 69.7819 20.0099 69.7922 19.1574C69.8674 12.7851 69.6826 6.36743 69.8049 0H81.279Z" fill="#C2BFB9"/>
<path d="M98.6817 0C99.245 0.426776 100.072 0.950614 100.076 1.71305C100.082 2.69656 100.198 4.9897 100.101 5.87906C100.108 6.08496 100.138 6.32456 100.048 6.50617C99.3623 6.64648 96.5942 6.5453 95.7918 6.52743C95.7591 5.867 95.7855 5.10108 95.7614 4.40634C94.6379 4.39042 93.2518 4.37205 92.0758 4.40604L91.5862 4.42405L91.5157 4.78899L91.5234 4.90679C91.6004 6.42571 91.5939 17.0774 91.4593 17.3723C92.8277 17.4476 94.3649 17.3785 95.7505 17.3995C95.7999 16.7033 95.7632 15.8196 95.8556 15.1854C96.3432 15.1902 99.8544 15.1482 100.029 15.2796C100.318 16.4092 100.014 18.9486 100.065 20.2064C100.093 20.8885 98.912 21.4413 98.6817 21.7683H88.6216C88.5188 21.6378 87.6483 21.2069 87.4019 20.784C87.159 20.3662 87.1188 20.0112 87.1073 19.5173C87.0774 18.1858 87.0901 16.8285 87.0896 15.4969L87.0907 3.82617C87.089 1.84144 86.7898 1.18487 88.5549 0H98.6817Z" fill="#C2BFB9"/>
<path d="M117.6 4.31363C117.046 4.47618 109.809 4.45517 108.864 4.39453C108.808 5.53308 108.812 7.56345 108.857 8.71702C110.286 8.73247 111.715 8.73471 113.144 8.72351C113.709 8.72259 114.886 8.69262 115.41 8.77223C115.43 9.38599 115.515 12.6365 115.34 12.9828C114.816 13.2169 109.892 13.0772 108.863 13.1343C108.813 14.2134 108.806 16.3023 108.861 17.3847C110.832 17.4316 112.839 17.3716 114.811 17.3915C115.45 17.398 117.074 17.346 117.6 17.4671V21.7683H104.542C104.501 21.3743 104.548 19.6198 104.549 19.1359V4.65465C104.548 3.29906 104.486 1.29018 104.57 0H117.6V4.31363Z" fill="#C2BFB9"/>
<path d="M48.0177 10.366L48 10.4894C48.106 10.8938 48.0982 19.744 48.0038 20.0249C47.9833 20.0942 47.9735 20.1302 47.9409 20.1958L47.8721 20.1374C47.806 19.7354 47.8297 18.9655 47.8299 18.5327L47.8326 12.1983C47.832 11.8575 47.7421 10.4752 47.9374 10.2993L48.0177 10.366Z" fill="#C2BFB9"/>
<path d="M48.0186 1.90172C48.1014 2.28871 48.0969 6.19803 48.0053 6.44033C47.9765 6.42888 47.9313 6.38877 47.9046 6.3677C47.8471 5.53577 47.9104 4.49595 47.8922 3.6437C47.8847 3.29234 47.8659 2.12203 47.9079 1.84975L47.997 1.82702L48.0186 1.90172Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,19 @@
<svg width="122" height="23" viewBox="0 0 122 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.0498 2.08858C19.7947 2.09487 16.472 2.12329 13.2206 2.08944L19.5173 14.6881C20.6762 17.0087 21.9537 19.455 23.0556 21.7839C22.537 22.0518 21.7501 22.4192 21.2755 22.7035H21.1798C19.1216 18.6948 17.1673 14.5864 15.1189 10.569C14.2784 8.92062 13.4613 7.16352 12.5891 5.54081L12.586 22.7035H10.477L10.4773 11.7188C10.4775 9.68092 10.5065 7.56743 10.4584 5.53586L1.879 22.7035H1.78627C1.37028 22.4447 0.456336 22.0205 0 21.7985V21.6561C0.298159 21.359 1.90123 17.9578 2.20881 17.3451L9.82774 2.08739C6.60428 2.14206 3.23535 2.09182 0 2.0922V0H23.0537L23.0498 2.08858Z" fill="#C2BFB9"/>
<path d="M94.2751 16.0714C94.8228 16.0509 95.6125 16.4099 95.8079 16.974C95.859 17.1221 95.8464 17.3599 95.7353 17.4778C94.9441 17.4744 95.3563 17.2157 94.7999 16.742C94.4876 16.4761 93.7085 16.688 93.5059 16.9783C93.4182 17.1046 93.386 17.261 93.4152 17.4116C93.5741 18.2024 95.8371 17.9368 95.9443 19.0356C96.0388 20.0007 95.4829 20.4916 94.5864 20.6572C93.442 20.6721 92.873 20.2825 92.6806 19.144C93.7787 19.1232 93.1278 19.501 93.8006 19.9861C94.2084 20.2804 95.5531 19.9466 95.3158 19.1757C95.1965 18.7889 94.6712 18.6885 94.3185 18.5928C93.1658 18.3725 92.2635 17.7413 93.1024 16.5256C93.254 16.3062 93.6447 16.1662 93.9224 16.1013C94.0398 16.0869 94.1572 16.0769 94.2751 16.0714Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.03 16.125C88.0473 16.1227 89.6049 15.8493 90.0745 16.9793C90.2104 17.3105 90.2109 17.6818 90.076 18.0132C89.8962 18.4453 89.6243 18.6292 89.2175 18.7984L90.2889 20.6066C90.0818 20.6103 89.6828 20.6442 89.5274 20.5412L88.591 18.8506L87.6132 18.8521L87.6107 20.6034L87.03 20.6085V16.125ZM89.3618 16.9662C88.8692 16.6146 88.1866 16.7062 87.6137 16.7043V18.3109C88.2129 18.2761 88.895 18.3981 89.3535 18.0764C89.6078 17.7357 89.7646 17.2535 89.3618 16.9662Z" fill="#C2BFB9"/>
<path d="M74.945 16.1241C75.3893 16.1093 75.4195 16.0599 75.6924 16.4364C76.4349 17.4613 77.1779 18.4863 77.9208 19.511C77.8838 18.4414 77.912 17.2058 77.9116 16.1238C78.0699 16.1213 78.3398 16.0998 78.4733 16.1611C78.5576 17.3417 78.4913 19.3624 78.4899 20.6053C78.3773 20.6083 78.2209 20.6266 78.1123 20.6022C77.8794 20.5501 75.7956 17.5604 75.516 17.1475C75.5374 17.5867 75.5457 20.2559 75.4946 20.5576C75.3523 20.6301 75.1131 20.6075 74.9455 20.6044L74.945 16.1241Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M63.5806 16.1239C64.5886 16.1281 66.1603 15.8447 66.6154 16.9954C66.7479 17.3335 66.7405 17.7104 66.5949 18.0429C66.4161 18.4445 66.1481 18.6404 65.752 18.7915L66.8117 20.606C66.553 20.6109 66.344 20.618 66.0848 20.59C65.7954 20.0237 65.4349 19.428 65.1304 18.8499L64.1569 18.853L64.1564 20.6057L63.5801 20.6027L63.5806 16.1239ZM65.9084 18.0546C66.8258 16.6747 64.9638 16.6908 64.1555 16.7031L64.1545 18.3049C64.7752 18.285 65.4324 18.402 65.9084 18.0546Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.7406 16.1269C52.5533 16.1266 53.6914 15.9686 54.3052 16.4693C54.5941 16.7039 54.7754 17.0454 54.8085 17.4159C54.9055 18.4316 54.1839 18.9529 53.2568 19.029L52.3268 19.0155C52.3268 19.364 52.3555 20.2741 52.3 20.5673C52.1752 20.6234 51.8907 20.6075 51.7402 20.6076L51.7406 16.1269ZM53.953 16.9717C53.4839 16.6501 52.8641 16.7142 52.3263 16.7152L52.3268 18.435C52.8451 18.4403 53.5789 18.5221 53.973 18.1933C54.2682 17.8073 54.4061 17.2823 53.953 16.9717Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5497 16.1283C58.6715 16.1251 59.0277 16.1108 59.0525 16.1591C59.5056 17.049 60.6432 19.8474 60.9526 20.6056L60.3222 20.6069C60.1088 20.0731 59.9032 19.5364 59.7054 18.9968L59.2557 18.9939L57.9637 18.9952L57.3439 20.6039L56.7155 20.6089L58.5497 16.1283ZM58.807 16.8857C58.6043 17.3937 58.4026 17.9099 58.1931 18.4144L58.9634 18.4048L59.4506 18.4026C59.3634 18.1838 58.9078 16.9853 58.807 16.8857Z" fill="#C2BFB9"/>
<path d="M72.2907 16.1257L72.2888 16.7052L70.9475 16.7019V20.6017L70.3639 20.6083L70.3634 16.6974L69.0397 16.7083L69.0358 16.1253L72.2907 16.1257Z" fill="#C2BFB9"/>
<path d="M81.6099 16.1299C82.3422 16.0911 83.4564 16.1258 84.2169 16.1259L84.2179 16.7058L82.1906 16.7014V18.0281C82.558 18.0232 83.8613 17.985 84.155 18.0495C84.2237 18.1934 84.2023 18.4296 84.1989 18.5961L82.1941 18.5926C82.1756 19.0168 82.1921 19.5898 82.1911 20.0252C82.8376 19.9896 83.5504 20.0059 84.2033 20.0093L84.2038 20.6012L81.6114 20.6039L81.6099 16.1299Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.048 2.01512C101.256 1.6316 104.166 3.92364 104.545 7.13201C104.924 10.3404 102.628 13.2476 99.4195 13.6222C96.2171 13.9961 93.3168 11.7055 92.9382 8.50345C92.5602 5.30137 94.8467 2.39791 98.048 2.01512ZM98.6867 3.10704C96.0788 3.1376 93.9916 5.28081 94.0306 7.88864C94.0691 10.4965 96.2186 12.5769 98.8266 12.5301C101.423 12.4836 103.492 10.3453 103.453 7.74895C103.415 5.15252 101.284 3.07662 98.6867 3.10704Z" fill="#C2BFB9"/>
<path d="M56.334 2.23936L56.3354 7.25333C56.3374 8.25301 56.4163 9.6123 56.1474 10.5587C55.2582 13.6866 51.1053 14.6067 48.8433 12.3446C48.2999 11.7962 47.9137 11.1118 47.7254 10.3629C47.5005 9.45968 47.5651 7.80546 47.5654 6.80158L47.5691 2.24145L49.4849 2.24193L49.4795 8.18762C49.4776 9.16714 49.4898 10.3964 50.2425 11.0251C52.1474 12.6169 54.6429 11.2013 54.4202 8.74805C54.3774 8.27888 54.4144 7.46752 54.4139 7.02125L54.4149 2.23912L56.334 2.23936Z" fill="#C2BFB9"/>
<path d="M64.2963 2.24198C64.4346 2.60897 64.5974 2.98453 64.7523 3.34541C66.1905 6.68834 67.541 10.0817 68.9997 13.4146C68.6109 13.4031 68.1783 13.4146 67.7866 13.4166C67.3427 12.3577 66.9111 11.1524 66.4823 10.0707C65.6556 8.00572 64.8181 5.94491 63.9704 3.88842C63.9002 3.7518 63.8032 3.46465 63.7462 3.31049C63.4787 4.19435 62.7523 5.83983 62.3714 6.78616C61.4749 8.9828 60.6101 11.1921 59.7765 13.4133C59.3853 13.4134 58.9824 13.4254 58.5907 13.432L63.1572 2.24117L64.2963 2.24198Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.6301 2.24031C72.8651 2.25193 74.1007 2.22964 75.3357 2.23708C75.7045 2.23929 76.0758 2.23642 76.4417 2.28679C76.8475 2.33928 77.2431 2.45454 77.6134 2.62845C79.856 3.69654 80.1371 6.73413 78.1517 8.17892C77.7108 8.49963 77.3459 8.63518 76.8344 8.81442C77.685 10.1576 78.7934 12.0261 79.568 13.4145C79.1047 13.4104 78.6414 13.4155 78.1786 13.4297L75.6593 8.91114C74.6878 8.9184 73.7168 8.91904 72.7453 8.91319L72.7472 13.4206C72.3969 13.4055 71.9843 13.4234 71.6291 13.4305L71.6301 2.24031ZM77.6153 3.93647C76.8548 3.20318 75.7304 3.2863 74.7623 3.29212C74.0948 3.29205 73.4059 3.31248 72.7453 3.29174L72.7482 7.88555C73.3845 7.8645 74.0242 7.87144 74.6605 7.8787C75.6919 7.89049 76.8388 7.97424 77.6534 7.19857C78.54 6.24858 78.5785 4.86534 77.6153 3.93647Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.6478 2.23698C86.2003 2.23632 86.9369 2.21451 87.465 2.2867C87.8694 2.33854 88.2636 2.45389 88.6324 2.6285C90.8706 3.69892 91.1668 6.75605 89.1347 8.20509C88.7079 8.50973 88.3523 8.64067 87.8578 8.8129L90.6016 13.422C90.149 13.4092 89.6477 13.4173 89.1917 13.4154L88.9598 13.012C88.342 11.7801 87.3681 10.1562 86.6729 8.91086C85.7024 8.92129 84.7319 8.92301 83.7609 8.91614L83.7584 13.413C83.3945 13.411 83.0145 13.4228 82.6491 13.4286L82.6476 2.24102L85.6478 2.23698ZM89.3369 5.58386C89.2385 3.46873 87.4743 3.19518 85.7711 3.29355C85.1918 3.32701 84.3348 3.30925 83.7604 3.29512L83.7619 7.88569C84.4386 7.85368 85.165 7.87691 85.8442 7.87818C86.9671 7.88032 87.6848 7.96626 88.6105 7.253C89.0821 6.7754 89.3676 6.24411 89.3369 5.58386Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.6959 2.2405C38.0088 2.23962 41.1262 2.08233 42.1825 2.44885C43.071 2.75713 43.818 3.37736 44.2302 4.22851C44.6343 5.08458 44.6874 6.06475 44.3782 6.95954C44.0294 7.95825 43.3762 8.54797 42.4479 8.99488C43.2341 10.4347 44.1699 11.9925 44.9966 13.4126C44.2553 13.4093 43.5139 13.4123 42.7725 13.4216C42.6669 13.375 40.7449 9.78423 40.4665 9.2744C39.8363 9.29482 39.2057 9.29505 38.5756 9.27502L38.5742 13.4089C37.9485 13.4032 37.3228 13.4096 36.6973 13.4282L36.6959 2.2405ZM42.6604 5.66365C42.5015 3.61229 40.0352 4.19297 38.6077 4.11636C38.5295 4.47411 38.5793 6.90255 38.5753 7.43461C39.7802 7.38691 41.3058 7.66269 42.1911 6.95226C42.5306 6.55729 42.7023 6.20474 42.6604 5.66365Z" fill="#C2BFB9"/>
<path d="M107.485 2.24083L110.416 11.7167C111.247 9.42164 112.937 4.86395 113.836 2.33171C113.871 2.31265 113.901 2.29626 113.926 2.28261C113.993 2.27922 114.106 2.26986 114.165 2.29626C114.177 2.30151 114.235 2.33516 114.238 2.34103C115.238 4.99759 116.839 9.22764 117.773 11.7282C118.689 8.61534 119.661 5.32554 120.647 2.24083L121.8 2.2406V2.32586C121.717 2.4931 121.511 3.18662 121.446 3.39579L118.316 13.4202C118.001 13.409 117.609 13.4225 117.287 13.4247C117 12.5099 116.52 11.3168 116.181 10.4001L114.048 4.62407C113.126 7.44773 111.905 10.5943 110.885 13.4162L109.871 13.4258C109.707 12.9473 109.552 12.452 109.402 11.9688C108.394 8.72176 107.293 5.49167 106.305 2.23965L107.485 2.24083Z" fill="#C2BFB9"/>
<path d="M34.0608 2.2406L34.0592 4.08982L30.9074 4.08301L30.9068 13.4006L29.0442 13.4035L29.0456 4.07811L25.884 4.08896L25.8833 2.23907L34.0608 2.2406Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="37" height="48" viewBox="0 0 37 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37 48H23.3135C17.0346 47.9999 13.3532 43.0774 15.0801 37.0127L16.7461 31.1621C17.458 28.6708 15.5955 26.1868 13.0205 26.1865C12.1061 26.1865 11.4659 26.9844 11.2399 27.8705L9.74121 33.748C8.5757 38.3031 4.21923 42 0 42V6H9.18164C13.4008 6.00006 15.8743 9.69691 14.7139 14.252L14.0983 16.6643C13.497 19.0203 13.9592 22.0946 16.3906 22.0947C18.125 22.0946 19.6625 20.9366 20.1396 19.2529L22.4951 10.9873H22.5107C24.2377 4.92254 30.7286 1.98986e-06 37 0V48Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#211927"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#211927"><path d="M3 5.548l7.546-1.03v7.287H3V5.548zm0 12.904l7.546 1.03v-7.177H3v6.147zm8.454 1.14L22 21v-8.695h-10.546v8.287zM11.454 4.408L22 3v8.695H11.454V4.408z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -1,110 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 900" fill="none">
<!-- Background geometric lines -->
<g stroke="#49378B" stroke-width="1.5" fill="none" opacity="0.4">
<!-- Outer hexagonal frame layers -->
<path d="M400 80 L600 200 L600 440 L400 560 L200 440 L200 200 Z" />
<path d="M400 120 L570 220 L570 420 L400 520 L230 420 L230 220 Z" />
<!-- Connector lines going up -->
<line x1="300" y1="160" x2="300" y2="60" />
<line x1="400" y1="120" x2="400" y2="20" />
<line x1="500" y1="160" x2="500" y2="60" />
<!-- Bottom platform layers -->
<path d="M250 520 L550 520 L600 560 L600 600 L400 700 L200 600 L200 560 Z" opacity="0.3" />
<path d="M280 620 L520 620 L560 650 L560 680 L400 760 L240 680 L240 650 Z" opacity="0.2" />
<path d="M320 700 L480 700 L510 720 L510 740 L400 800 L290 740 L290 720 Z" opacity="0.15" />
</g>
<!-- 3D Isometric cube cluster -->
<g transform="translate(400, 380)">
<!-- Back layer cubes (purple/dark) -->
<!-- Top back -->
<g transform="translate(0, -100)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Middle row - left back -->
<g transform="translate(-70, -55)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Middle row - right back -->
<g transform="translate(70, -55)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Yellow accent cubes - front facing -->
<!-- Top -->
<g transform="translate(0, -65)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Middle left yellow -->
<g transform="translate(-70, -20)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Middle right yellow -->
<g transform="translate(70, -20)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Center purple -->
<g transform="translate(0, -20)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Bottom row -->
<g transform="translate(-70, 25)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<g transform="translate(70, 25)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Front bottom yellow -->
<g transform="translate(0, 25)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Outer corner yellow accents -->
<g transform="translate(-105, 5)">
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
<polygon points="0,-2 20,-12 20,12 0,25" fill="#d4e04e" />
</g>
<g transform="translate(105, 5)">
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
<polygon points="0,-2 -20,-12 -20,12 0,25" fill="#e0ec50" />
</g>
<g transform="translate(0, -135)">
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
</g>
</g>
<!-- Bottom arrow/chevron shape -->
<path d="M340 780 L400 820 L460 780 L460 850 L400 890 L340 850 Z" fill="#211927" stroke="#49378B" stroke-width="1" opacity="0.5" />
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg">
<g id="title404" fill="currentColor" transform="matrix(0.9346 -0.02737 -0.00901 1.0422 -597.31 -135.88)">
<path d="M1436.44 522.556V439.749L1462.05 454.536C1478.8 464.21 1489.92 479.348 1489.92 505.313V518.746C1489.92 544.611 1478.72 546.966 1462.05 537.342L1436.44 522.556ZM1442.77 519.899L1461.87 530.926C1477.15 539.748 1483.32 535.087 1483.32 515.137V501.303C1483.32 481.253 1477.15 469.573 1461.87 460.751L1442.77 449.724V519.899Z"/>
<path d="M1369.77 484.067V401.261L1376.02 404.87L1416.65 498.303V428.328L1422.73 431.837V514.643L1416.48 511.034L1375.85 417.601V487.576L1369.77 484.067Z"/>
<path d="M1330.66 462.491C1311.56 451.464 1304.62 438.231 1304.62 417.68V363.645L1311.04 367.354V421.69C1311.04 440.737 1318.34 448.857 1330.66 455.975C1342.99 463.093 1350.2 463.343 1350.2 444.296V389.96L1356.62 393.67V447.704C1356.62 468.757 1349.24 473.218 1330.66 462.491Z"/>
<path d="M1265.37 424.794C1245.75 413.466 1236.11 392.263 1236.11 372.614V358.278C1236.11 338.83 1245.75 328.654 1265.37 339.983C1285.08 351.361 1294.72 372.664 1294.72 392.113V406.448C1294.72 426.097 1285.08 436.173 1265.37 424.794ZM1242.71 376.724C1242.71 394.068 1250.53 409.506 1265.37 418.077C1280.3 426.699 1288.12 420.283 1288.12 402.94V388.002C1288.12 370.96 1280.3 355.321 1265.37 346.699C1250.53 338.128 1242.71 344.745 1242.71 361.787V376.724Z"/>
<path d="M1187.24 378.685V295.879L1231.78 321.593V328.009L1193.58 305.954V341.643L1229.79 362.545V368.961L1193.58 348.059V382.344L1187.24 378.685Z"/>
<path d="M1129.17 345.153V268.763L1106.33 255.58V249.164L1158.34 279.189V285.605L1135.5 272.422V348.812L1129.17 345.153Z"/>
<path d="M1076.63 315.825C1057.01 304.497 1047.37 283.294 1047.37 263.645V249.309C1047.37 229.861 1057.01 219.685 1076.63 231.014C1096.34 242.392 1105.98 263.695 1105.98 283.144V297.479C1105.98 317.128 1096.34 327.204 1076.63 315.825ZM1053.97 267.755C1053.97 285.099 1061.79 300.537 1076.63 309.108C1091.56 317.73 1099.38 311.314 1099.38 293.971V279.033C1099.38 261.991 1091.56 246.352 1076.63 237.73C1061.79 229.159 1053.97 235.776 1053.97 252.818V267.755Z"/>
<path d="M983.922 261.297V178.49L990.173 182.099L1030.8 275.532V205.558L1036.88 209.066V291.873L1030.63 288.264L989.999 194.831V264.805L983.922 261.297Z"/>
<path d="M905.279 215.892V133.086L949.557 158.65V165.066L911.617 143.161V174.94L947.994 195.943V202.359L911.617 181.356V213.135L949.557 235.04V241.456L905.279 215.892Z"/>
<path d="M864.552 193.381C846.059 182.704 836.422 161.702 836.422 141.852V127.517C836.422 107.767 845.972 97.8425 865.507 109.121C883.131 119.296 892.855 137.642 893.723 154.083L893.81 158.243L887.038 154.333V149.822C886.343 135.687 878.356 123.156 865.68 115.837C850.313 106.965 842.934 113.832 842.934 130.875V145.912C842.934 163.356 850.4 178.594 865.073 187.065C878.096 194.584 886.864 191.225 887.125 175.436V170.123L860.732 154.885V148.669L893.81 167.767V209.27L889.208 206.614L888.601 191.426C884.52 198.193 876.706 200.398 864.552 193.381Z"/>
<path d="M777.479 142.107L801.615 73.2351L809.515 77.7965L833.738 174.588L826.966 170.678L821.93 150.027L789.286 131.18L784.251 146.017L777.479 142.107ZM791.11 125.816L820.107 142.558L805.869 84.2124L805.522 83.0094L805.261 83.8616L791.11 125.816Z"/>
<path d="M727.161 113.056V30.2494L756.853 47.3921C772.047 56.164 779.513 69.2967 779.513 86.9407V88.1437C779.513 105.687 772.047 110.6 756.853 101.828L733.499 88.3442V116.715L727.161 113.056ZM733.499 82.0285L756.419 95.2615C767.792 101.828 772.915 98.67 772.915 84.7352V82.63C772.915 68.7955 767.792 60.0236 756.419 53.4572L733.499 40.2242V82.0285Z"/>
<path d="M582.85 5.5V67.98L592.376 62.48V72.38L582.85 77.88V96.36L572.942 102.08V83.6L532.075 107.195V96.855L573.133 11.11L582.85 5.5ZM541.601 91.795L572.942 73.7V27.61L541.601 91.795Z"/>
<path d="M495.102 148.121C475.096 159.671 464.903 150.816 464.903 126.286V111.546C464.903 87.2364 475.192 66.5564 495.102 55.0614C514.631 43.7864 524.824 52.6414 524.824 76.9514V91.6914C524.824 116.221 514.631 136.846 495.102 148.121ZM475.001 121.556C475.001 127.936 475.763 132.666 477.287 136.076L509.296 63.2564C506.057 60.8364 501.294 61.3864 495.102 64.9614C481.765 72.6614 475.001 86.0264 475.001 104.726V121.556ZM480.812 140.311C484.146 142.456 488.91 141.796 495.102 138.221C508.057 130.741 514.726 117.431 514.726 98.5114V81.6814C514.726 75.7414 514.059 71.1764 512.725 67.8764L480.812 140.311Z"/>
<path d="M447.443 83.6773V146.157L456.969 140.657V150.557L447.443 156.057V174.537L437.535 180.257V161.777L396.668 185.372V175.032L437.726 89.2873L447.443 83.6773ZM406.194 169.972L437.535 151.877V105.787L406.194 169.972Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

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