Compare commits

..

77 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
780 changed files with 31948 additions and 5796 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

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

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

View File

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

View File

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

@@ -7,14 +7,23 @@ on:
types:
- completed
permissions:
contents: read
pull-requests: write
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
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' &&
@@ -31,44 +40,31 @@ jobs:
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
uses: ./.github/actions/resolve-pr-from-workflow-run
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
- name: Read preview URLs
if: steps.pr-meta.outputs.skip != 'true'
id: meta
id: urls
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
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: Write report
- name: Post preview comment
if: steps.pr-meta.outputs.skip != 'true'
run: |
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./preview-report.md
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
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

@@ -12,6 +12,7 @@
"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

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

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

@@ -1 +0,0 @@
export { cn } from '@comfyorg/tailwind-utils'

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 }) => {
@@ -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,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

@@ -1,18 +0,0 @@
<svg width="114" height="23" viewBox="0 0 114 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M59.7261 19.7973C59.7261 21.2999 60.3252 22.0063 61.7621 22.0063C63.1991 22.0063 63.7905 21.2999 63.7905 19.7973V16.8824H64.7098V19.5511C64.7098 21.9979 63.6425 22.7617 61.7621 22.7617C59.8817 22.7617 58.8063 21.9979 58.8063 19.5511V16.8824H59.7261V19.7973Z" fill="#C2BFB9"/>
<path d="M88.2206 16.7512C90.0927 16.7513 91.3324 17.5068 91.349 18.6973H90.4045C90.4127 17.9584 89.5177 17.5146 88.2696 17.5146C86.9233 17.5146 86.2004 17.835 86.2004 18.4097C86.2004 19.8713 91.4883 18.3443 91.4883 20.98C91.4881 22.154 90.1824 22.7617 88.2943 22.7617C86.3566 22.7616 85.125 21.9733 85.1494 20.8155H86.0853C86.0771 21.5463 86.9724 21.9978 88.2287 21.9978C89.7391 21.9896 90.5603 21.6778 90.5604 21.0209C90.5604 19.3295 85.2561 20.9633 85.2558 18.5167C85.2558 17.359 86.4717 16.7512 88.2206 16.7512Z" fill="#C2BFB9"/>
<path d="M25.8672 22.3676L28.6832 16.8824H29.8165L26.7865 22.63H24.9393L21.9093 16.8824H23.0426L25.8672 22.3676Z" fill="#C2BFB9"/>
<path d="M37.4926 17.6463H33.174V19.3624H37.4189V20.0602H33.174V21.8666H37.4926V22.63H32.2541V16.8824H37.4926V17.6463Z" fill="#C2BFB9"/>
<path d="M46.0695 21.5134V16.8824H46.9889V22.63H46.0448L41.4633 18.0076V22.63H40.5435V16.8824H41.4794L46.0695 21.5134Z" fill="#C2BFB9"/>
<path d="M56.2693 17.6463H53.3463V22.63H52.427V17.6463H49.5121V16.8824H56.2693V17.6463Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.3161 16.8824C73.5067 16.8824 74.1638 17.5231 74.1638 18.4017C74.1637 19.3212 73.4244 19.8468 72.3161 19.8468H71.7081C73.0301 19.937 73.5643 20.758 74.5821 22.63H73.5805C72.5131 20.7169 72.2256 20.1258 71.1338 20.1258H68.7694L68.777 22.63H67.8577V16.8824H72.3161ZM68.7694 19.3376H72.0285C72.8987 19.3376 73.2439 19.0175 73.244 18.5495C73.244 17.9255 72.8827 17.6544 72.0285 17.6544H68.7608L68.7694 19.3376Z" fill="#C2BFB9"/>
<path d="M82.4683 17.6463H78.1492V19.3624H82.3942V20.0602H78.1492V21.8666H82.4683V22.63H77.2299V16.8824H82.4683V17.6463Z" fill="#C2BFB9"/>
<path d="M6.82474 0C10.6948 4.35571e-05 12.5148 1.24639 12.5148 1.24639V3.24338C12.5148 3.24338 10.6948 1.65668 6.9683 1.65663C4.07267 1.65663 2.17477 2.33495 2.17477 3.61749C2.17519 7.145 13.5522 3.35015 13.5525 9.15639C13.5525 11.688 10.9865 13.041 6.95119 13.041C2.69336 13.041 0.0167388 11.2525 0 11.2413V9.33892C0 9.33892 3.01305 11.2367 6.75677 11.2385C9.99295 11.2385 11.5455 10.7091 11.5455 9.2486C11.5453 5.47073 0.14838 9.46271 0.148312 3.86753C0.148312 1.33587 3.03945 0 6.82474 0Z" fill="#C2BFB9"/>
<path d="M113.4 1.95088H103.178V5.3516H111.851V7.061H103.178V11.1101H113.4V12.7677H101.186V0.293297H113.4V1.95088Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.5959 0.293297C44.1736 0.293325 45.7748 1.89222 45.7748 3.82142C45.7721 6.16157 43.9928 7.33195 41.5931 7.33195H35.2204V12.7667H33.2476V0.293297H41.5959ZM35.2175 5.62256H41.1476C42.9965 5.62256 43.7788 5.08577 43.7788 3.80954C43.7796 2.53432 42.9973 1.96799 41.1476 1.96799H35.2175V5.62256Z" fill="#C2BFB9"/>
<path d="M65.2223 5.34162H74.1286V0.284741H76.1208V12.7577H74.1286V6.99778H65.2223V12.7577H63.2295V0.284741H65.2223V5.34162Z" fill="#C2BFB9"/>
<path d="M81.6388 12.7577H79.6466V0.284741H81.6388V12.7577Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.0372 0.270955C96.6238 0.270955 98.0511 1.66293 98.0511 3.57138C98.051 5.56913 96.3736 6.71066 94.0372 6.71066L92.7171 6.72872V6.7639C95.589 6.95987 96.7305 8.69061 98.9424 12.7577H96.7125C94.3941 8.60131 93.5165 7.31681 91.1617 7.31674H87.1444L87.1611 12.7577H85.1627V0.270955H94.0372ZM87.1444 5.6045H93.4135C95.304 5.60445 96.0536 4.90875 96.0536 3.89177C96.0536 2.53619 95.2684 1.94711 93.4135 1.94707H87.1264L87.1444 5.6045Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.5869 0.284741C59.1648 0.284762 60.7658 1.88318 60.7658 3.81239C60.7633 6.15432 58.9847 7.32241 56.584 7.32245H50.2113V12.7568H48.2386V0.284741H56.5869ZM50.2113 5.61495H56.1415C57.9902 5.61495 58.7726 5.07773 58.7726 3.80241C58.7726 2.52713 57.9902 1.96087 56.1415 1.96086H50.2113V5.61495Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3638 12.7506H29.1187L27.2182 9.18919H18.8124L16.9076 12.7506H14.6458L17.5712 7.56773V7.55632H17.5783L21.6874 0.284741H24.3399L31.3638 12.7506ZM19.6847 7.55632H26.3468L23.0203 1.31675L19.6847 7.55632Z" fill="#C2BFB9"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -1,4 +0,0 @@
WEBVTT
00:00:00.000 --> 00:00:06.000
AI-generated video showcasing Grok Imagine image generation capabilities

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -1,4 +0,0 @@
WEBVTT
00:00:00.000 --> 00:00:05.000
AI-generated video showcasing Seedance 2.0 video generation capabilities

View File

@@ -1,4 +0,0 @@
WEBVTT
00:00:00.000 --> 00:00:05.000
AI-generated video showcasing Wan 2.2 image-to-video generation capabilities

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 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