Compare commits

...

44 Commits

Author SHA1 Message Date
snomiao
4bdfb57f9b Merge main into bl-selective-snapshot-update
Resolved conflict in pr-update-playwright-expectations.yaml by keeping the detailed comments from the feature branch while adopting the updated workflow name from main.
2025-10-19 03:46:37 +00:00
Christian Byrne
0ff1837ced [ci] allow setting GENERATE_SOURCEMAP as env var (#6134)
## Summary

Introduces a `GENERATE_SOURCEMAP` environment flag in `vite.config.mts`
that defaults to enabled (`true` unless set to `'false'`). This keeps
source maps on by default, while allowing opt-out for lean production
artifacts.

Allows the choice to be made as part of the distribution pipeline and
changed for different targets.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6134-ci-allow-setting-GENERATE_SOURCEMAP-as-env-var-2916d73d3650815d91b9eff12b6e55fd)
by [Unito](https://www.unito.io)
2025-10-18 20:23:27 -07:00
Terry Jia
7e1e8e3b65 subscription page (#6064)
Summary

Implements cloud subscription management UI and flow for ComfyUI Cloud
users.

  Core Features:
- Subscription Status Tracking: Global reactive state management for
subscription status across all components
  using shared subscriptionStatus ref
- Subscribe to Run Button: Replaces the Run button in the actionbar with
a "Subscribe to Run" button for users
  without active subscriptions
- Subscription Required Dialog: Modal dialog with subscription benefits,
pricing, and checkout flow with video
  background
- Subscription Settings Panel: New settings panel showing subscription
status, renewal date, and quick access to
  billing management
- Auto-detection & Polling: Automatically polls subscription status
after checkout completion and syncs state
  across the application


https://github.com/user-attachments/assets/f41b8e6a-5845-48a7-8169-3a6fc0d2e5c8



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6064-subscription-page-28d6d73d36508135a2a0fe7c94b40852)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-10-18 20:21:30 -07:00
Christian Byrne
d83e34d0fc [ci] collapsible sections in ci size report comments (#6118)
## Summary

Adjust size reporting scripts to aggregate baseline/current metrics,
render collapsible GitHub-friendly tables using `<details>`, and
expanded bundle categorisation heuristics

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6118-ci-collapsible-sections-in-ci-size-report-comments-2906d73d365081d0b302c09fa1b98ccb)
by [Unito](https://www.unito.io)
2025-10-18 20:15:51 -07:00
Christian Byrne
c6b528b8be [ci] allow manual workflow dispatch to do version bumping on core branches (rather than just on main) (#6117)
## Summary

Added configurable base branch selection to version bump workflows,
enabling patch releases from `core/*` branches via GitHub Actions UI.

## Changes

- **What**: Extended [workflow_dispatch
inputs](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch)
with `branch` parameter for both main frontend and desktop-ui version
bump workflows
- **Validation**: Added branch existence check that lists available
`core/*` branches on error
- **Workflow modifications**:
- `release-version-bump.yaml`: Checkout and create PRs targeting
user-specified branch
- `version-bump-desktop-ui.yaml`: Same behavior for desktop-ui releases

## Review Focus

Branch validation logic correctly handles both local (`refs/heads/`) and
remote (`refs/remotes/origin/`) refs. Default value preserves backward
compatibility for release sheriffs unfamiliar with new feature.

## Use Case

Previously, patch releases from `core/1.29` required manual version
bumping. Now maintainers can trigger from Actions UI with dropdown
selections.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6117-ci-allow-manual-workflow-dispatch-to-do-version-bumping-on-core-branches-rather-than-j-2906d73d365081cba3aff46471206a9e)
by [Unito](https://www.unito.io)
2025-10-18 13:04:49 -07:00
Christian Byrne
37d2f25d2f [style] ensure all floating menus have same gap from screen edges (#6120)
## Summary

Ensures all the floating shell app elements (graph canvas menu, minmap,
floating sidebar) all have .5rem gap between edge and viewport. Was not
proper distance for graph canvas menu and minimap due to splitter
overlay (parent) itself having 8px gap from viewport.

**After**:

<img width="605" height="535" alt="Screenshot from 2025-10-18 01-34-51"
src="https://github.com/user-attachments/assets/215efd5c-541f-441a-8b0d-1eb1f6fce672"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6120-style-ensure-all-floating-menus-have-same-gap-from-screen-edges-2906d73d365081598ae4d16f2380baf8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-18 05:07:06 -07:00
Christian Byrne
3dcabaeb0a Mark failing vue nodes bypass test as fixme temporarily (#6127)
## Summary

Mark this test as `fixme` while I fix it. Don't want to disrupt CI
signal for other repo maintainers - the test has already been broken for
nearly 2 days now which is not acceptable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6127-Mark-failing-vue-nodes-bypass-test-as-fixme-temporarily-2906d73d36508186a1def1d2dc7f85a5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-10-18 05:06:24 -07:00
Comfy Org PR Bot
eb85f99b10 1.30.1 (#6124)
Patch version increment to 1.30.1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6124-1-30-1-2906d73d36508176a9a0dca7ecfbb40c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-18 04:24:04 -07:00
Christian Byrne
2e99c394a0 fix minimap not re-rendering when Vue nodes change mode (bypass, mute) (#6110)
## Summary

Fixes minimap not updating when node bypass/mute state changes in vue
nodes mode by adding `onTrigger` tracking of relevant instrumented
properties.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6110-fix-minimap-not-re-rendering-when-Vue-nodes-change-mode-bypass-mute-28f6d73d36508112bab1ce031705c6a9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-18 03:59:19 -07:00
Johnpaul Chiwetelu
9ef6f94f92 [fix] Enable AUDIO_RECORD widget in both LiteGraph and Vue nodes modes (#6094)
Fixed the AUDIO_RECORD widget to display correctly in both rendering
modes:

- Removed conflicting AUDIO_RECORD registration from ComfyWidgets that
was blocking the custom widget implementation in uploadAudio.ts
extension
- Changed canvasOnly flags from true to false on both audioUIWidget and
recordWidget to enable Vue nodes rendering
- Added type override (recordWidget.type = 'audiorecord') after widget
creation to enable Vue component lookup while preserving LiteGraph
button rendering
- Removed unused IAudioRecordWidget type definition

The widget now works correctly:
- LiteGraph mode: Displays as a functional button
- Vue nodes mode: Displays full recording UI with waveform visualization

## Summary

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

## Changes

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

## Review Focus

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

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

## Screenshots (if applicable)

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



https://github.com/user-attachments/assets/cf6513d4-0a4b-4210-88e2-a948855b5206


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6094-fix-Enable-AUDIO_RECORD-widget-in-both-LiteGraph-and-Vue-nodes-modes-28e6d73d365081faa879f53e3e2dddad)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2025-10-18 03:15:26 -07:00
Christian Byrne
598d170d10 [style] update dropdown button text class in Vue node upload widgets (#6062)
## Summary

Change colors to match Figma variables.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6062-style-update-dropdown-button-text-class-in-Vue-node-upload-widgets-28c6d73d365081ae8857ddcef06784ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-18 02:09:38 -07:00
Christian Byrne
2c221bdd7b [ci] enable minification in build workflows (#6116)
## Summary

Continuation of https://github.com/Comfy-Org/ComfyUI_frontend/pull/6068:
set `SHOULD_MINIFY` in build workflows to enable minification.

Our build plugin dictates what files are available for extensions to
import at runtime:
bfe083dcba/build/plugins/comfyAPIPlugin.ts (L52)

The shimming marks these files and their named exports and named memebrs
as side-effectful, so names should be preserved from the perspective of
3rd party extensions, making this safe in theory.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6116-ci-enable-minification-in-build-workflows-2906d73d36508141be57f63b619f9f16)
by [Unito](https://www.unito.io)
2025-10-18 01:47:43 -07:00
Christian Byrne
76b44d3de5 [perf] disable cache-busting param on cloud (#6105)
## Summary

Updates `ComfyApp.getRandParam()` to return an empty string on cloud
builds (detected via `isCloud`), letting hosted deployments rely on HTTP
caching for `/view` assets while local hosts retain the legacy cache
busting. The audio widget helpers now defer to the same method instead
of generating their own random suffix, so every frontend consumer shares
this logic.

This keeps the original overwrite protection for localhost workflows but
removes needless cache misses in the cloud stack, where GCS already
serve strong ETags and there is not really a concern about stale
filenames. Once the backend exposes a deterministic file-version field,
we can delete the remaining local-only randomness entirely.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6105-perf-disable-cache-busting-param-on-cloud-28f6d73d36508181b34afad406fabc59)
by [Unito](https://www.unito.io)
2025-10-18 01:26:40 -07:00
Christian Byrne
3fc8b49038 [refactor] simplify redundant check in cloud badge extension (#6106)
Simplifies code with tautological condition. The extension is only
loaded when `isCloud` is true due to
38f188759d/src/extensions/core/index.ts (L27-L29)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6106-refactor-simplify-redundant-check-in-cloud-badge-extension-28f6d73d365081d69903f5a985fdc8be)
by [Unito](https://www.unito.io)
2025-10-18 01:25:59 -07:00
Christian Byrne
10748bdac9 [auth] handle auth/requires-recent-login for account deletion and password updates (#6109)
## Summary

Implemented error recovery system to handle Firebase
`auth/requires-recent-login` errors when deleting accounts or updating
passwords.


https://github.com/user-attachments/assets/92a79e2a-cff5-4b18-b529-dbaf5f2303f2

## Changes

- **What**: Added [ErrorRecoveryStrategy
pattern](https://firebase.google.com/docs/auth/web/manage-users#re-authenticate_a_user)
to `useErrorHandling` composable with automatic retry logic for
sensitive Firebase operations
- **Breaking**: None - recovery strategies are optional, all existing
code unchanged

## Technical Details

Firebase enforces
[reauthentication](https://firebase.google.com/docs/reference/js/auth#autherrorcodes)
for security-sensitive operations (account deletion, password changes)
after ~5 minutes of inactivity. Previously these operations failed with
cryptic error messages.

New flow:
1. Operation throws `auth/requires-recent-login`
2. Recovery strategy shows confirmation dialog
3. User logs out and re-authenticates
4. Operation automatically retries

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6109-auth-handle-auth-requires-recent-login-for-account-deletion-and-password-updates-28f6d73d36508119abf4ce30eecea976)
by [Unito](https://www.unito.io)
2025-10-18 01:24:52 -07:00
AustinMroz
bfe083dcba Move subgraph badge into node title (#6115)
<img width="1350" height="691" alt="image"
src="https://github.com/user-attachments/assets/4a1a909f-463e-4c77-8855-6fe0a177b659"
/>

Design docs show icon as being reversed in litegraph mode. If needed,
fixing this is basically just removing a negative sign.

LiteGraph implementation was not as clean as I hoped. I can spend some
more time divining some non-magic constants, and mayybe resolve the svg
data from css styles if desired.

Resolves #4647
Resolves #6107

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6115-Move-subgraph-badge-into-node-title-2906d73d365081e4ba82d321fc27afed)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-10-18 00:32:54 -07:00
Rizumu Ayaka
917f8ae27d ci: size report (#6082)
## Summary

show bundle size info automatically in Pull Request

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6082-ci-size-report-28e6d73d365081c2bf73ce9919a7c01a)
by [Unito](https://www.unito.io)
2025-10-17 19:23:42 -07:00
Benjamin Lu
8dee207b8c WorkflowTabs: cleanup scroll/overflow handling and watcher disposal (#6080)
## Summary

Refactor and cleanup `WorkflowTabs` scroll/overflow handling to improve
stability, ensure proper watcher disposal, and keep the active tab in
view more reliably.

note: honestly a nit, can drop if review is too annoying

## Changes

- **What**: 
- Replace `ScrollPanel` ref with `containerRef` and query
`.p-scrollpanel-content` within the container.
- Add computed `scrollContent` to centralize access to the scrollable
element.
- Add `ensureActiveTabVisible({ waitForDom })` option to skip `nextTick`
when not needed.
- Rework watchers with explicit `WatchStopHandle`s and `onCleanup` to
stop previous watchers and dispose `overflowObserver` correctly when
`scrollContent` changes.
- Update arrow enable logic by watching `arrivedState.left/right`
together and setting `leftArrowEnabled`/`rightArrowEnabled` immediately.
- Only measure and re-ensure visibility when overflowing; call
`scrollState.measure()` and `ensureActiveTabVisible({ waitForDom: false
})` after arrows update.
- **Breaking**: None
- **Dependencies**: None

## Review Focus

- Correctness of watcher lifecycle and cleanup when `scrollContent`
changes
- Arrow enablement behavior at scroll boundaries
- Reliability of `ensureActiveTabVisible` across tab selection and
overflow changes
- Regressions in scroll performance and tab visibility

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6080-WorkflowTabs-cleanup-scroll-overflow-handling-and-watcher-disposal-28d6d73d3650819cba6de70fb10354ad)
by [Unito](https://www.unito.io)
2025-10-17 15:12:03 -07:00
AustinMroz
a0d9cc7e33 Add subgraph management keybinds (#6114)
Adds a new keybind to attempt to toggle the promotion state of the
currently hovered widget

Adds a keybind to open the subgraph configuration panel

Neither of these options are given a default binding.

Cleans up some incorrect version and icon information that was forgotten
about.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6114-Add-subgraph-management-keybinds-28f6d73d365081e6b6f3c78f99afc094)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
2025-10-17 15:09:37 -07:00
Christian Byrne
d1639c4377 [ci] extend backport workflow to work with arbitrary branches (#6108)
## Summary

Expands the PR backport workflow so maintainers can target any release
branch using labels, instead of being limited to the `core/x.y` release
lines. The workflow now collects labels formatted as plain version
numbers (`1.24`) as before, plus new prefixes like
`branch:release/hotfix` or `backport:partner/foo`, validates that each
referenced branch exists, and then cherry-picks the source merge commit
to every target.

All generated PRs and failure comments reference the actual branch name,
making it clear where the backport landed or why it failed. This keeps
the existing opt-in flow (`needs-backport`) but makes it flexible enough
for custom support and partner branches without extra manual work.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6108-ci-extend-backport-workflow-to-work-with-arbitrary-branches-28f6d73d365081bf85a3d4c40a23bb68)
by [Unito](https://www.unito.io)
2025-10-17 14:38:06 -07:00
Benjamin Lu
9f046a11ea Use scrollIntoView to keep active workflow tab visible (#6077)
> "The watcher that should keep the active workflow tab visible compares
the tab’s bounds against the scroll panel’s content element, whose
rectangle spans the entire scroll width instead of the visible viewport.
As a result, when the active tab starts off-screen, the computed offsets
stay ≤ 0 and no corrective scroll occurs, so the selected tab remains
hidden."

fixes #6057

------
https://chatgpt.com/codex/tasks/task_e_68efe2b5b4a08330940a20552c1db339
2025-10-17 00:37:43 -07:00
sno
e1cd88afe3 fix(ci): update claude review workflow to match renamed CI workflows (#6101)
## Summary
- Investigated Claude Review workflow failures after CI workflow
renaming
- Found and fixed outdated workflow references in `.claude/commands`
files
- Created comprehensive workflow evolution documentation

## Problem
Claude Review workflows were failing, initially thought to be due to
workflow name changes in commit 5a7ec8148. Investigation revealed the
real issues and uncovered additional documentation problems.

## Root Cause Analysis
After checking the [lewagon/wait-on-check-action
documentation](https://github.com/lewagon/wait-on-check-action):
- `check-regexp` matches against job names (`jobs.<job_id>.name`) 
- Job names like `lint-and-format`, `test`, `playwright-tests` were
never changed
- The real issue was Playwright test failures, not regex patterns

## Findings & Fixes

### 1. Claude Review Workflow 
- **Issue**: Failures were caused by actual test failures (e.g.,
`playwright-tests-chromium-sharded (8, 8): completed (failure)`)
- **Fix**: Reverted unnecessary regex changes since original patterns
were correct

### 2. Outdated Command References 
Found 5 outdated workflow references in `.claude/commands` files:

| File | Old Reference | New Reference |
|------|---------------|---------------|
| `create-frontend-release.md` | `release.yaml` |
`release-draft-create.yaml` |
| `create-frontend-release.md` | `create-release-candidate-branch.yaml`
| `release-branch-create.yaml` |
| `create-frontend-release.md` | `release.yaml` (in script) |
`release-draft-create.yaml` |
| `create-hotfix-release.md` | `release.yaml` |
`release-draft-create.yaml` |
| `create-frontend-release.md` | workflow text reference | updated to
match filename |

### 3. Historical Analysis & Documentation 📚
Created comprehensive workflow evolution timeline:

#### Documentation Added:
- `logs/workflow-renames.md`: Complete mapping of all workflow name
changes
- `logs/report.md`: Comprehensive audit report with findings and
recommendations
- `logs/track-workflow-renames.md`: **Exhaustive history of all 77
workflow files that ever existed**

#### Key Historical Insights:
- **77 unique workflow files** have existed since 2024-06-13
- **195 commits** analyzed affecting workflows
- **10 evolution phases** from ad-hoc naming to systematic
categorization
- **52 rename operations** and **10 file deletions**
- **68% reduction** through optimization (77 total → 25 active)

#### Timeline Highlights:
- **2024-06-13**: `test-ui.yaml` - first workflow ever created
- **2025-10-01**: First major reorganization (17 renames)
- **2025-10-02**: Category-based naming introduction (`ci-*`, `pr-*`,
`release-*`)
- **2025-10-16**: API category refinement (`api-update-*`)
- **2025-10-17**: Final consolidation (our current branch)

#### Evolution Patterns:
- **Era 1**: Descriptive names (`test-ui.yaml`, `format.yaml`)
- **Era 2**: Category prefixes (`ci-*`, `pr-*`, `release-*`)
- **Era 3**: Action-target patterns (`api-update-*-types.yaml`)
- **Era 4**: Semantic categorization (current state)

## Impact
-  Claude Review workflow now works correctly (job names were always
correct)
-  Command documentation now references actual workflow files
-  Complete archaeological record of workflow evolution created
-  Future workflow changes will be easier to track with comprehensive
documentation

## Test Plan
- [x] Workflow syntax validation passes
- [x] All `gh run list --workflow=<filename>` commands in docs now work
- [x] Documentation provides complete historical context
- [ ] Monitor if Claude Review works properly once underlying test
failures are resolved

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-10-17 00:25:43 -07:00
AustinMroz
38f188759d Fix mouse clicks being swallowed by new menu (#6103)
Moves around pointer events styling so that mouse events near the top
and side bar don't get swallowed.

Resolves #6102

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6103-Fix-mouse-clicks-being-swallowed-by-new-menu-28f6d73d3650819aa4a1dc1eacfd3adb)
by [Unito](https://www.unito.io)
2025-10-16 22:26:54 -07:00
Christian Byrne
ad5be8ec70 [style] update style of mask editor button on Vue node image preview (#6060)
Updates style of mask editor button on Vue nodes image preview overlay
to align with
[design](https://www.figma.com/design/31uH3r4x3xbIctuRWYW6NM/V3---Vue-Nodes?node-id=7744-84270&m=dev).
Also makes the custom mask have color be derived from text color class.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6060-style-update-style-of-mask-editor-button-on-Vue-node-image-preview-28c6d73d36508111a562c6a22c64f027)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-10-16 21:02:55 -07:00
sno
653cf64e01 [chore] Upgrade Prettier from 3.3.2 to 3.6.2 (#6089)
## Summary
- Updated Prettier from 3.3.2 to 3.6.2 in pnpm-workspace.yaml
- Ran `pnpm install` to update dependencies
- Ran `pnpm format` to apply new formatting rules
- Verified typecheck passes

## Test plan
- [x] TypeScript typecheck passes
- [x] Prettier formatting applied successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6089-chore-Upgrade-Prettier-from-3-3-2-to-3-6-2-28e6d73d3650816a888ff1129b14e0bc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-10-16 20:46:59 -07:00
Comfy Org PR Bot
626fcff80b 1.30.0 (#6099)
Minor version increment to 1.30.0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6099-1-30-0-28f6d73d3650817b9505d85b906047c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: arjansingh <1598641+arjansingh@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-16 19:10:17 -07:00
AustinMroz
15b1b91b16 Implement a legacy canvas widget for vue mode (#6011)
![updated-legacy-widget](https://github.com/user-attachments/assets/3f0a1623-9445-4059-acbb-086baec54980)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6011-Implement-a-legacy-canvas-widget-for-vue-mode-2896d73d36508127a5d1debcccb519a0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-10-16 18:50:19 -07:00
Christian Byrne
e48e11e434 fix LiteGraph capturing node pointer events if Vue and LG node positions become desynced (#6058)
## Summary

Added Vue mode guards to LiteGraph canvas to prevent dual event handling
between Vue node components and LiteGraph node event handlers. Fixes a
bug where the litegraph and vue positions become out of sync and then
there are effectively dead spots on the canvas (the user should still be
able to interact with the graph even if the positions desync - the only
real downside of desync should just be mispositioned nodes in the
serialized state).

Returns early only in `processNodeClick` and the node-related
(alt+click+drag node cloning) canvas handlers. In general, the LiteGraph
canvas still owns some events/interactions - but the node interactions
are fully owned by Vue when in Vue nodes mode.

## Changes

- **What**: Added `LiteGraph.vueNodesMode` checks in
[LGraphCanvas.ts](src/lib/litegraph/src/LGraphCanvas.ts) to skip native
event processing when Vue components handle interactions
- **Breaking**: This could have some side effects or break some
extensions if anything was relying on these. However, prior to this
change, things like `processNodeClick` should only have been getting the
click events in de-sync scenarios anyway (described in
[Summary](##Summary) section)

## Review Focus

Any possible side-effects you can think of.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6058-fix-LiteGraph-capturing-node-pointer-events-if-Vue-and-LG-node-positions-become-desynced-28c6d73d365081f389f8c72299c31b0b)
by [Unito](https://www.unito.io)
2025-10-16 18:46:37 -07:00
AustinMroz
09b1e1702c Skip rename operation on focus lost. (#6052)
When renaming a workflow through the breadcrumb bar. It is intended to
complete the rename operation if the user clicks away from the text
input. However, completing the text input by pressing enter can cause
the rename operation to occur twice: On pressing enter and on loss of
focus. When this occurs, the second rename causes, an error to be raised
because renaming a workflow to the same name is not allowed.

Worse, a user should be able to cancel a rename by pressing escape, but
this still causes the "loss of focus" event to fire and rename the
workflow anyways.

As a simple fix to both of these, this PR disables rename on loss of
focus. A rename only occurs when the user completes a rename operation
with the enter key.

Resolves #6051

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6052-Skip-rename-operation-on-focus-lost-28c6d73d3650815d9a5fecd8567d4299)
by [Unito](https://www.unito.io)
2025-10-16 18:45:47 -07:00
Rizumu Ayaka
64430708ea perf: tree shaking and minify (#6068)
## Summary

I believe tree-shaking does not affect [the
shim](https://github.com/Comfy-Org/ComfyUI_frontend/blob/rizumu/perf/cloud-minify/build/plugins/comfyAPIPlugin.ts#L73),
and it should safe to enable it.

maybe we need to find the extension that will broke in this case.


|   | Rendered | Gzip     | Brotli  |
|--------|-----------|----------|----------|
| Before | 16.69 MB  | 4.23 MB | 3.54 MB |
| After  | 12.07 MB  | 3 MB   | 2.53 MB |
2025-10-16 18:44:04 -07:00
Benjamin Lu
8396c9ae94 Rename ImagePreview spec to test (#6093)
## Summary
- rename the ImagePreview test file to use the .test.ts suffix required
outside of browser tests
- align with the eslint no-restricted-syntax rule that forbids .spec.ts
files in tests-ui

## Testing
- pnpm typecheck


------
https://chatgpt.com/codex/tasks/task_e_68f14718b0608330bd559182c9f7c62e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6093-Rename-ImagePreview-spec-to-test-28e6d73d36508191b7e0c076fd96e4a3)
by [Unito](https://www.unito.io)
2025-10-16 18:16:02 -07:00
AustinMroz
1234e1c56d Support Right Click -> Save for animated webp (#6095)
Allow for Right Click -> Save Image to work for the "SaveAnimatedWEBP"
node.

Fixing this revealed other existing issues:
- Attempting to resize the node causes runaway scaling
- Right clicking on the image directly causes a browser context menu
without a save option.

Significant rewriting has been performed to resolve both of these
issues.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6095-Support-Right-Click-Save-for-animated-webp-28e6d73d3650818e85a2ec58c38c2aae)
by [Unito](https://www.unito.io)
2025-10-16 18:15:23 -07:00
pythongosssss
984ebef416 Floating Menus - UI rework (#5980)
## Summary

Enhancing and further modernizing the UI, giving users more usable area
whilst keeping farmiliar positioning and feel of elements.

## Changes

- **What**: Significant restructure of the UI elements, changing
elements from large blocks to floating elements, updating:
- Side toolbar menu (floating style, supports small/normal mode,
combines to scroll on height overflow)
- Bottom tabs panel (floating style, tabs redesigned)
- Action bar (support for docking/undocking menu)
    - Added login/user menu button to top right
- Restyled breadcrumbs (still collapse when overflows)
- Add litegraph support for fps info position (so it isn't covered by
the sidebar)

- **Breaking**: 
- Removed various elements and added new ones, I have tested custom
sidebars, custom actions, etc but if scripts are inserting elements into
"other" elements they may have been (re)moved.
- Remove support for bottom menu
- Remove support for 2nd-row tabs

## Screenshots 
<img width="1116" height="907" alt="ui"
src="https://github.com/user-attachments/assets/b040a215-67d3-4c88-8c4d-f402a16a34f6"
/>


https://github.com/user-attachments/assets/571dbda5-01ec-47e8-b235-ee1b88c93dd0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5980-Floating-Menus-UI-rework-2866d73d3650810aac60cc1afe979b60)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-16 18:12:09 -07:00
snomiao
8cc5b52c64 refactor: reorganize GitHub workflows with consistent naming convention (#5891)
## Summary

This PR implements a systematic naming convention for all GitHub
workflows to improve organization and discoverability. All 22 workflows
have been renamed and grouped by logical categories with consistent
prefixes.

## Changes

### Naming Convention
- **`ci-*`**: Continuous Integration workflows (testing, linting,
validation)
- **`pr-*`**: PR-specific automation triggered by labels  
- **`release-*`**: Release management workflows
- **`types-*`**: TypeScript type generation workflows
- **`i18n-*`**: Internationalization workflows

### Key Renames
- `tests-ci.yaml` → `ci-tests-e2e.yaml`
- `vitest-tests.yaml` → `ci-tests-unit.yaml`
- `storybook-and-chromatic-ci.yaml` → `ci-tests-storybook.yaml`
- `auto-backport.yaml` → `pr-backport.yaml`
- `claude-pr-review.yml` → `pr-claude-review.yaml`
- `version-bump.yaml` → `release-version-bump.yaml`
- `publish-frontend-types.yaml` → `release-npm-types.yaml`
- `create-dev-pypi-package.yaml` → `release-pypi-dev.yaml`

### Test Workflow Improvements
- Grouped all test workflows under `ci-tests-*` pattern
- Fork-safe deployment workflows: `ci-tests-e2e-forks.yaml`,
`ci-tests-storybook-forks.yaml`
- Added comments explaining fork deployment security workarounds

### Documentation
- Added comprehensive `.github/workflows/README.md`
- Documents naming conventions, best practices, and workflow
organization
- Includes trigger patterns and external dependencies

## Benefits

1. **Better Organization**: Workflows are now grouped logically by
prefix
2. **Improved Discoverability**: Easy to find related workflows  
3. **Consistent Naming**: All workflows follow the same pattern
4. **Clear Purpose**: Workflow names immediately indicate their function
5. **Maintainable**: README provides guidelines for future workflows

## Test Plan

- [x] All workflow cross-references updated
- [x] Display names match new file names  
- [x] Fork deployment workflows properly reference main workflows
- [x] Release workflows reference correct npm types workflow
- [x] All workflows retain original functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5891-refactor-reorganize-GitHub-workflows-with-consistent-naming-convention-2806d73d365081febe47c7511bf0507e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-10-17 08:32:33 +09:00
Arjan Singh
03681a12bd feat: AssetCard tweaks (#6085)
## Summary

1. fix `preview_url` logic
2. design tweaks for border radius, fallback gradient, and name line
clamping (2 lines)
3. handle image error states by showing gradient
4. misc. refactors

## Screenshots


<img width="1515" height="1087" alt="Screenshot 2025-10-15 at 8 13
41 PM"
src="https://github.com/user-attachments/assets/85642869-d8cb-4ee4-b23d-a381e33fe802"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6085-feat-AssetCard-tweaks-28e6d73d3650818da7a2f4148be48ff7)
by [Unito](https://www.unito.io)
2025-10-16 14:46:50 -07:00
AustinMroz
9bfc9b740d Add additional check when restoring widgets_values (#6054)
Prior to #6014, I had identified and started on #6015 to resolve what I
believed to be a larger class of issues. While the issue I was solving
produced the same error log on the same line, I misunderstood the
circumstances in which the reported issue would occur. As proxyWidgets
do not have their own value, only linked widgets should have their value
restored from `widgets_values`. This PR adds an additional check both
that the property entry is within bounds, and that the property entry is
for a linked widget.

See #6014
A potential additional issue is still being investigated.
- Additional issue seems unrelated to fix here. Will leave issue open,
but recommend merging this as is to not block v1.28.7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6054-Add-additional-check-when-restoring-widgets_values-28c6d73d365081489d29fc9723132db0)
by [Unito](https://www.unito.io)
2025-10-15 19:37:41 -07:00
AustinMroz
7355a51282 In update-locales workflow, don't skip ci (#6081)
Adding `[skip ci]` causes release workflows to break and the added
safety of rerunning tests is beneficial for releases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6081-In-update-locales-workflow-don-t-skip-ci-28e6d73d365081208332ef7b11ebbb52)
by [Unito](https://www.unito.io)
2025-10-15 18:50:23 -07:00
Arjan Singh
7ca8615947 feat(assetService): increase limit to 500 (#6078)
## Summary

Increase asset API limit to 500 to account for additional models being
added.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6078-feat-assetService-increase-limit-to-500-28d6d73d365081539760f986dd5bff23)
by [Unito](https://www.unito.io)
2025-10-15 14:38:10 -07:00
bymyself
ebd20a815e Merge latest main (v1.29.2) into bl-selective-snapshot-update 2025-10-14 11:56:10 -07:00
bymyself
9616b91700 Merge latest main into bl-selective-snapshot-update
Resolved conflicts by:
- Keeping our selective snapshot update logic in workflows
- Accepting main's Vue node refactoring changes
2025-10-14 11:03:41 -07:00
bymyself
939d1a0e44 Merge branch 'main' into bl-selective-snapshot-update
Resolved conflicts in update-playwright-expectations.yaml by:
- Keeping main's improvements: concurrency control, comment reactions, better branch checkout
- Keeping our selective snapshot update logic with validation
- Keeping our workflow summary generation
- Combined both sets of improvements for a robust solution

Fixed eslint configuration issue where vite.config.mts was in both allowDefaultProject and tsconfig.json
2025-10-12 20:27:25 -07:00
bymyself
df6723415b Address review comments and improve workflow
- Add workflow documentation explaining selective update strategy
- Improve logging with clear output formatting (no emojis)
- Add GitHub Actions workflow summary with file change details
- Fix command injection vulnerability by validating test paths with regex
- Add error handling for JSON.parse with descriptive messages
- Replace non-null assertion with safer null checking pattern
- Add explicit error handling for TypeScript script execution
2025-10-12 16:00:38 -07:00
Benjamin Lu
83ff415815 Merge remote-tracking branch 'origin/main' into bl-selective-snapshot-update 2025-10-07 19:29:01 -07:00
Benjamin Lu
87d3111d5c Only update snapshots of failed 2025-10-06 19:07:22 -07:00
191 changed files with 6035 additions and 1175 deletions

View File

@@ -458,15 +458,15 @@ echo "Workflow triggered. Waiting for PR creation..."
3. **IMMEDIATELY CHECK**: Did release workflow trigger?
```bash
sleep 10
gh run list --workflow=release.yaml --limit=1
gh run list --workflow=release-draft-create.yaml --limit=1
```
4. **For Minor/Major Version Releases**: The create-release-candidate-branch workflow will automatically:
4. **For Minor/Major Version Releases**: The release-branch-create workflow will automatically:
- Create a `core/x.yy` branch for the PREVIOUS minor version
- Apply branch protection rules
- Document the feature freeze policy
```bash
# Monitor branch creation (for minor/major releases)
gh run list --workflow=create-release-candidate-branch.yaml --limit=1
gh run list --workflow=release-branch-create.yaml --limit=1
```
4. If workflow didn't trigger due to [skip ci]:
```bash
@@ -477,7 +477,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
5. If workflow triggered, monitor execution:
```bash
WORKFLOW_RUN_ID=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[0].databaseId')
WORKFLOW_RUN_ID=$(gh run list --workflow=release-draft-create.yaml --limit=1 --json databaseId --jq '.[0].databaseId')
gh run watch ${WORKFLOW_RUN_ID}
```

View File

@@ -246,7 +246,7 @@ For each commit:
3. Merge the PR: `gh pr merge --merge`
4. Monitor release workflow:
```bash
gh run list --workflow=release.yaml --limit=1
gh run list --workflow=release-draft-create.yaml --limit=1
gh run watch
```
5. Track progress:

21
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# GitHub Workflows
## Naming Convention
Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.yaml`
### Category Prefixes
| Prefix | Purpose | Example |
| ---------- | ----------------------------------- | ------------------------------------ |
| `ci-` | Testing, linting, validation | `ci-tests-e2e.yaml` |
| `release-` | Version management, publishing | `release-version-bump.yaml` |
| `pr-` | PR automation (triggered by labels) | `pr-claude-review.yaml` |
| `api-` | External Api type generation | `api-update-registry-api-types.yaml` |
| `i18n-` | Internationalization updates | `i18n-update-core.yaml` |
## Documentation
Each workflow file contains comments explaining its purpose, triggers, and behavior. For specific details about what each workflow does, refer to the comments at the top of each `.yaml` file.
For GitHub Actions documentation, see [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows).

View File

@@ -1,4 +1,5 @@
name: Update Electron Types
name: 'Api: Update Electron API Types'
description: 'When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: Update ComfyUI-Manager API Types
name: 'Api: Update Manager API Types'
description: 'When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,4 +1,5 @@
name: Update Comfy Registry API Types
name: 'Api: Update Registry API Types'
description: 'When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,4 +1,5 @@
name: Validate JSON
name: "CI: JSON Validation"
description: "Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq"
on:
push:

View File

@@ -1,4 +1,5 @@
name: Lint and Format
name: "CI: Lint Format"
description: "Linting and code formatting validation for pull requests"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Devtools Python Check
name: "CI: Python Validation"
description: "Validates Python code in tools/devtools directory"
on:
pull_request:

View File

@@ -1,8 +1,9 @@
name: PR Playwright Deploy (Forks)
name: "CI: Tests E2E (Deploy for Forks)"
description: "Deploys test results from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:
workflows: ["Tests CI"]
workflows: ["CI: Tests E2E"]
types: [requested, completed]
env:

View File

@@ -1,4 +1,5 @@
name: Tests CI
name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
on:
push:
@@ -142,7 +143,7 @@ jobs:
merge-reports:
needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ always() && !cancelled() }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -168,6 +169,26 @@ jobs:
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
- name: Build failed screenshot manifest
if: ${{ needs.playwright-tests-chromium-sharded.result == 'failure' }}
run: |
set -euo pipefail
if ! pnpm tsx scripts/cicd/build-failed-screenshot-manifest.ts; then
echo "ERROR: Failed to generate screenshot manifest"
echo "This may indicate an issue with the Playwright JSON report or the manifest script"
exit 1
fi
working-directory: ComfyUI_frontend
- name: Upload failed screenshot manifest
if: ${{ needs.playwright-tests-chromium-sharded.result == 'failure' }}
uses: actions/upload-artifact@v4
with:
name: failed-screenshot-tests
path: ComfyUI_frontend/ci-rerun/*.txt
retention-days: 7
if-no-files-found: ignore
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:

View File

@@ -1,8 +1,9 @@
name: PR Storybook Deploy (Forks)
name: "CI: Tests Storybook (Deploy for Forks)"
description: "Deploys Storybook previews from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:
workflows: ['Storybook and Chromatic CI']
workflows: ["CI: Tests Storybook"]
types: [requested, completed]
env:

View File

@@ -1,6 +1,5 @@
name: Storybook and Chromatic CI
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on:
workflow_dispatch: # Allow manual triggering

View File

@@ -1,4 +1,5 @@
name: Vitest Tests
name: "CI: Tests Unit"
description: "Unit and component testing with Vitest"
on:
push:

View File

@@ -1,4 +1,5 @@
name: Update Locales
name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
on:
# Manual dispatch for urgent translation updates
@@ -54,5 +55,5 @@ jobs:
# Apply the stashed changes if any
git stash pop || true
git add src/locales/
git diff --staged --quiet || git commit -m "Update locales [skip ci]"
git diff --staged --quiet || git commit -m "Update locales"
git push origin HEAD:${{ github.head_ref }}

View File

@@ -1,4 +1,4 @@
name: Update Locales for given custom node repository
name: i18n Update Custom Nodes
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Update Node Definitions Locales
name: i18n Update Nodes
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Auto Backport
name: PR Backport
on:
pull_request_target:
@@ -95,41 +95,61 @@ jobs:
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::Backport PRs already exist for PR #${PR_NUMBER}, skipping to avoid duplicates"
- name: Extract version labels
- name: Collect backport targets
if: steps.check-existing.outputs.skip != 'true'
id: versions
id: targets
run: |
# Extract version labels (e.g., "1.24", "1.22")
VERSIONS=""
TARGETS=()
declare -A SEEN=()
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# For manual triggers, get labels from the PR
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
# For automatic triggers, extract from PR event
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
for label in $LABELS; do
# Match version labels like "1.24" (major.minor only)
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
# Validate the branch exists before adding to list
if git ls-remote --exit-code origin "core/${label}" >/dev/null 2>&1; then
VERSIONS="${VERSIONS}${label} "
else
echo "::warning::Label '${label}' found but branch 'core/${label}' does not exist"
fi
fi
done
if [ -z "$VERSIONS" ]; then
echo "::error::No version labels found (e.g., 1.24, 1.22)"
add_target() {
local label="$1"
local target="$2"
if [ -z "$target" ]; then
return
fi
target=$(echo "$target" | xargs)
if [ -z "$target" ] || [ -n "${SEEN[$target]}" ]; then
return
fi
if git ls-remote --exit-code origin "$target" >/dev/null 2>&1; then
TARGETS+=("$target")
SEEN["$target"]=1
else
echo "::warning::Label '${label}' references missing branch '${target}'"
fi
}
while IFS= read -r label; do
[ -z "$label" ] && continue
if [[ "$label" =~ ^branch:(.+)$ ]]; then
add_target "$label" "${BASH_REMATCH[1]}"
elif [[ "$label" =~ ^backport:(.+)$ ]]; then
add_target "$label" "${BASH_REMATCH[1]}"
elif [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
add_target "$label" "core/${label}"
fi
done <<< "$LABELS"
if [ "${#TARGETS[@]}" -eq 0 ]; then
echo "::error::No backport targets found (use labels like '1.24' or 'branch:release/hotfix')"
exit 1
fi
echo "versions=${VERSIONS}" >> $GITHUB_OUTPUT
echo "Found version labels: ${VERSIONS}"
echo "targets=${TARGETS[*]}" >> $GITHUB_OUTPUT
echo "Found backport targets: ${TARGETS[*]}"
- name: Backport commits
if: steps.check-existing.outputs.skip != 'true'
@@ -150,16 +170,17 @@ jobs:
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for version in ${{ steps.versions.outputs.versions }}; do
echo "::group::Backporting to core/${version}"
for target in ${{ steps.targets.outputs.targets }}; do
TARGET_BRANCH="${target}"
SAFE_TARGET=$(echo "$TARGET_BRANCH" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
TARGET_BRANCH="core/${version}"
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${version}"
echo "::group::Backporting to ${TARGET_BRANCH}"
# Fetch target branch (fail if doesn't exist)
if ! git fetch origin "${TARGET_BRANCH}"; then
echo "::error::Target branch ${TARGET_BRANCH} does not exist"
FAILED="${FAILED}${version}:branch-missing "
FAILED="${FAILED}${TARGET_BRANCH}:branch-missing "
echo "::endgroup::"
continue
fi
@@ -170,7 +191,7 @@ jobs:
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
git push origin "${BACKPORT_BRANCH}"
SUCCESS="${SUCCESS}${version}:${BACKPORT_BRANCH} "
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main
@@ -180,7 +201,7 @@ jobs:
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${version}:conflicts:${CONFLICTS} "
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
@@ -214,13 +235,13 @@ jobs:
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"
IFS=':' read -r target branch <<< "${backport}"
if PR_URL=$(gh pr create \
--base "core/${version}" \
--base "${target}" \
--head "${branch}" \
--title "[backport ${version}] ${PR_TITLE}" \
--body "Backport of #${PR_NUMBER} to \`core/${version}\`"$'\n\n'"Automatically created by backport workflow." \
--title "[backport ${target}] ${PR_TITLE}" \
--body "Backport of #${PR_NUMBER} to \`${target}\`"$'\n\n'"Automatically created by backport workflow." \
--label "backport" 2>&1); then
# Extract PR number from URL
@@ -230,9 +251,9 @@ jobs:
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
fi
else
echo "::error::Failed to create PR for ${version}: ${PR_URL}"
echo "::error::Failed to create PR for ${target}: ${PR_URL}"
# Still try to comment on the original PR about the failure
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`core/${version}\`. Please create the PR manually from branch \`${branch}\`"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`${target}\`. Please create the PR manually from branch \`${branch}\`"
fi
done
@@ -253,16 +274,16 @@ jobs:
fi
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r version reason conflicts <<< "${failure}"
IFS=':' read -r target reason conflicts <<< "${failure}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`core/${version}\` does not exist"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "conflicts" ]; then
# Convert comma-separated conflicts back to newlines for display
CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /')
COMMENT_BODY="@${PR_AUTHOR} Backport to \`core/${version}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`core/${version}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
COMMENT_BODY="@${PR_AUTHOR} Backport to \`${target}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`${target}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -1,4 +1,5 @@
name: Claude PR Review
name: "PR: Claude Review"
description: "AI-powered code review triggered by adding the 'claude-review' label to a PR"
permissions:
contents: read

View File

@@ -0,0 +1,284 @@
# Setting test expectation screenshots for Playwright
#
# This workflow uses a selective snapshot update strategy:
# 1. When tests fail in CI, they generate a manifest of failed test locations (file:line)
# 2. This workflow downloads that manifest from the failed test run artifacts
# 3. Only the failed tests are re-run with --update-snapshots (much faster than running all tests)
# 4. Updated snapshots are committed back to the PR branch
#
# Trigger: Add label "New Browser Test Expectations" OR comment "/update-playwright" on PR
name: "PR: Update Playwright Expectations"
on:
pull_request:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
if: >
( github.event_name == 'pull_request' && github.event.label.name == 'New Browser Test Expectations' ) ||
( 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-playwright') )
steps:
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
id: "find-update-comment"
with:
issue-number: ${{ github.event.number || github.event.issue.number }}
comment-author: "github-actions[bot]"
body-includes: "Updating Playwright Expectations"
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
body: |
Updating Playwright Expectations
edit-mode: replace
reactions: eyes
- name: Get Branch SHA
id: "get-branch"
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
env:
REPO: ${{ github.repository }}
PR_NO: ${{ github.event.number || github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Initial Checkout
uses: actions/checkout@v5
with:
ref: ${{ steps.get-branch.outputs.branch }}
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Locate failed screenshot manifest artifact
id: locate-manifest
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo
let headSha = ''
if (context.eventName === 'pull_request') {
headSha = context.payload.pull_request.head.sha
} else if (context.eventName === 'issue_comment') {
const prNumber = context.payload.issue.number
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber })
headSha = pr.data.head.sha
}
if (!headSha) {
core.setOutput('run_id', '')
core.setOutput('has_manifest', 'false')
return
}
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'tests-ci.yaml',
head_sha: headSha,
event: 'pull_request',
per_page: 1,
})
const run = data.workflow_runs?.[0]
let has = 'false'
let runId = ''
if (run) {
runId = String(run.id)
const { data: { artifacts = [] } } = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: run.id,
per_page: 100,
})
if (artifacts.some(a => a.name === 'failed-screenshot-tests' && !a.expired)) has = 'true'
}
core.setOutput('run_id', runId)
core.setOutput('has_manifest', has)
- name: Download failed screenshot manifest
if: steps.locate-manifest.outputs.has_manifest == 'true'
uses: actions/download-artifact@v4
with:
run-id: ${{ steps.locate-manifest.outputs.run_id }}
name: failed-screenshot-tests
path: ci-rerun
- name: Re-run failed screenshot tests and update snapshots
id: playwright-tests
continue-on-error: true
run: |
set -euo pipefail
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Selective Snapshot Update"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Check if manifest exists
if [ ! -d ci-rerun ]; then
echo "ERROR: No manifest found in ci-rerun/ directory"
echo " This means no failed screenshot tests were detected in the latest CI run."
echo " Please ensure tests have been run and failures were recorded."
exit 1
fi
shopt -s nullglob
files=(ci-rerun/*.txt)
if [ ${#files[@]} -eq 0 ]; then
echo "ERROR: No manifest files found in ci-rerun/"
echo " Expected files like: chromium.txt, chromium-2x.txt, mobile-chrome.txt"
exit 1
fi
echo "Found ${#files[@]} project manifest(s):"
for f in "${files[@]}"; do
project="$(basename "$f" .txt)"
count=$(grep -c . "$f" 2>/dev/null || echo "0")
echo " - $project: $count failed test(s)"
done
echo ""
# Re-run tests per project
total_tests=0
for f in "${files[@]}"; do
project="$(basename "$f" .txt)"
mapfile -t lines < "$f"
filtered=( )
# Validate and sanitize test paths to prevent command injection
for l in "${lines[@]}"; do
# Skip empty lines
[ -z "$l" ] && continue
# Validate format: must be browser_tests/...spec.ts:number
if [[ "$l" =~ ^browser_tests/.+\.spec\.ts:[0-9]+$ ]]; then
filtered+=("$l")
else
echo "WARNING: Skipping invalid test path: $l"
fi
done
if [ ${#filtered[@]} -eq 0 ]; then
echo "WARNING: Skipping $project (no valid tests in manifest)"
continue
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Updating snapshots for project: $project"
echo " Re-running ${#filtered[@]} failed test(s)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright test --project="$project" --update-snapshots \
--reporter=line --reporter=html \
"${filtered[@]}"
total_tests=$((total_tests + ${#filtered[@]}))
echo ""
done
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Completed snapshot updates for $total_tests test(s)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: ./playwright-report/
retention-days: 30
- name: Debugging info
run: |
echo "PR: ${{ github.event.issue.number }}"
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
git status
- name: Commit updated expectations
id: commit
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
git add browser_tests
if git diff --cached --quiet; then
echo "No expectation updates detected; skipping commit."
echo "changed=false" >> $GITHUB_OUTPUT
else
# Count changed snapshots
changed_count=$(git diff --cached --name-only browser_tests | wc -l)
echo "changed=true" >> $GITHUB_OUTPUT
echo "count=$changed_count" >> $GITHUB_OUTPUT
git commit -m "[automated] Update test expectations"
git push origin ${{ steps.get-branch.outputs.branch }}
fi
- name: Generate workflow summary
if: always()
run: |
echo "## Snapshot Update Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.commit.outputs.changed }}" = "true" ]; then
echo "**${{ steps.commit.outputs.count }} snapshot(s) updated**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details>" >> $GITHUB_STEP_SUMMARY
echo "<summary>View updated files</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
git diff HEAD~1 --name-only browser_tests 2>/dev/null || echo "No git history available" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.commit.outputs.changed }}" = "false" ]; then
echo "No snapshot changes detected" >> $GITHUB_STEP_SUMMARY
else
echo "WARNING: Snapshot update may have failed - check logs above" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Strategy:** Selective snapshot update (only failed tests re-run)" >> $GITHUB_STEP_SUMMARY
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
if: github.event_name == 'issue_comment'
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
reactions: +1
reactions-edit-mode: replace
- name: Remove New Browser Test Expectations label
if: always() && github.event_name == 'pull_request'
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -44,6 +44,7 @@ jobs:
contents: read
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
ENABLE_MINIFY: 'true'
steps:
- name: Validate inputs
env:

View File

@@ -1,4 +1,4 @@
name: Create Release Branch
name: Release Branch Create
on:
pull_request:

View File

@@ -1,4 +1,4 @@
name: Create Release Draft
name: Release Draft Create
on:
pull_request:
@@ -55,6 +55,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile
@@ -126,7 +127,7 @@ jobs:
publish_types:
needs: build
uses: ./.github/workflows/publish-frontend-types.yaml
uses: ./.github/workflows/release-npm-types.yaml
with:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}

View File

@@ -1,4 +1,4 @@
name: Publish Frontend Types
name: Release NPM Types
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Create Dev PyPI Package
name: Release PyPI Dev
on:
workflow_dispatch:
@@ -44,6 +44,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile

View File

@@ -1,4 +1,5 @@
name: Version Bump
name: "Release: Version Bump"
description: "Manual workflow to increment package version with semantic versioning support"
on:
workflow_dispatch:
@@ -14,6 +15,11 @@ on:
required: false
default: ''
type: string
branch:
description: 'Base branch to bump (e.g., main, core/1.29, core/1.30)'
required: true
default: 'main'
type: string
jobs:
bump-version:
@@ -25,6 +31,24 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
- name: Validate branch exists
run: |
BRANCH="${{ github.event.inputs.branch }}"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist"
echo ""
echo "Available core branches:"
git branch -r | grep 'origin/core/' | sed 's/.*origin\// - /' || echo " (none found)"
echo ""
echo "Main branch:"
echo " - main"
exit 1
fi
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -58,7 +82,9 @@ jobs:
title: ${{ steps.bump-version.outputs.NEW_VERSION }}
body: |
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ github.event.inputs.branch }}`
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: main
base: ${{ github.event.inputs.branch }}
labels: |
Release

52
.github/workflows/size-data.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: size data
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
collect:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR number & base branch
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
- name: Upload size data
uses: actions/upload-artifact@v4
with:
name: size-data
path: temp/size

104
.github/workflows/size-report.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
name: size report
on:
workflow_run:
workflows: ['size data']
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to report on'
required: true
type: number
run_id:
description: 'Size data workflow run ID'
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
jobs:
size-report:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Download size data
uses: dawidd6/action-download-artifact@v11
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
path: temp/size
- name: Set PR number
id: pr-number
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "content=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
else
echo "content=$(cat temp/size/number.txt)" >> $GITHUB_OUTPUT
fi
- name: Set base branch
id: pr-base
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "content=main" >> $GITHUB_OUTPUT
else
echo "content=$(cat temp/size/base.txt)" >> $GITHUB_OUTPUT
fi
- name: Download previous size data
uses: dawidd6/action-download-artifact@v11
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: size-data.yml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn
- name: Generate size report
run: node scripts/size-report.js > size-report.md
- name: Read size report
id: size-report
uses: juliangruber/read-file-action@v1
with:
path: ./size-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}
body: |
${{ steps.size-report.outputs.content }}
<!-- COMFYUI_FRONTEND_SIZE -->
body-include: '<!-- COMFYUI_FRONTEND_SIZE -->'

View File

@@ -1,108 +0,0 @@
# Setting test expectation screenshots for Playwright
name: Update Playwright Expectations
on:
pull_request:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
if: >
( github.event_name == 'pull_request' && github.event.label.name == 'New Browser Test Expectations' ) ||
( 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-playwright') )
steps:
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
id: "find-update-comment"
with:
issue-number: ${{ github.event.number || github.event.issue.number }}
comment-author: "github-actions[bot]"
body-includes: "Updating Playwright Expectations"
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
body: |
Updating Playwright Expectations
edit-mode: replace
reactions: eyes
- name: Get Branch SHA
id: "get-branch"
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
env:
REPO: ${{ github.repository }}
PR_NO: ${{ github.event.number || github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Initial Checkout
uses: actions/checkout@v5
with:
ref: ${{ steps.get-branch.outputs.branch }}
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests and update snapshots
id: playwright-tests
run: pnpm exec playwright test --update-snapshots
continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: ./playwright-report/
retention-days: 30
- name: Debugging info
run: |
echo "PR: ${{ github.event.issue.number }}"
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
git status
- name: Commit updated expectations
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
git add browser_tests
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "[automated] Update test expectations"
git push origin ${{ steps.get-branch.outputs.branch }}
fi
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
if: github.event_name == 'issue_comment'
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
reactions: +1
reactions-edit-mode: replace
- name: Remove New Browser Test Expectations label
if: always() && github.event_name == 'pull_request'
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,6 +14,11 @@ on:
required: false
default: ''
type: string
branch:
description: 'Base branch to bump (e.g., main, core/1.29, core/1.30)'
required: true
default: 'main'
type: string
jobs:
bump-version-desktop-ui:
@@ -26,8 +31,25 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
persist-credentials: false
- name: Validate branch exists
run: |
BRANCH="${{ github.event.inputs.branch }}"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist"
echo ""
echo "Available core branches:"
git branch -r | grep 'origin/core/' | sed 's/.*origin\// - /' || echo " (none found)"
echo ""
echo "Main branch:"
echo " - main"
exit 1
fi
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -64,8 +86,10 @@ jobs:
title: desktop-ui ${{ steps.bump-version.outputs.NEW_VERSION }}
body: |
${{ steps.capitalised.outputs.capitalised }} version increment for @comfyorg/desktop-ui to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ github.event.inputs.branch }}`
branch: desktop-ui-version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: main
base: ${{ github.event.inputs.branch }}
labels: |
Release

View File

@@ -1,23 +1,23 @@
<template>
<div
class="task-div max-w-48 min-h-52 grid relative"
class="task-div relative grid min-h-52 max-w-48"
:class="{ 'opacity-75': isLoading }"
>
<Card
class="max-w-48 relative h-full overflow-hidden"
class="relative h-full max-w-48 overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
<i
v-if="runner.state === 'error'"
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
class="pi pi-exclamation-triangle absolute top-0 -right-14 m-2 text-red-500 opacity-15"
style="font-size: 10rem"
/>
<img
v-if="task.headerImg"
:src="task.headerImg"
class="object-contain w-full h-full opacity-25 pt-4 px-4"
class="h-full w-full object-contain px-4 pt-4 opacity-25"
/>
</template>
<template #title>
@@ -27,7 +27,7 @@
{{ description }}
</template>
<template #footer>
<div class="flex gap-4 mt-1">
<div class="mt-1 flex gap-4">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
@@ -73,7 +73,7 @@ defineEmits<{
// Bindings
const description = computed(() =>
runner.value.state === 'error'
? props.task.errorDescription ?? props.task.shortDescription
? (props.task.errorDescription ?? props.task.shortDescription)
: props.task.shortDescription
)

View File

@@ -46,6 +46,10 @@ class ComfyMenu {
.nth(0)
}
get buttons() {
return this.sideToolbar.locator('.side-bar-button')
}
get nodeLibraryTab() {
this._nodeLibraryTab ??= new NodeLibrarySidebarTab(this.page)
return this._nodeLibraryTab

View File

@@ -7,7 +7,7 @@ export class Topbar {
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfyui-logo-wrapper')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
}
async getTabNames(): Promise<string[]> {
@@ -105,7 +105,7 @@ export class Topbar {
* Close the topbar menu by clicking outside
*/
async closeTopbarMenu() {
await this.page.locator('body').click({ position: { x: 10, y: 10 } })
await this.page.locator('body').click({ position: { x: 300, y: 10 } })
await expect(this.menuLocator).not.toBeVisible()
}

View File

@@ -116,9 +116,10 @@ test.describe('Actionbar', () => {
test('Can dock actionbar into top menu', async ({ comfyPage }) => {
await comfyPage.page.dragAndDrop(
'.actionbar .drag-handle',
'.comfyui-menu',
'.actionbar-container',
{
targetPosition: { x: 0, y: 0 }
targetPosition: { x: 50, y: 20 },
force: true
}
)
expect(await comfyPage.actionbar.isDocked()).toBe(true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -233,6 +233,7 @@ test.describe('Group Node', () => {
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
await comfyPage.menu.nodeLibraryTab.open()
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
.count()
@@ -253,8 +254,6 @@ test.describe('Group Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow(WORKFLOW_NAME)
await comfyPage.menu.nodeLibraryTab.open()
groupNode = await comfyPage.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)

View File

@@ -792,10 +792,19 @@ test.describe('Viewport settings', () => {
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await comfyPage.nextFrame()
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
await comfyPage.nextFrame()
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
await changeTab(tabA)
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
await changeTab(tabB)
await comfyMouse.move(comfyPage.emptySpace)
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
@@ -807,9 +816,6 @@ test.describe('Viewport settings', () => {
// Ensure that the screenshots are different due to zoom level
expect(screenshotB).not.toBe(screenshotA)
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await changeTab(tabA)
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(

View File

@@ -8,9 +8,7 @@ test.describe('Menu', () => {
})
test('Can register sidebar tab', async ({ comfyPage }) => {
const initialChildrenCount = await comfyPage.menu.sideToolbar.evaluate(
(el) => el.children.length
)
const initialChildrenCount = await comfyPage.menu.buttons.count()
await comfyPage.page.evaluate(async () => {
window['app'].extensionManager.registerSidebarTab({
@@ -26,9 +24,7 @@ test.describe('Menu', () => {
})
await comfyPage.nextFrame()
const newChildrenCount = await comfyPage.menu.sideToolbar.evaluate(
(el) => el.children.length
)
const newChildrenCount = await comfyPage.menu.buttons.count()
expect(newChildrenCount).toBe(initialChildrenCount + 1)
})

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Record Audio Node', () => {
test('should add a record audio node and take a screenshot', async ({
comfyPage
}) => {
// Open the search box by double clicking on the canvas
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Search for and add the RecordAudio node
await comfyPage.searchBox.fillAndSelectFirstNode('RecordAudio')
await comfyPage.nextFrame()
// Verify the RecordAudio node was added
const recordAudioNodes = await comfyPage.getNodeRefsByType('RecordAudio')
expect(recordAudioNodes.length).toBe(1)
// Take a screenshot of the canvas with the RecordAudio node
await expect(comfyPage.canvas).toHaveScreenshot('record_audio_node.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -12,21 +12,26 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling bypass on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
test.fixme(
'should allow toggling bypass on a selected node with hotkey',
async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.page.mouse.click(400, 300)
await comfyPage.page.waitForTimeout(128)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
})
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
}
)
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -0,0 +1,755 @@
# Playwright Selective Test Rerun Alternatives
This document analyzes alternatives for selectively re-running only failed Playwright tests for snapshot updates, comparing native Playwright features with the current custom manifest approach used in this project.
## Table of Contents
- [Current Approach](#current-approach)
- [Native Playwright Features](#native-playwright-features)
- [Playwright Reporter Options](#playwright-reporter-options)
- [GitHub Actions Integration Patterns](#github-actions-integration-patterns)
- [Third-Party Solutions](#third-party-solutions)
- [Comparison and Recommendations](#comparison-and-recommendations)
---
## Current Approach
### Implementation
The project currently uses a **custom manifest-based approach** that:
1. **Generates a manifest** of failed screenshot tests after CI runs
- Script: `scripts/cicd/build-failed-screenshot-manifest.ts`
- Parses JSON report to find tests with failed screenshot assertions
- Creates per-project text files: `ci-rerun/{project}.txt`
- Format: `file_path:line_number` (e.g., `browser_tests/menu.test.ts:42`)
2. **Stores manifest as GitHub artifact**
- Artifact name: `failed-screenshot-tests`
- Retention: 7 days
- Only uploaded when chromium sharded tests fail
3. **Downloads manifest in update workflow**
- Workflow: `.github/workflows/update-playwright-expectations.yaml`
- Triggered by: PR label "New Browser Test Expectations" or `/update-playwright` comment
- Falls back to full test suite if manifest not found
4. **Re-runs only failed tests**
```bash
for f in ci-rerun/*.txt; do
project="$(basename "$f" .txt)"
mapfile -t lines < "$f"
# Filter empty lines
pnpm exec playwright test --project="$project" --update-snapshots "${filtered[@]}"
done
```
### Advantages
- ✅ Works across workflow runs and different trigger mechanisms
- ✅ Survives beyond single workflow execution
- ✅ Precise control over which tests to re-run
- ✅ Supports multiple projects with separate manifests
- ✅ Works with sharded test runs (merged report)
- ✅ Platform-agnostic approach (works on any CI/CD platform)
### Disadvantages
- ❌ Custom implementation requires maintenance
- ❌ Requires parsing JSON report format (could break with Playwright updates)
- ❌ Additional artifact storage needed
- ❌ More complex than native solutions
---
## Native Playwright Features
### 1. `--last-failed` CLI Flag
**Availability:** Playwright v1.44.0+ (May 2024)
#### How It Works
```bash
# First run - execute all tests
npx playwright test
# Second run - only re-run failed tests
npx playwright test --last-failed
```
Playwright maintains a `.last-run.json` file in the `test-results/` directory that tracks failed tests.
#### CLI Examples
```bash
# Run only failed tests from last run
npx playwright test --last-failed
# Update snapshots for only failed tests
npx playwright test --last-failed --update-snapshots
# Combine with project filtering
npx playwright test --last-failed --project=chromium
# Debug failed tests
npx playwright test --last-failed --debug
```
#### File Location and Format
- **Location:** `test-results/.last-run.json`
- **Format:** JSON object containing failed test information
- **Structure:** Contains a `failedTests: []` array with test identifiers
- **Persistence:** Cleared when all tests pass on subsequent run
#### Advantages
- ✅ Built into Playwright (no custom code)
- ✅ Simple CLI flag
- ✅ Automatically maintained by Playwright
- ✅ Works with all Playwright features (debug, UI mode, etc.)
#### Limitations
- ❌ **Not designed for CI/CD distributed testing** (per Playwright maintainers)
- ❌ **Intended for local development only** ("inner loop scenario")
- ❌ Cleared on new test runs (doesn't persist across clean environments)
- ❌ **GitHub Actions starts with clean environment** - `.last-run.json` not available on retry
- ❌ **Doesn't work with sharded tests** - each shard creates its own `.last-run.json`
- ❌ No native way to merge `.last-run.json` across shards
- ❌ Not designed for cross-workflow persistence
#### CI/CD Workaround (Not Recommended)
To use `--last-failed` in GitHub Actions, you would need to:
```yaml
- name: Run Playwright tests
id: playwright-test
run: npx playwright test
- name: Upload last run state
if: failure()
uses: actions/upload-artifact@v4
with:
name: last-run-state
path: test-results/.last-run.json
# In retry workflow:
- name: Download last run state
uses: actions/download-artifact@v4
with:
name: last-run-state
path: test-results/
- name: Rerun failed tests
run: npx playwright test --last-failed --update-snapshots
```
**Why This Isn't Ideal:**
- Playwright maintainers explicitly state this is not the intended use case
- Doesn't work well with sharded tests (multiple `.last-run.json` files)
- Requires manual artifact management
- More complex than the current custom approach for this use case
### 2. File:Line Syntax for Specific Tests
Playwright supports running tests at specific line numbers:
```bash
# Run a specific test at line 42
npx playwright test tests/example.spec.ts:42
# Multiple tests
npx playwright test tests/file1.spec.ts:10 tests/file2.spec.ts:25
# With snapshot updates
npx playwright test tests/example.spec.ts:42 --update-snapshots
# With project selection
npx playwright test --project=chromium tests/example.spec.ts:42
```
This is **exactly the format** the current custom manifest uses, making it compatible with Playwright's native CLI.
### 3. Test Filtering Options
```bash
# Filter by grep pattern
npx playwright test -g "screenshot"
# Inverse grep
npx playwright test --grep-invert "mobile"
# By project
npx playwright test --project=chromium
# Multiple projects
npx playwright test --project=chromium --project=firefox
# Specific directory
npx playwright test tests/screenshots/
```
---
## Playwright Reporter Options
### 1. JSON Reporter
**Purpose:** Machine-readable test results
#### Configuration
```typescript
// playwright.config.ts
export default defineConfig({
reporter: [
['json', { outputFile: 'results.json' }]
]
})
```
Or via environment variable:
```bash
PLAYWRIGHT_JSON_OUTPUT_NAME=results.json npx playwright test --reporter=json
```
#### Output Structure
```json
{
"stats": {
"expected": 100,
"unexpected": 5,
"flaky": 2,
"skipped": 3
},
"suites": [
{
"title": "Test Suite",
"specs": [
{
"file": "browser_tests/example.test.ts",
"line": 42,
"tests": [
{
"projectId": "chromium",
"results": [
{
"status": "failed",
"attachments": [
{ "contentType": "image/png" }
]
}
]
}
]
}
]
}
]
}
```
**This is the format** the current `build-failed-screenshot-manifest.ts` script parses.
#### Advantages
- ✅ Stable, documented JSON schema (`@playwright/test/reporter`)
- ✅ Includes all test metadata (file, line, project, status, attachments)
- ✅ Can be used programmatically
- ✅ Supports multiple reporters simultaneously
#### Current Project Usage
```yaml
# In tests-ci.yaml
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright test --project=${{ matrix.browser }} \
--reporter=list \
--reporter=html \
--reporter=json
```
### 2. Blob Reporter
**Purpose:** Merging sharded test reports
#### Configuration
```typescript
// playwright.config.ts
export default defineConfig({
reporter: process.env.CI ? 'blob' : 'html'
})
```
#### Usage with Sharding
```bash
# Run sharded test with blob output
npx playwright test --shard=1/4 --reporter=blob
# Merge blob reports
npx playwright merge-reports --reporter=html ./all-blob-reports
npx playwright merge-reports --reporter=json ./all-blob-reports
```
#### Current Project Usage
```yaml
# Sharded chromium tests
- run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report
# Merge reports job
- run: |
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
```
#### Advantages
- ✅ Designed for distributed testing
- ✅ Can merge into any reporter format (HTML, JSON, etc.)
- ✅ Preserves all test information across shards
#### Blob Reporter and `--last-failed`
- ❌ Blob reports **do not contain** a merged `.last-run.json`
- ❌ Each shard creates its own `.last-run.json` that isn't included in blob
- ❌ GitHub issue [#30924](https://github.com/microsoft/playwright/issues/30924) requests this feature (currently unsupported)
### 3. Multiple Reporters
You can use multiple reporters simultaneously:
```typescript
export default defineConfig({
reporter: [
['list'], // Terminal output
['html'], // Browse results
['json', { outputFile: 'results.json' }], // Programmatic parsing
['junit', { outputFile: 'results.xml' }] // CI integration
]
})
```
Or via CLI:
```bash
npx playwright test --reporter=list --reporter=html --reporter=json
```
---
## GitHub Actions Integration Patterns
### Pattern 1: Comment-Triggered Workflow (JupyterLab Approach)
**Example:** [jupyterlab/jupyterlab-git](https://github.com/jupyterlab/jupyterlab-git/blob/main/.github/workflows/update-integration-tests.yml)
```yaml
name: Update Playwright Snapshots
on:
issue_comment:
types: [created, edited]
permissions:
contents: write
pull-requests: write
jobs:
update-snapshots:
# Only run for authorized users on PRs with specific comment
if: >
(github.event.issue.author_association == 'OWNER' ||
github.event.issue.author_association == 'COLLABORATOR' ||
github.event.issue.author_association == 'MEMBER'
) && github.event.issue.pull_request &&
contains(github.event.comment.body, 'please update snapshots')
runs-on: ubuntu-latest
steps:
- name: React to the triggering comment
run: gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout PR branch
run: gh pr checkout ${{ github.event.issue.number }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup and run tests
run: |
npm ci
npx playwright install --with-deps
npx playwright test --update-snapshots
- name: Commit and push
run: |
git config user.name 'github-actions'
git config user.email 'github-actions@github.com'
git add .
git diff --cached --quiet || git commit -m "Update snapshots"
git push
```
#### Advantages
- ✅ Simple comment-based trigger
- ✅ Visual feedback (reaction on comment)
- ✅ Authorization checks built-in
- ✅ Auto-commits to PR branch
#### Limitations
- ❌ Runs **all** tests with `--update-snapshots` (not selective)
- ❌ No integration with failed test information from CI
### Pattern 2: Label-Based Trigger + Manifest (Current Approach)
```yaml
name: Update Playwright Expectations
on:
pull_request:
types: [labeled]
issue_comment:
types: [created]
jobs:
test:
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'New Browser Test Expectations' ) ||
( github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-playwright') )
steps:
# ... setup steps ...
- name: Locate failed screenshot manifest artifact
id: locate-manifest
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo
let headSha = ''
if (context.eventName === 'pull_request') {
headSha = context.payload.pull_request.head.sha
} else if (context.eventName === 'issue_comment') {
const prNumber = context.payload.issue.number
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber })
headSha = pr.data.head.sha
}
const { data } = await github.rest.actions.listWorkflowRuns({
owner, repo,
workflow_id: 'tests-ci.yaml',
head_sha: headSha,
per_page: 1,
})
const run = data.workflow_runs?.[0]
let has = 'false'
if (run) {
const { data: { artifacts = [] } } = await github.rest.actions.listWorkflowRunArtifacts({
owner, repo, run_id: run.id
})
if (artifacts.some(a => a.name === 'failed-screenshot-tests' && !a.expired))
has = 'true'
}
core.setOutput('has_manifest', has)
- name: Download failed screenshot manifest
if: steps.locate-manifest.outputs.has_manifest == 'true'
uses: actions/download-artifact@v4
with:
run-id: ${{ steps.locate-manifest.outputs.run_id }}
name: failed-screenshot-tests
path: ComfyUI_frontend/ci-rerun
- name: Re-run failed screenshot tests
run: |
if [ ! -d ci-rerun ]; then
echo "No manifest found; running full suite"
pnpm exec playwright test --update-snapshots
exit 0
fi
for f in ci-rerun/*.txt; do
project="$(basename "$f" .txt)"
mapfile -t lines < "$f"
filtered=()
for l in "${lines[@]}"; do
[ -n "$l" ] && filtered+=("$l")
done
if [ ${#filtered[@]} -gt 0 ]; then
echo "Re-running ${#filtered[@]} tests for project $project"
pnpm exec playwright test --project="$project" --update-snapshots "${filtered[@]}"
fi
done
```
#### Advantages
- ✅ **Selective** - only re-runs failed screenshot tests
- ✅ Works across different trigger mechanisms (label or comment)
- ✅ Fallback to full suite if manifest not found
- ✅ Per-project manifests support multiple browser configurations
- ✅ Handles sharded tests via merged report
### Pattern 3: WordPress/Openverse Approach (Always Update)
Proposed pattern (not fully implemented):
1. CI always runs with `--update-snapshots` flag
2. If snapshots change, create/update a secondary branch
3. Open PR targeting the original PR branch
4. Developer reviews snapshot changes before merging
#### Advantages
- ✅ Always generates correct snapshots
- ✅ Snapshot changes are visible in separate PR
- ✅ No test failures due to mismatched snapshots
#### Limitations
- ❌ Creates multiple PRs
- ❌ More complex merge workflow
- ❌ Potential for snapshot changes to mask real issues
### Pattern 4: Manual Workflow Dispatch
```yaml
name: Update Snapshots
on:
workflow_dispatch:
inputs:
update-snapshots:
description: 'Update snapshots'
type: boolean
default: false
test-pattern:
description: 'Test pattern (optional)'
type: string
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
run: |
npm ci
npx playwright install --with-deps
- name: Run tests
run: |
if [ "${{ inputs.update-snapshots }}" = "true" ]; then
FLAGS="--update-snapshots"
fi
PATTERN="${{ inputs.test-pattern }}"
npx playwright test ${PATTERN} ${FLAGS}
```
#### Advantages
- ✅ Full manual control
- ✅ Can specify test patterns
- ✅ Simple to understand
#### Limitations
- ❌ Requires manual triggering
- ❌ Not integrated with CI failures
---
## Third-Party Solutions
### Currents.dev - Last Failed GitHub Action
**Repository:** [currents-dev/playwright-last-failed](https://github.com/currents-dev/playwright-last-failed)
#### Purpose
Helps run last failed Playwright tests using Currents' cloud-based caching service.
#### Usage
```yaml
- name: Playwright Last Failed action
id: last-failed-action
uses: currents-dev/playwright-last-failed@v1
with:
pw-output-dir: test-results
matrix-index: ${{ matrix.shard }}
matrix-total: ${{ strategy.job-total }}
```
#### How It Works
- Uses Currents' cloud service to persist failed test information
- Supports sharded tests via matrix parameters
- Enables selective rerun of failed tests across workflow retries
#### Advantages
- ✅ Works with sharded tests
- ✅ Persists across workflow runs
- ✅ Supports GitHub Actions retry mechanism
- ✅ Handles distributed testing
#### Limitations
- ❌ **Requires Currents subscription** (third-party paid service)
- ❌ Dependency on external service
- ❌ Data sent to third-party cloud
- ❌ Additional cost
- ❌ Vendor lock-in
#### Recommendation
**Not suitable for this project** due to:
- External service dependency
- Cost implications
- The current custom solution is already working well
---
## Comparison and Recommendations
### Feature Matrix
| Feature | Current Approach | `--last-failed` | Currents | Comment Trigger Only |
|---------|-----------------|-----------------|----------|---------------------|
| Works with sharded tests | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes |
| Persists across workflows | ✅ Yes | ❌ No | ✅ Yes | N/A |
| Selective reruns | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No (runs all) |
| No external dependencies | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| Simple implementation | ⚠️ Medium | ✅ Simple | ✅ Simple | ✅ Simple |
| Maintenance overhead | ⚠️ Medium | ✅ Low | ✅ Low | ✅ Low |
| Works in CI/CD | ✅ Yes | ⚠️ Workaround | ✅ Yes | ✅ Yes |
| Cost | ✅ Free | ✅ Free | ❌ Paid | ✅ Free |
| Supports multiple projects | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
### Why `--last-failed` Isn't Suitable (Currently)
1. **Not designed for CI/CD:** Playwright maintainers explicitly state it's for "inner loop scenario (local development)"
2. **Doesn't work with sharded tests:** Each shard creates its own `.last-run.json` with no native merge
3. **Clean environment issue:** GitHub Actions starts fresh, losing `.last-run.json`
4. **Feature request pending:** GitHub issue [#30924](https://github.com/microsoft/playwright/issues/30924) requests blob report integration (not yet implemented)
### Recommendations
#### Short Term: Keep Current Approach
**Verdict: The current custom manifest approach is the best solution for this project's needs.**
**Reasons:**
1. ✅ **Works perfectly with sharded tests** - merges results across 8 shards
2. ✅ **Persists across workflows** - artifact storage for 7 days
3. ✅ **Selective reruns** - only failed screenshot tests
4. ✅ **No external dependencies** - fully self-contained
5. ✅ **Uses stable Playwright JSON format** - typed via `@playwright/test/reporter`
6. ✅ **Already working well** - proven in production
**Minor Improvements:**
```typescript
// Add version check to warn if JSON schema changes
import { version } from '@playwright/test/package.json'
if (major(version) !== 1) {
console.warn('Playwright major version changed - verify JSON schema compatibility')
}
// Add more robust error handling
try {
const report: JSONReport = JSON.parse(raw)
} catch (error) {
throw new Error(`Failed to parse Playwright JSON report: ${error.message}`)
}
// Consider adding tests for the manifest builder
// e.g., tests/cicd/build-failed-screenshot-manifest.test.ts
```
#### Long Term: Monitor Playwright Development
**Watch for these features:**
1. **Blob report + `.last-run.json` merge** - GitHub issue [#30924](https://github.com/microsoft/playwright/issues/30924)
2. **Native CI/CD support for `--last-failed`** - may never happen (by design)
3. **Report merging improvements** - GitHub issue [#33094](https://github.com/microsoft/playwright/issues/33094)
**Migration path if native support improves:**
```yaml
# Future potential approach (if Playwright adds this feature)
- name: Merge reports with last-run
run: |
npx playwright merge-reports --reporter=html ./all-blob-reports
npx playwright merge-reports --reporter=last-failed ./all-blob-reports
- name: Upload merged last-run
uses: actions/upload-artifact@v4
with:
name: last-run-state
path: test-results/.last-run.json
# In update workflow
- name: Download last-run state
uses: actions/download-artifact@v4
with:
name: last-run-state
path: test-results/
- name: Update snapshots for failed tests
run: npx playwright test --last-failed --update-snapshots
```
**However, this is speculative** - Playwright maintainers have indicated `--last-failed` is not intended for CI/CD.
#### Alternative: Simplify to Full Suite Reruns
If the custom manifest becomes too complex to maintain, consider:
```yaml
- name: Re-run ALL screenshot tests
run: |
# Simple grep-based filtering for screenshot tests
npx playwright test -g "screenshot" --update-snapshots
```
**Trade-offs:**
- ✅ Much simpler
- ✅ No custom scripts
- ❌ Slower (runs all screenshot tests, not just failed ones)
- ❌ Potentially updates snapshots that weren't actually failing
---
## Conclusion
The current custom manifest approach is **well-designed** and **appropriate** for this project's requirements:
1. **Handles sharded tests** - critical for CI performance
2. **Selective reruns** - saves time and resources
3. **Stable implementation** - uses documented Playwright JSON schema
4. **No external dependencies** - fully controlled
While `--last-failed` is a nice feature for **local development**, Playwright's own documentation and maintainer comments confirm it's **not suitable for distributed CI/CD testing**, which is exactly what this project needs.
The only potentially better solution (Currents) requires a paid external service, which adds cost and complexity without significant benefits over the current approach.
**Recommendation: Keep the current implementation**, with minor improvements to error handling and documentation. Monitor Playwright development for native improvements, but don't expect `--last-failed` to become a viable alternative for this use case.
---
## References
### Official Playwright Documentation
- [Command Line](https://playwright.dev/docs/test-cli)
- [Reporters](https://playwright.dev/docs/test-reporters)
- [Test Sharding](https://playwright.dev/docs/test-sharding)
- [CI/CD Setup](https://playwright.dev/docs/ci-intro)
### Community Resources
- [Playwright Solutions: How to Run Failures Only](https://playwrightsolutions.com/how-to-run-failures-only-from-the-last-playwright-run/)
- [Medium: How to Run Only Last Failed Tests](https://medium.com/@testerstalk/how-to-run-only-last-failed-tests-in-playwright-e5e41472594a)
- [Medium: Streamlining Visual Regression Testing](https://medium.com/@haleywardo/streamlining-playwright-visual-regression-testing-with-github-actions-e077fd33c27c)
### GitHub Issues
- [#30924 - Last-failed with blob reports](https://github.com/microsoft/playwright/issues/30924)
- [#33094 - Merging main run with --last-failed](https://github.com/microsoft/playwright/issues/33094)
- [#28254 - Feature request for --last-failed](https://github.com/microsoft/playwright/issues/28254)
### Example Implementations
- [JupyterLab Git - Update Integration Tests](https://github.com/jupyterlab/jupyterlab-git/blob/main/.github/workflows/update-integration-tests.yml)
- [WordPress Openverse - Discussion #4535](https://github.com/WordPress/openverse/issues/4535)
### Third-Party Tools
- [Currents - Playwright Last Failed Action](https://github.com/currents-dev/playwright-last-failed)
- [Currents - Re-run Only Failed Tests](https://docs.currents.dev/guides/re-run-only-failed-tests)

View File

@@ -0,0 +1,482 @@
# Snapshot Update from Actual Files (Fast Approach)
**Date:** 2025-10-08
**Status:** Proposed Optimization
## Overview
When Playwright snapshot tests fail, Playwright **already generates the new ("actual") snapshots**. Instead of re-running tests with `--update-snapshots`, we can extract these actual snapshots from the `test-results/` directory and copy them to overwrite the expected snapshots.
**Performance improvement:** ~1-2 minutes → **~10-30 seconds**
## How Playwright Stores Snapshots
### Expected (Baseline) Snapshots
Stored in: `<test-file>-snapshots/<snapshot-name>-<project>-<platform>.png`
**Example:**
```
browser_tests/tests/interaction.spec.ts-snapshots/default-chromium-linux.png
```
### Failed Test Artifacts
When a snapshot test fails, Playwright creates:
```
test-results/<test-hash>/
├── <snapshot-name>-actual.png # The NEW screenshot
├── <snapshot-name>-expected.png # Copy of baseline
└── <snapshot-name>-diff.png # Visual diff
```
**Example:**
```
test-results/interaction-default-chromium-67af3c/
├── default-1-actual.png
├── default-1-expected.png
└── default-1-diff.png
```
## Current Approach vs. Proposed Approach
### Current: Re-run Tests with `--update-snapshots`
```yaml
# Current workflow (.github/workflows/update-playwright-expectations.yaml)
- name: Re-run failed screenshot tests and update snapshots
run: |
# Download manifest of failed tests
# For each project: chromium, chromium-2x, etc.
# Run: playwright test --project="$project" --update-snapshots test1.spec.ts:42 test2.spec.ts:87 ...
```
**Time:** ~2-5 minutes (depends on # of failed tests)
**Why slow:**
- Re-executes tests (browser startup, navigation, interactions)
- Waits for elements, animations, etc.
- Generates HTML report
- Each test takes 5-15 seconds
### Proposed: Copy Actual → Expected
```yaml
# Proposed workflow
- name: Download test artifacts (includes test-results/)
- name: Copy actual snapshots to expected locations
run: pnpm tsx scripts/cicd/update-snapshots-from-actuals.ts
- name: Commit and push
```
**Time:** ~10-30 seconds (just file operations)
**Why fast:**
- No test execution
- No browser startup
- Just file copying
- Parallel file operations
## Implementation Plan
### Step 1: Modify tests-ci.yaml
Currently, test artifacts upload only the `playwright-report/` directory.
**Add test-results/ to artifacts:**
```yaml
# .github/workflows/tests-ci.yaml
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-results-${{ matrix.browser }} # New artifact
path: |
ComfyUI_frontend/test-results/**/*-actual.png
ComfyUI_frontend/test-results/**/*-expected.png
ComfyUI_frontend/test-results/**/*-diff.png
retention-days: 7
```
**Optimization:** Only upload actual snapshots for failed tests (saves artifact storage)
### Step 2: Create Script to Map Actuals → Expected
**File:** `scripts/cicd/update-snapshots-from-actuals.ts`
```typescript
import type { JSONReport, JSONReportTestResult } from '@playwright/test/reporter'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
interface SnapshotMapping {
actualPath: string // test-results/.../snapshot-1-actual.png
expectedPath: string // browser_tests/tests/foo.spec.ts-snapshots/snapshot-chromium-linux.png
testFile: string
testName: string
project: string
}
async function main() {
const reportPath = path.join('playwright-report', 'report.json')
if (!fs.existsSync(reportPath)) {
console.log('No report.json found - no failed tests to update')
return
}
const raw = await fsp.readFile(reportPath, 'utf8')
const report: JSONReport = JSON.parse(raw)
const mappings: SnapshotMapping[] = []
// Parse JSON report to extract snapshot paths
function collectFailedSnapshots(suite: any) {
if (!suite) return
for (const childSuite of suite.suites ?? []) {
collectFailedSnapshots(childSuite)
}
for (const spec of suite.specs ?? []) {
for (const test of spec.tests) {
const lastResult = test.results[test.results.length - 1]
if (lastResult?.status !== 'failed') continue
// Check if test has image attachments (indicates screenshot test)
const imageAttachments = lastResult.attachments.filter(
(att: any) => att?.contentType?.startsWith('image/')
)
if (imageAttachments.length === 0) continue
// Extract snapshot mapping from attachments
for (const attachment of imageAttachments) {
const attachmentPath = attachment.path
if (!attachmentPath || !attachmentPath.includes('-actual.png')) {
continue
}
// Parse test-results path to determine expected location
// test-results/interaction-default-chromium-67af3c/default-1-actual.png
// → browser_tests/tests/interaction.spec.ts-snapshots/default-chromium-linux.png
const actualPath = attachmentPath
const expectedPath = inferExpectedPath(actualPath, spec.file, test.projectId)
if (expectedPath) {
mappings.push({
actualPath,
expectedPath,
testFile: spec.file,
testName: test.annotations[0]?.description || test.title,
project: test.projectId
})
}
}
}
}
}
collectFailedSnapshots(report)
if (mappings.length === 0) {
console.log('No failed snapshot tests found')
return
}
console.log(`Found ${mappings.length} snapshots to update`)
// Copy actual → expected
let successCount = 0
let errorCount = 0
for (const mapping of mappings) {
try {
if (!fs.existsSync(mapping.actualPath)) {
console.warn(`⚠️ Actual file not found: ${mapping.actualPath}`)
errorCount++
continue
}
// Ensure expected directory exists
const expectedDir = path.dirname(mapping.expectedPath)
await fsp.mkdir(expectedDir, { recursive: true })
// Copy actual → expected
await fsp.copyFile(mapping.actualPath, mapping.expectedPath)
console.log(`✓ Updated: ${path.basename(mapping.expectedPath)}`)
successCount++
} catch (error) {
console.error(`✗ Failed to update ${mapping.expectedPath}:`, error)
errorCount++
}
}
console.log(`\n✅ Successfully updated ${successCount} snapshots`)
if (errorCount > 0) {
console.log(`⚠️ Failed to update ${errorCount} snapshots`)
process.exit(1)
}
}
/**
* Infer the expected snapshot path from the actual path
*
* Actual: test-results/interaction-default-chromium-67af3c/default-1-actual.png
* Expected: browser_tests/tests/interaction.spec.ts-snapshots/default-chromium-linux.png
*/
function inferExpectedPath(actualPath: string, testFile: string, projectId: string): string | null {
try {
// Extract snapshot name from actual path
// "default-1-actual.png" → "default"
const actualFilename = path.basename(actualPath)
const snapshotName = actualFilename.replace(/-\d+-actual\.png$/, '')
// Determine platform (linux, darwin, win32)
const platform = process.platform === 'linux' ? 'linux'
: process.platform === 'darwin' ? 'darwin'
: 'win32'
// Build expected path
const testDir = path.dirname(testFile)
const testBasename = path.basename(testFile)
const snapshotsDir = path.join(testDir, `${testBasename}-snapshots`)
const expectedFilename = `${snapshotName}-${projectId}-${platform}.png`
return path.join(snapshotsDir, expectedFilename)
} catch (error) {
console.error(`Failed to infer expected path for ${actualPath}:`, error)
return null
}
}
main().catch((err) => {
console.error('Failed to update snapshots:', err)
process.exit(1)
})
```
### Step 3: Better Approach - Use Playwright's Attachment Metadata
The JSON reporter actually includes the **expected snapshot path** in the attachments!
**Simplified script:**
```typescript
async function main() {
const report: JSONReport = JSON.parse(await fsp.readFile('playwright-report/report.json', 'utf8'))
const updates: Array<{ actual: string; expected: string }> = []
for (const result of getAllTestResults(report)) {
if (result.status !== 'failed') continue
for (const attachment of result.attachments) {
// Playwright includes both actual and expected in attachments
if (attachment.name?.includes('-actual') && attachment.path) {
const actualPath = attachment.path
// Find corresponding expected attachment
const expectedAttachment = result.attachments.find(
att => att.name === attachment.name.replace('-actual', '-expected')
)
if (expectedAttachment?.path) {
// The expected path in attachment points to the test-results copy
// But we can infer the real expected path from the attachment metadata
const expectedPath = inferRealExpectedPath(expectedAttachment)
updates.push({ actual: actualPath, expected: expectedPath })
}
}
}
}
// Copy files
for (const { actual, expected } of updates) {
await fsp.copyFile(actual, expected)
console.log(`✓ Updated: ${path.relative(process.cwd(), expected)}`)
}
}
```
### Step 4: Update GitHub Actions Workflow
```yaml
# .github/workflows/update-playwright-expectations.yaml
name: Update Playwright Expectations
on:
issue_comment:
types: [created]
jobs:
update:
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-snapshots') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'),
github.event.comment.author_association)
runs-on: ubuntu-latest
steps:
- name: React to comment
uses: actions/github-script@v8
with:
script: |
github.rest.reactions.createForIssueComment({
comment_id: context.payload.comment.id,
content: '+1'
})
- name: Checkout PR
run: gh pr checkout ${{ github.event.issue.number }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
- name: Get latest failed test run
id: get-run
uses: actions/github-script@v8
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number
})
const runs = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'tests-ci.yaml',
head_sha: pr.data.head.sha,
per_page: 1
})
core.setOutput('run_id', runs.data.workflow_runs[0]?.id || '')
- name: Download test results
uses: actions/download-artifact@v4
with:
run-id: ${{ steps.get-run.outputs.run_id }}
pattern: playwright-results-*
path: ComfyUI_frontend/test-results
merge-multiple: true
- name: Download JSON report
uses: actions/download-artifact@v4
with:
run-id: ${{ steps.get-run.outputs.run_id }}
pattern: playwright-report-*
path: ComfyUI_frontend/playwright-report
merge-multiple: true
- name: Update snapshots from actuals
working-directory: ComfyUI_frontend
run: pnpm tsx scripts/cicd/update-snapshots-from-actuals.ts
- name: Commit and push
working-directory: ComfyUI_frontend
run: |
git config user.name 'github-actions'
git config user.email 'github-actions@github.com'
git add browser_tests/**/*-snapshots/*.png
if git diff --cached --quiet; then
echo "No snapshot changes detected"
else
git commit -m "[automated] Update test expectations"
git push
fi
```
## Performance Comparison
### Current Approach: Re-run Tests
| Step | Time |
|------|------|
| Download manifest | 5s |
| Install Playwright browsers | 20s |
| Re-run 50 failed tests | 2-3 min |
| Generate report | 10s |
| Commit and push | 10s |
| **Total** | **~3-4 min** |
### Proposed Approach: Copy Actuals
| Step | Time |
|------|------|
| Download test-results artifacts | 10s |
| Download JSON report | 2s |
| Run copy script | 5s |
| Commit and push | 10s |
| **Total** | **~30s** |
**Speedup: 6-8x faster**
## Advantages
**Much faster** - No test re-execution
**Simpler** - No need for manifest generation
**Fewer dependencies** - No Playwright browser install needed
**Less resource usage** - No ComfyUI server, no browser processes
**More reliable** - File operations are deterministic
**Already tested** - The snapshots were generated during the actual test run
## Disadvantages / Edge Cases
**New snapshots** - If a test creates a snapshot for the first time, there's no existing expected file. This is rare and can be handled by fallback to re-running.
**Deleted tests** - Old snapshots won't be cleaned up automatically. Could add a cleanup step.
**Multiple projects** - Each project (chromium, chromium-2x, mobile-chrome) generates separate actuals. The script needs to handle all of them.
**Artifact storage** - Storing test-results/ increases artifact size. Mitigation: Only upload `-actual.png` files, not traces/videos.
## Hybrid Approach (Recommended)
Use the fast copy approach **with fallback**:
```yaml
- name: Update snapshots
run: |
# Try fast approach first
if pnpm tsx scripts/cicd/update-snapshots-from-actuals.ts; then
echo "✓ Updated snapshots from actuals"
else
echo "⚠ Fast update failed, falling back to re-running tests"
# Fallback to current approach
pnpm exec playwright test --update-snapshots --project=chromium ...
fi
```
## Implementation Checklist
- [ ] Create `scripts/cicd/update-snapshots-from-actuals.ts`
- [ ] Update `tests-ci.yaml` to upload `test-results/` artifacts
- [ ] Update `update-playwright-expectations.yaml` to use new script
- [ ] Add fallback logic for edge cases
- [ ] Test with actual PR
- [ ] Update documentation
- [ ] Consider switching from label trigger → comment trigger (`/update-snapshots`)
## Related Links
- **Playwright snapshot docs:** https://playwright.dev/docs/test-snapshots
- **JSON reporter types:** `@playwright/test/reporter`
- **GitHub Actions artifacts:** https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts
- **Issue #22064:** Playwright feature request for better snapshot file alignment
## Conclusion
This approach is **significantly faster** and **simpler** than re-running tests. The main trade-off is artifact storage size, but this can be mitigated by only uploading actual snapshots (not traces/videos).
**Recommendation:** Implement this as the primary approach with fallback to re-running tests for edge cases.

View File

@@ -1,6 +1,7 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
@@ -23,10 +24,17 @@ const commonGlobals = {
} as const
const settings = {
'import/resolver': {
typescript: true,
node: true
},
'import-x/resolver-next': [
createTypeScriptImportResolver({
alwaysTryTypes: true,
project: [
'./tsconfig.json',
'./apps/*/tsconfig.json',
'./packages/*/tsconfig.json'
],
noWarnOnMultipleProjects: true
})
],
tailwindcss: {
config: `${import.meta.dirname}/packages/design-system/src/css/style.css`,
functions: ['cn', 'clsx', 'tw']
@@ -67,11 +75,8 @@ export default defineConfig([
...commonParserOptions,
projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts',
'playwright.config.ts',
'playwright.i18n.config.ts'
'vite.types.config.mts'
]
}
}
@@ -249,5 +254,17 @@ export default defineConfig([
rules: {
'no-console': 'off'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'no-console': 'off'
}
}
])

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.29.2",
"version": "1.30.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -13,6 +13,8 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
@@ -86,9 +88,12 @@
"jsdom": "catalog:",
"knip": "catalog:",
"lint-staged": "catalog:",
"markdown-table": "catalog:",
"nx": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
"stylelint": "catalog:",

View File

@@ -63,6 +63,7 @@
--color-sand-200: #d6cfc2;
--color-sand-300: #888682;
--color-pure-black: #000000;
--color-pure-white: #ffffff;
--color-slate-100: #9c9eab;
@@ -144,6 +145,9 @@
--content-hover-bg: #adadad;
--content-hover-fg: #000;
--button-surface: var(--color-pure-white);
--button-surface-contrast: var(--color-pure-black);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);
@@ -155,6 +159,7 @@
--backdrop: var(--color-white);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--button-icon: var(--color-gray-600);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
@@ -201,8 +206,11 @@
.dark-theme {
--accent-primary: var(--color-pure-white);
--backdrop: var(--color-neutral-900);
--button-surface: var(--color-charcoal-600);
--button-surface-contrast: var(--color-pure-white);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-gray-800);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
@@ -244,8 +252,11 @@
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-active-surface: var(--button-active-surface);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(--interface-menu-component-surface-hovered);
--color-interface-menu-component-surface-selected: var(--interface-menu-component-surface-selected);

View File

@@ -1,14 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_704_2695)">
<path d="M6.05048 2C5.52055 7.29512 9.23033 10.4722 14 9.94267" stroke="#9C9EAB" stroke-width="1.3"/>
<path d="M6.5 5.5L10 2" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
<path d="M8 8L12.5 3.5" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="square"/>
<path d="M10.5 9.5L14 6" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
<path d="M7.99992 14.6667C11.6818 14.6667 14.6666 11.6819 14.6666 8.00004C14.6666 4.31814 11.6818 1.33337 7.99992 1.33337C4.31802 1.33337 1.33325 4.31814 1.33325 8.00004C1.33325 11.6819 4.31802 14.6667 7.99992 14.6667Z" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.05048 2C5.52055 7.29512 9.23033 10.4722 14 9.94267" stroke="currentColor" stroke-width="1.3"/>
<path d="M6.5 5.5L10 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M8 8L12.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="square"/>
<path d="M10.5 9.5L14 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M7.99992 14.6667C11.6818 14.6667 14.6666 11.6819 14.6666 8.00004C14.6666 4.31814 11.6818 1.33337 7.99992 1.33337C4.31802 1.33337 1.33325 4.31814 1.33325 8.00004C1.33325 11.6819 4.31802 14.6667 7.99992 14.6667Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_704_2695">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 964 B

151
pnpm-lock.yaml generated
View File

@@ -183,9 +183,15 @@ catalogs:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
markdown-table:
specifier: ^3.0.4
version: 3.0.4
nx:
specifier: 21.4.1
version: 21.4.1
picocolors:
specifier: ^1.1.1
version: 1.1.1
pinia:
specifier: ^2.1.7
version: 2.2.2
@@ -193,8 +199,11 @@ catalogs:
specifier: ^1.8.0
version: 1.8.0
prettier:
specifier: ^3.3.2
version: 3.3.2
specifier: ^3.6.2
version: 3.6.2
pretty-bytes:
specifier: ^7.1.0
version: 7.1.0
primeicons:
specifier: ^7.0.0
version: 7.0.0
@@ -254,7 +263,7 @@ catalogs:
version: 3.5.13
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.1.0
version: 3.1.1
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0
@@ -476,7 +485,7 @@ importers:
version: 21.4.1(@babel/traverse@7.28.3)(@playwright/test@1.52.0)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)(typescript@5.9.2)
'@nx/storybook':
specifier: 'catalog:'
version: 21.4.1(@babel/traverse@7.28.3)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)
version: 21.4.1(@babel/traverse@7.28.3)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)
'@nx/vite':
specifier: 'catalog:'
version: 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)(typescript@5.9.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vitest@3.2.4)
@@ -488,19 +497,19 @@ importers:
version: 1.52.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
version: 9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
'@storybook/vue3':
specifier: 'catalog:'
version: 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vue@3.5.13(typescript@5.9.2))
version: 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vue@3.5.13(typescript@5.9.2))
'@storybook/vue3-vite':
specifier: 'catalog:'
version: 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))
version: 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.1.12(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
'@trivago/prettier-plugin-sort-imports':
specifier: 'catalog:'
version: 5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.3.2)
version: 5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.6.2)
'@types/eslint-plugin-tailwindcss':
specifier: 'catalog:'
version: 3.17.0
@@ -548,10 +557,10 @@ importers:
version: 4.16.1(@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0(jiti@2.4.2))
eslint-plugin-prettier:
specifier: 'catalog:'
version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)))(eslint@9.35.0(jiti@2.4.2))(prettier@3.3.2)
version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)))(eslint@9.35.0(jiti@2.4.2))(prettier@3.6.2)
eslint-plugin-storybook:
specifier: 'catalog:'
version: 9.1.6(eslint@9.35.0(jiti@2.4.2))(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)
version: 9.1.6(eslint@9.35.0(jiti@2.4.2))(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)
eslint-plugin-tailwindcss:
specifier: 'catalog:'
version: 4.0.0-beta.0(tailwindcss@4.1.12)
@@ -585,21 +594,30 @@ importers:
lint-staged:
specifier: 'catalog:'
version: 15.2.7
markdown-table:
specifier: 'catalog:'
version: 3.0.4
nx:
specifier: 'catalog:'
version: 21.4.1
picocolors:
specifier: 'catalog:'
version: 1.1.1
postcss-html:
specifier: 'catalog:'
version: 1.8.0
prettier:
specifier: 'catalog:'
version: 3.3.2
version: 3.6.2
pretty-bytes:
specifier: 'catalog:'
version: 7.1.0
rollup-plugin-visualizer:
specifier: 'catalog:'
version: 6.0.4(rollup@4.22.4)
storybook:
specifier: 'catalog:'
version: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
version: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
stylelint:
specifier: 'catalog:'
version: 16.24.0(typescript@5.9.2)
@@ -647,7 +665,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.1.0
version: 3.1.1
vue-eslint-parser:
specifier: 'catalog:'
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
@@ -4892,9 +4910,6 @@ packages:
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
get-tsconfig@4.7.5:
resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -5999,11 +6014,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
@@ -6351,10 +6361,6 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.1:
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -6367,11 +6373,15 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier@3.3.2:
resolution: {integrity: sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
pretty-bytes@7.1.0:
resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==}
engines: {node: '>=20'}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -7473,9 +7483,6 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.1.0:
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
@@ -9668,7 +9675,7 @@ snapshots:
- typescript
- verdaccio
'@nx/storybook@21.4.1(@babel/traverse@7.28.3)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)':
'@nx/storybook@21.4.1(@babel/traverse@7.28.3)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)':
dependencies:
'@nx/cypress': 21.4.1(@babel/traverse@7.28.3)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)(typescript@5.9.2)
'@nx/devkit': 21.4.1(nx@21.4.1)
@@ -9676,7 +9683,7 @@ snapshots:
'@nx/js': 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)
'@phenomnomnominal/tsquery': 5.0.1(typescript@5.9.2)
semver: 7.7.2
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
tslib: 2.8.1
transitivePeerDependencies:
- '@babel/traverse'
@@ -10027,29 +10034,29 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@storybook/addon-docs@9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))':
'@storybook/addon-docs@9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))':
dependencies:
'@mdx-js/react': 3.1.0(@types/react@19.1.9)(react@19.1.1)
'@storybook/csf-plugin': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
'@storybook/csf-plugin': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
'@storybook/icons': 1.4.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@storybook/react-dom-shim': 9.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
'@storybook/react-dom-shim': 9.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
ts-dedent: 2.2.0
transitivePeerDependencies:
- '@types/react'
'@storybook/builder-vite@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))':
'@storybook/builder-vite@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))':
dependencies:
'@storybook/csf-plugin': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
'@storybook/csf-plugin': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
ts-dedent: 2.2.0
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
'@storybook/csf-plugin@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))':
'@storybook/csf-plugin@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))':
dependencies:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
unplugin: 1.16.1
'@storybook/global@5.0.0': {}
@@ -10059,19 +10066,19 @@ snapshots:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
'@storybook/react-dom-shim@9.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))':
'@storybook/react-dom-shim@9.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))':
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
'@storybook/vue3-vite@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))':
'@storybook/vue3-vite@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@storybook/builder-vite': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
'@storybook/vue3': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vue@3.5.13(typescript@5.9.2))
'@storybook/builder-vite': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
'@storybook/vue3': 9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vue@3.5.13(typescript@5.9.2))
find-package-json: 1.2.0
magic-string: 0.30.19
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
typescript: 5.9.2
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-meta: 2.2.12(typescript@5.9.2)
@@ -10079,10 +10086,10 @@ snapshots:
transitivePeerDependencies:
- vue
'@storybook/vue3@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vue@3.5.13(typescript@5.9.2))':
'@storybook/vue3@9.1.1(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@storybook/global': 5.0.0
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.1
@@ -10347,7 +10354,7 @@ snapshots:
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
'@tiptap/pm': 2.10.4
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.3.2)':
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.6.2)':
dependencies:
'@babel/generator': 7.28.3
'@babel/parser': 7.28.4
@@ -10355,7 +10362,7 @@ snapshots:
'@babel/types': 7.28.4
javascript-natural-sort: 0.7.1
lodash: 4.17.21
prettier: 3.3.2
prettier: 3.6.2
optionalDependencies:
'@vue/compiler-sfc': 3.5.13
transitivePeerDependencies:
@@ -12133,20 +12140,20 @@ snapshots:
- supports-color
optional: true
eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)))(eslint@9.35.0(jiti@2.4.2))(prettier@3.3.2):
eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)))(eslint@9.35.0(jiti@2.4.2))(prettier@3.6.2):
dependencies:
eslint: 9.35.0(jiti@2.4.2)
prettier: 3.3.2
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.35.0(jiti@2.4.2))
eslint-plugin-storybook@9.1.6(eslint@9.35.0(jiti@2.4.2))(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2):
eslint-plugin-storybook@9.1.6(eslint@9.35.0(jiti@2.4.2))(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2):
dependencies:
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)
eslint: 9.35.0(jiti@2.4.2)
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
transitivePeerDependencies:
- supports-color
- typescript
@@ -12585,10 +12592,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-tsconfig@4.7.5:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -13883,8 +13886,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.8: {}
nanoid@5.1.5: {}
napi-postinstall@0.3.3: {}
@@ -14264,14 +14265,14 @@ snapshots:
dependencies:
htmlparser2: 8.0.2
js-tokens: 9.0.1
postcss: 8.5.1
postcss-safe-parser: 6.0.0(postcss@8.5.1)
postcss: 8.5.6
postcss-safe-parser: 6.0.0(postcss@8.5.6)
postcss-resolve-nested-selector@0.1.6: {}
postcss-safe-parser@6.0.0(postcss@8.5.1):
postcss-safe-parser@6.0.0(postcss@8.5.6):
dependencies:
postcss: 8.5.1
postcss: 8.5.6
postcss-safe-parser@7.0.1(postcss@8.5.6):
dependencies:
@@ -14289,12 +14290,6 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.1:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -14307,7 +14302,9 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier@3.3.2: {}
prettier@3.6.2: {}
pretty-bytes@7.1.0: {}
pretty-format@27.5.1:
dependencies:
@@ -14984,7 +14981,7 @@ snapshots:
internal-slot: 1.1.0
optional: true
storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)):
storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)):
dependencies:
'@storybook/global': 5.0.0
'@testing-library/jest-dom': 6.6.4
@@ -14999,7 +14996,7 @@ snapshots:
semver: 7.7.2
ws: 8.18.3
optionalDependencies:
prettier: 3.3.2
prettier: 3.6.2
transitivePeerDependencies:
- '@testing-library/dom'
- bufferutil
@@ -15321,7 +15318,7 @@ snapshots:
tsx@4.19.4:
dependencies:
esbuild: 0.25.5
get-tsconfig: 4.7.5
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
@@ -15673,7 +15670,7 @@ snapshots:
vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2):
dependencies:
esbuild: 0.21.5
postcss: 8.5.1
postcss: 8.5.6
rollup: 4.22.4
optionalDependencies:
'@types/node': 20.14.10
@@ -15738,8 +15735,6 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.1.0: {}
vue-component-type-helpers@3.1.1: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):

View File

@@ -62,10 +62,13 @@ catalog:
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.2.7
markdown-table: ^3.0.4
nx: 21.4.1
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0
prettier: ^3.3.2
prettier: ^3.6.2
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
@@ -98,9 +101,6 @@ catalog:
cleanupUnusedCatalogs: true
overrides:
'@types/eslint': '-'
ignoredBuiltDependencies:
- '@firebase/util'
- protobufjs
@@ -115,3 +115,6 @@ onlyBuiltDependencies:
- esbuild
- nx
- oxc-resolver
overrides:
'@types/eslint': '-'

Binary file not shown.

View File

@@ -0,0 +1,130 @@
// @ts-check
/**
* Bundle categorization configuration
*
* This file defines how bundles are categorized in size reports.
* Categories help identify which parts of the application are growing.
*/
/**
* @typedef {Object} BundleCategory
* @property {string} name - Display name of the category
* @property {string} description - Description of what this category includes
* @property {RegExp[]} patterns - Regex patterns to match bundle files
* @property {number} order - Sort order for display (lower = first)
*/
/** @type {BundleCategory[]} */
export const BUNDLE_CATEGORIES = [
{
name: 'App Entry Points',
description: 'Main entry bundles and manifests',
patterns: [/^index-.*\.js$/i, /^manifest-.*\.js$/i],
order: 1
},
{
name: 'Graph Workspace',
description: 'Graph editor runtime, canvas, workflow orchestration',
patterns: [
/Graph(View|State)?-.*\.js$/i,
/(Canvas|Workflow|History|NodeGraph|Compositor)-.*\.js$/i
],
order: 2
},
{
name: 'Views & Navigation',
description: 'Top-level views, pages, and routed surfaces',
patterns: [/.*(View|Page|Layout|Screen|Route)-.*\.js$/i],
order: 3
},
{
name: 'Panels & Settings',
description: 'Configuration panels, inspectors, and settings screens',
patterns: [/.*(Panel|Settings|Config|Preferences|Manager)-.*\.js$/i],
order: 4
},
{
name: 'User & Accounts',
description: 'Authentication, profile, and account management bundles',
patterns: [
/.*((User(Panel|Select|Auth|Account|Profile|Settings|Preferences|Manager|List|Menu|Modal))|Account|Auth|Profile|Login|Signup|Password).*-.+\.js$/i
],
order: 5
},
{
name: 'Editors & Dialogs',
description: 'Modals, dialogs, drawers, and in-app editors',
patterns: [/.*(Modal|Dialog|Drawer|Editor)-.*\.js$/i],
order: 6
},
{
name: 'UI Components',
description: 'Reusable component library chunks',
patterns: [
/.*(Button|Avatar|Badge|Dropdown|Tabs|Table|List|Card|Form|Input|Toggle|Menu|Toolbar|Sidebar)-.*\.js$/i,
/.*\.vue_vue_type_script_setup_true_lang-.*\.js$/i
],
order: 7
},
{
name: 'Data & Services',
description: 'Stores, services, APIs, and repositories',
patterns: [/.*(Service|Store|Api|Repository)-.*\.js$/i],
order: 8
},
{
name: 'Utilities & Hooks',
description: 'Helpers, composables, and utility bundles',
patterns: [
/.*(Util|Utils|Helper|Composable|Hook)-.*\.js$/i,
/use[A-Z].*\.js$/
],
order: 9
},
{
name: 'Vendor & Third-Party',
description: 'External libraries and shared vendor chunks',
patterns: [
/^(chunk|vendor|prime|three|lodash|chart|firebase|yjs|axios|uuid)-.*\.js$/i
],
order: 10
},
{
name: 'Other',
description: 'Bundles that do not match a named category',
patterns: [/.*/],
order: 99
}
]
/**
* Categorize a bundle file based on its name
*
* @param {string} fileName - The bundle file name (e.g., "assets/GraphView-BnV6iF9h.js")
* @returns {string} - The category name
*/
export function categorizeBundle(fileName) {
// Extract just the file name without path
const baseName = fileName.split('/').pop() || fileName
// Find the first matching category
for (const category of BUNDLE_CATEGORIES) {
for (const pattern of category.patterns) {
if (pattern.test(baseName)) {
return category.name
}
}
}
return 'Other'
}
/**
* Get category metadata by name
*
* @param {string} categoryName - The category name
* @returns {BundleCategory | undefined} - The category metadata
*/
export function getCategoryMetadata(categoryName) {
return BUNDLE_CATEGORIES.find((cat) => cat.name === categoryName)
}

View File

@@ -0,0 +1,87 @@
import type {
JSONReport,
JSONReportSpec,
JSONReportSuite,
JSONReportTestResult
} from '@playwright/test/reporter'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
const argv = process.argv.slice(2)
const getArg = (flag: string, fallback: string) => {
const i = argv.indexOf(flag)
if (i >= 0 && i + 1 < argv.length) return argv[i + 1]
return fallback
}
async function main() {
// Defaults mirror the workflow layout
const reportPath = getArg(
'--report',
path.join('playwright-report', 'report.json')
)
const outDir = getArg('--out', path.join('ci-rerun'))
if (!fs.existsSync(reportPath)) {
throw Error(`Report not found at ${reportPath}`)
}
const raw = await fsp.readFile(reportPath, 'utf8')
let data: JSONReport
try {
data = JSON.parse(raw)
} catch (error) {
throw new Error(
`Failed to parse Playwright JSON report at ${reportPath}. ` +
`The report file may be corrupted or incomplete. ` +
`Error: ${error instanceof Error ? error.message : String(error)}`
)
}
const hasScreenshotSignal = (r: JSONReportTestResult) => {
return r.attachments.some((att) => att?.contentType?.startsWith('image/'))
}
const out = new Map<string, Set<string>>()
const collectFailedScreenshots = (suite?: JSONReportSuite) => {
if (!suite) return
const childSuites = suite.suites ?? []
for (const childSuite of childSuites) collectFailedScreenshots(childSuite)
const specs: JSONReportSpec[] = suite.specs ?? []
for (const spec of specs) {
const file = spec.file
const line = spec.line
const loc = `${file}:${line}`
for (const test of spec.tests) {
const project = test.projectId
const last = test.results[test.results.length - 1]
const failedScreenshot =
last && last.status === 'failed' && hasScreenshotSignal(last)
if (!failedScreenshot) continue
if (!out.has(project)) out.set(project, new Set())
const projectSet = out.get(project)
if (projectSet) {
projectSet.add(loc)
}
}
}
}
const report: JSONReport = data
const rootSuites = report.suites ?? []
for (const suite of rootSuites) collectFailedScreenshots(suite)
await fsp.mkdir(outDir, { recursive: true })
for (const [project, set] of out.entries()) {
const f = path.join(outDir, `${project}.txt`)
await fsp.writeFile(f, Array.from(set).join('\n') + '\n', 'utf8')
}
}
main().catch((err) => {
console.error('Manifest generation failed:', err)
process.exit(1)
})

90
scripts/size-collect.js Normal file
View File

@@ -0,0 +1,90 @@
// @ts-check
import { existsSync } from 'node:fs'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { brotliCompressSync, gzipSync } from 'node:zlib'
import pico from 'picocolors'
import prettyBytes from 'pretty-bytes'
import { categorizeBundle } from './bundle-categories.js'
const distDir = path.resolve('dist')
const sizeDir = path.resolve('temp/size')
/**
* @typedef {Object} SizeResult
* @property {string} file
* @property {string} category
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
run()
/**
* Main function to collect bundle size data
*/
async function run() {
if (!existsSync(distDir)) {
console.error(pico.red('Error: dist directory does not exist'))
console.error(pico.yellow('Please run "pnpm build" first'))
process.exit(1)
}
console.log(pico.blue('\nCollecting bundle size data...\n'))
// Collect main bundle files from dist/assets
const assetsDir = path.join(distDir, 'assets')
const bundles = []
if (existsSync(assetsDir)) {
const files = await readdir(assetsDir)
const jsFiles = files.filter(
(file) => file.endsWith('.js') && !file.includes('legacy')
)
for (const file of jsFiles) {
const filePath = path.join(assetsDir, file)
const content = await readFile(filePath, 'utf-8')
const size = Buffer.byteLength(content)
const gzip = gzipSync(content).length
const brotli = brotliCompressSync(content).length
const fileName = `assets/${file}`
const category = categorizeBundle(fileName)
bundles.push({
file: fileName,
category,
size,
gzip,
brotli
})
console.log(
`${pico.green(file)} ${pico.dim(`[${category}]`)} - ` +
`Size: ${prettyBytes(size)} / ` +
`Gzip: ${prettyBytes(gzip)} / ` +
`Brotli: ${prettyBytes(brotli)}`
)
}
}
// Create temp/size directory
await mkdir(sizeDir, { recursive: true })
// Write individual bundle files
for (const bundle of bundles) {
const fileName = bundle.file.replace(/[/\\]/g, '_').replace('.js', '.json')
await writeFile(
path.join(sizeDir, fileName),
JSON.stringify(bundle, null, 2),
'utf-8'
)
}
console.log(
pico.green(`\n✓ Collected size data for ${bundles.length} bundles\n`)
)
console.log(pico.blue(`Data saved to: ${sizeDir}\n`))
}

560
scripts/size-report.js Normal file
View File

@@ -0,0 +1,560 @@
// @ts-check
import { markdownTable } from 'markdown-table'
import { existsSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import path from 'node:path'
import prettyBytes from 'pretty-bytes'
import { getCategoryMetadata } from './bundle-categories.js'
/**
* @typedef {Object} SizeMetrics
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
/**
* @typedef {Object} SizeResult
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
/**
* @typedef {SizeResult & { file: string, category?: string }} BundleResult
*/
/**
* @typedef {'added' | 'removed' | 'increased' | 'decreased' | 'unchanged'} BundleStatus
*/
/**
* @typedef {Object} BundleDiff
* @property {string} fileName
* @property {BundleResult | undefined} curr
* @property {BundleResult | undefined} prev
* @property {SizeMetrics} diff
* @property {BundleStatus} status
*/
/**
* @typedef {Object} CountSummary
* @property {number} added
* @property {number} removed
* @property {number} increased
* @property {number} decreased
* @property {number} unchanged
*/
/**
* @typedef {Object} CategoryReport
* @property {string} name
* @property {string | undefined} description
* @property {number} order
* @property {{ current: SizeMetrics, baseline: SizeMetrics, diff: SizeMetrics }} metrics
* @property {CountSummary} counts
* @property {BundleDiff[]} bundles
*/
/**
* @typedef {Object} BundleReport
* @property {CategoryReport[]} categories
* @property {{ currentBundles: number, baselineBundles: number, metrics: { current: SizeMetrics, baseline: SizeMetrics, diff: SizeMetrics }, counts: CountSummary }} overall
* @property {boolean} hasBaseline
*/
const currDir = path.resolve('temp/size')
const prevDir = path.resolve('temp/size-prev')
run()
/**
* Main entry for generating the size report
*/
async function run() {
if (!existsSync(currDir)) {
console.error('Error: temp/size directory does not exist')
console.error('Please run "pnpm size:collect" first')
process.exit(1)
}
const report = await buildBundleReport()
const output = renderReport(report)
process.stdout.write(output)
}
/**
* Build bundle comparison data from current and baseline artifacts
* @returns {Promise<BundleReport>}
*/
async function buildBundleReport() {
/**
* @param {string[]} files
* @returns {string[]}
*/
const filterFiles = (files) => files.filter((file) => file.endsWith('.json'))
const currFiles = filterFiles(await readdir(currDir))
const baselineFiles = existsSync(prevDir)
? filterFiles(await readdir(prevDir))
: []
const fileList = new Set([...currFiles, ...baselineFiles])
/** @type {Map<string, CategoryReport>} */
const categories = new Map()
const overall = {
currentBundles: 0,
baselineBundles: 0,
metrics: {
current: createMetrics(),
baseline: createMetrics(),
diff: createMetrics()
},
counts: createCounts()
}
for (const file of fileList) {
const currPath = path.resolve(currDir, file)
const prevPath = path.resolve(prevDir, file)
const curr = await importJSON(currPath)
const prev = await importJSON(prevPath)
const fileName = curr?.file || prev?.file
if (!fileName) continue
const categoryName = curr?.category || prev?.category || 'Other'
const category = ensureCategoryEntry(categories, categoryName)
const currMetrics = toMetrics(curr)
const baselineMetrics = toMetrics(prev)
const diffMetrics = subtractMetrics(currMetrics, baselineMetrics)
const status = getStatus(curr, prev, diffMetrics.size)
if (curr) {
overall.currentBundles++
}
if (prev) {
overall.baselineBundles++
}
addMetrics(overall.metrics.current, currMetrics)
addMetrics(overall.metrics.baseline, baselineMetrics)
addMetrics(overall.metrics.diff, diffMetrics)
incrementStatus(overall.counts, status)
addMetrics(category.metrics.current, currMetrics)
addMetrics(category.metrics.baseline, baselineMetrics)
addMetrics(category.metrics.diff, diffMetrics)
incrementStatus(category.counts, status)
category.bundles.push({
fileName,
curr,
prev,
diff: diffMetrics,
status
})
}
const sortedCategories = Array.from(categories.values()).sort(
(a, b) => a.order - b.order
)
return {
categories: sortedCategories,
overall,
hasBaseline: baselineFiles.length > 0
}
}
/**
* Render the complete report in markdown
* @param {BundleReport} report
* @returns {string}
*/
function renderReport(report) {
const parts = ['## Bundle Size Report\n']
parts.push(renderSummary(report))
if (report.categories.length > 0) {
const glance = renderCategoryGlance(report)
if (glance) {
parts.push('\n' + glance)
}
parts.push('\n' + renderCategoryDetails(report))
}
return (
parts
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
)
}
/**
* Render overall summary bullets
* @param {BundleReport} report
* @returns {string}
*/
function renderSummary(report) {
const { overall, hasBaseline } = report
const lines = ['**Summary**']
const rawLineParts = [
`- Raw size: ${prettyBytes(overall.metrics.current.size)}`
]
if (hasBaseline) {
rawLineParts.push(`baseline ${prettyBytes(overall.metrics.baseline.size)}`)
rawLineParts.push(`${formatDiffIndicator(overall.metrics.diff.size)}`)
}
lines.push(rawLineParts.join(' '))
const gzipLineParts = [`- Gzip: ${prettyBytes(overall.metrics.current.gzip)}`]
if (hasBaseline) {
gzipLineParts.push(`baseline ${prettyBytes(overall.metrics.baseline.gzip)}`)
gzipLineParts.push(`${formatDiffIndicator(overall.metrics.diff.gzip)}`)
}
lines.push(gzipLineParts.join(' '))
const brotliLineParts = [
`- Brotli: ${prettyBytes(overall.metrics.current.brotli)}`
]
if (hasBaseline) {
brotliLineParts.push(
`baseline ${prettyBytes(overall.metrics.baseline.brotli)}`
)
brotliLineParts.push(
`${formatDiffIndicator(overall.metrics.diff.brotli)}`
)
}
lines.push(brotliLineParts.join(' '))
const bundleStats = [`${overall.currentBundles} current`]
if (hasBaseline) {
bundleStats.push(`${overall.baselineBundles} baseline`)
}
const statusParts = []
if (overall.counts.added) statusParts.push(`${overall.counts.added} added`)
if (overall.counts.removed)
statusParts.push(`${overall.counts.removed} removed`)
if (overall.counts.increased)
statusParts.push(`${overall.counts.increased} grew`)
if (overall.counts.decreased)
statusParts.push(`${overall.counts.decreased} shrank`)
let bundlesLine = `- Bundles: ${bundleStats.join(' • ')}`
if (statusParts.length > 0) {
bundlesLine += `${statusParts.join(' / ')}`
}
lines.push(bundlesLine)
if (!hasBaseline) {
lines.push(
'_Baseline artifact not found; showing current bundle sizes only._'
)
}
return lines.join('\n')
}
/**
* Render a compact category glance line
* @param {BundleReport} report
* @returns {string}
*/
function renderCategoryGlance(report) {
const { categories, hasBaseline } = report
const relevant = categories.filter(
(category) =>
category.metrics.current.size > 0 ||
(hasBaseline && category.metrics.baseline.size > 0)
)
if (relevant.length === 0) return ''
const sorted = relevant.slice().sort((a, b) => {
if (hasBaseline) {
return (
Math.abs(b.metrics.diff.size) - Math.abs(a.metrics.diff.size) ||
b.metrics.current.size - a.metrics.current.size
)
}
return b.metrics.current.size - a.metrics.current.size
})
const limit = 6
const trimmed = sorted.slice(0, limit)
const parts = trimmed.map((category) => {
const currentStr = prettyBytes(category.metrics.current.size)
if (hasBaseline) {
return `${category.name} ${formatDiffIndicator(category.metrics.diff.size)} (${currentStr})`
}
return `${category.name} ${currentStr}`
})
if (sorted.length > limit) {
parts.push(`+ ${sorted.length - limit} more`)
}
return `**Category Glance**\n${parts.join(' · ')}`
}
/**
* Render per-category detail tables wrapped in collapsible sections
* @param {BundleReport} report
* @returns {string}
*/
function renderCategoryDetails(report) {
const lines = ['<details>', '<summary>Per-category breakdown</summary>', '']
for (const category of report.categories) {
lines.push(renderCategoryBlock(category, report.hasBaseline))
}
lines.push('</details>')
return lines.join('\n')
}
/**
* Render a single category block with its table
* @param {CategoryReport} category
* @param {boolean} hasBaseline
* @returns {string}
*/
function renderCategoryBlock(category, hasBaseline) {
const lines = ['<details>']
const currentStr = prettyBytes(category.metrics.current.size)
const summaryParts = [`<summary>${category.name}${currentStr}`]
if (hasBaseline) {
summaryParts.push(
` (baseline ${prettyBytes(category.metrics.baseline.size)}) • ${formatDiffIndicator(category.metrics.diff.size)}`
)
}
summaryParts.push('</summary>')
lines.push(summaryParts.join(''))
if (category.description) {
lines.push(`_${category.description}_`)
}
if (category.bundles.length === 0) {
lines.push('No bundles matched this category.\n')
lines.push('</details>\n')
return lines.join('\n')
}
const headers = hasBaseline
? ['File', 'Before', 'After', 'Δ Raw', 'Δ Gzip', 'Δ Brotli']
: ['File', 'Size', 'Gzip', 'Brotli']
const rows = category.bundles
.slice()
.sort((a, b) => {
const diffMagnitude = Math.abs(b.diff.size) - Math.abs(a.diff.size)
if (diffMagnitude !== 0) return diffMagnitude
return a.fileName.localeCompare(b.fileName)
})
.map((bundle) => {
if (hasBaseline) {
return [
formatFileLabel(bundle),
formatSize(bundle.prev?.size),
formatSize(bundle.curr?.size),
formatDiffIndicator(bundle.diff.size),
formatDiffIndicator(bundle.diff.gzip),
formatDiffIndicator(bundle.diff.brotli)
]
}
return [
formatFileLabel(bundle),
formatSize(bundle.curr?.size),
formatSize(bundle.curr?.gzip),
formatSize(bundle.curr?.brotli)
]
})
lines.push(markdownTable([headers, ...rows]))
const statusParts = []
if (category.counts.added) statusParts.push(`${category.counts.added} added`)
if (category.counts.removed)
statusParts.push(`${category.counts.removed} removed`)
if (category.counts.increased)
statusParts.push(`${category.counts.increased} grew`)
if (category.counts.decreased)
statusParts.push(`${category.counts.decreased} shrank`)
if (statusParts.length > 0) {
lines.push(`\n_Status:_ ${statusParts.join(' / ')}`)
}
lines.push('</details>\n')
return lines.join('\n')
}
/**
* Ensure a category entry exists in the map
* @param {Map<string, CategoryReport>} categories
* @param {string} categoryName
* @returns {CategoryReport}
*/
function ensureCategoryEntry(categories, categoryName) {
if (!categories.has(categoryName)) {
const meta = getCategoryMetadata(categoryName)
categories.set(categoryName, {
name: categoryName,
description: meta?.description,
order: meta?.order ?? 99,
metrics: {
current: createMetrics(),
baseline: createMetrics(),
diff: createMetrics()
},
counts: createCounts(),
bundles: []
})
}
// @ts-expect-error - ensured by check above
return categories.get(categoryName)
}
/**
* Convert bundle result to metrics
* @param {BundleResult | undefined} bundle
* @returns {SizeMetrics}
*/
function toMetrics(bundle) {
if (!bundle) return createMetrics()
return {
size: bundle.size,
gzip: bundle.gzip,
brotli: bundle.brotli
}
}
/**
* Create an empty metrics object
* @returns {SizeMetrics}
*/
function createMetrics() {
return { size: 0, gzip: 0, brotli: 0 }
}
/**
* Add source metrics into target metrics
* @param {SizeMetrics} target
* @param {SizeMetrics} source
*/
function addMetrics(target, source) {
target.size += source.size
target.gzip += source.gzip
target.brotli += source.brotli
}
/**
* Subtract baseline metrics from current metrics
* @param {SizeMetrics} current
* @param {SizeMetrics} baseline
* @returns {SizeMetrics}
*/
function subtractMetrics(current, baseline) {
return {
size: current.size - baseline.size,
gzip: current.gzip - baseline.gzip,
brotli: current.brotli - baseline.brotli
}
}
/**
* Create an empty counts object
* @returns {CountSummary}
*/
function createCounts() {
return { added: 0, removed: 0, increased: 0, decreased: 0, unchanged: 0 }
}
/**
* Increment status counters
* @param {CountSummary} counts
* @param {BundleStatus} status
*/
function incrementStatus(counts, status) {
counts[status] += 1
}
/**
* Determine bundle status for reporting
* @param {BundleResult | undefined} curr
* @param {BundleResult | undefined} prev
* @param {number} sizeDiff
* @returns {BundleStatus}
*/
function getStatus(curr, prev, sizeDiff) {
if (curr && prev) {
if (sizeDiff > 0) return 'increased'
if (sizeDiff < 0) return 'decreased'
return 'unchanged'
}
if (curr && !prev) return 'added'
if (!curr && prev) return 'removed'
return 'unchanged'
}
/**
* Format file label with status hints
* @param {BundleDiff} bundle
* @returns {string}
*/
function formatFileLabel(bundle) {
if (bundle.status === 'added') {
return `**${bundle.fileName}** _(new)_`
}
if (bundle.status === 'removed') {
return `~~${bundle.fileName}~~ _(removed)_`
}
return bundle.fileName
}
/**
* Format size for table output
* @param {number | undefined} value
* @returns {string}
*/
function formatSize(value) {
if (value === undefined) return '—'
return prettyBytes(value)
}
/**
* Format a diff with an indicator emoji
* @param {number} diff
* @returns {string}
*/
function formatDiffIndicator(diff) {
if (diff > 0) {
return `:red_circle: +${prettyBytes(diff)}`
}
if (diff < 0) {
return `:green_circle: -${prettyBytes(Math.abs(diff))}`
}
return ':white_circle: 0 B'
}
/**
* Import JSON data if it exists
* @template T
* @param {string} filePath
* @returns {Promise<T | undefined>}
*/
async function importJSON(filePath) {
if (!existsSync(filePath)) return undefined
return (await import(filePath, { with: { type: 'json' } })).default
}

View File

@@ -53,7 +53,7 @@
"comfy_base": {
"fg-color": "#222",
"bg-color": "#DDD",
"comfy-menu-bg": "#F5F5F5",
"comfy-menu-bg": "#FFFFFF",
"comfy-menu-hover-bg": "#ccc",
"comfy-menu-secondary-bg": "#EEE",
"comfy-input-bg": "#C9C9C9",

View File

@@ -1,48 +1,88 @@
<template>
<Splitter
:key="sidebarStateKey"
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:state-key="sidebarStateKey"
state-storage="local"
>
<SplitterPanel
v-show="sidebarPanelVisible"
v-if="sidebarLocation === 'left'"
class="side-bar-panel"
:min-size="10"
:size="20"
>
<slot name="side-bar-panel" />
</SplitterPanel>
<div class="splitter-overlay-root pointer-events-none flex flex-col">
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<SplitterPanel :size="100">
<Splitter
class="splitter-overlay max-w-full"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
state-key="bottom-panel-splitter"
key="main-splitter-stable"
class="splitter-overlay flex-1 overflow-hidden"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:state-key="sidebarStateKey || 'main-splitter'"
state-storage="local"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel" />
<SplitterPanel
v-if="sidebarLocation === 'left'"
class="side-bar-panel pointer-events-auto"
:min-size="10"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'left'
? 'flex'
: 'none'
}"
>
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'left'"
name="side-bar-panel"
/>
</SplitterPanel>
<SplitterPanel v-show="bottomPanelVisible" class="bottom-panel">
<slot name="bottom-panel" />
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible="sidebarPanelVisible" />
<Splitter
class="splitter-overlay splitter-overlay-bottom mr-2 mb-2 ml-2 flex-1"
layout="vertical"
:pt:gutter="
'rounded-tl-lg rounded-tr-lg ' +
(bottomPanelVisible ? '' : 'hidden')
"
state-key="bottom-panel-splitter"
state-storage="local"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible"
class="bottom-panel pointer-events-auto rounded-lg"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<SplitterPanel
v-if="sidebarLocation === 'right'"
class="side-bar-panel pointer-events-auto"
:min-size="10"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'right'
? 'flex'
: 'none'
}"
>
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'right'"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
</SplitterPanel>
<SplitterPanel
v-show="sidebarPanelVisible"
v-if="sidebarLocation === 'right'"
class="side-bar-panel"
:min-size="10"
:size="20"
>
<slot name="side-bar-panel" />
</SplitterPanel>
</Splitter>
</div>
</div>
</template>
<script setup lang="ts">
@@ -74,7 +114,11 @@ const activeSidebarTabId = computed(
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value ? 'unified-sidebar' : activeSidebarTabId.value ?? ''
if (unifiedWidth.value) {
return 'unified-sidebar'
}
// When no tab is active, use a default key to maintain state
return activeSidebarTabId.value ?? 'default-sidebar'
})
</script>
@@ -93,12 +137,17 @@ const sidebarStateKey = computed(() => {
.side-bar-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.bottom-panel {
background-color: var(--bg-color);
pointer-events: auto;
background-color: var(--comfy-menu-bg);
border: 1px solid var(--p-panel-border-color);
max-width: 100%;
overflow-x: auto;
}
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
transform: translateY(5px);
}
.splitter-overlay {

View File

@@ -1,8 +1,7 @@
<template>
<div
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger no-drag"
:style="positionCSS"
class="comfy-menu-hamburger no-drag top-0 right-0"
>
<Button
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
@@ -15,14 +14,13 @@
@click="exitFocusMode"
@contextmenu="showNativeSystemMenu"
/>
<div v-show="menuSetting !== 'Bottom'" class="window-actions-spacer" />
<div class="window-actions-spacer" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import type { CSSProperties } from 'vue'
import { computed, watchEffect } from 'vue'
import { watchEffect } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
@@ -45,15 +43,6 @@ watchEffect(() => {
app.ui.menuContainer.style.display = 'block'
}
})
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const positionCSS = computed<CSSProperties>(() =>
// 'Bottom' menuSetting shows the hamburger button in the bottom right corner
// 'Disabled', 'Top' menuSetting shows the hamburger button in the top right corner
menuSetting.value === 'Bottom'
? { bottom: '0px', right: '0px' }
: { top: '0px', right: '0px' }
)
</script>
<style scoped>

View File

@@ -0,0 +1,51 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-2 flex pt-2">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div
class="actionbar-container pointer-events-auto mx-2 flex h-12 items-center rounded-lg px-2 shadow-md"
>
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<LoginButton v-if="!isLoggedIn" />
<CurrentUserButton v-else class="shrink-0" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const { isLoggedIn } = useCurrentUser()
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
border: 1px solid var(--p-panel-border-color);
}
</style>

View File

@@ -1,50 +1,69 @@
<template>
<Panel
class="actionbar w-fit"
:style="style"
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
>
<div ref="panelRef" class="actionbar-content flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<ComfyQueueButton />
<div class="flex h-full items-center">
<div
v-if="isDragging && !isDocked"
class="actionbar-drop-zone m-1.5 flex items-center justify-center self-stretch rounded-md"
:class="{
'drop-zone-active': isMouseOverDropZone
}"
@mouseenter="onMouseEnterDropZone"
@mouseleave="onMouseLeaveDropZone"
>
{{ t('actionbar.dockToTop') }}
</div>
</Panel>
<Panel
class="actionbar"
:style="style"
:class="{
fixed: !isDocked,
'is-dragging': isDragging,
'is-docked static mr-2 border-none bg-transparent p-0': isDocked
}"
>
<div
ref="panelRef"
class="actionbar-content flex items-center select-none"
>
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<ComfyRunButton />
</div>
</Panel>
</div>
</template>
<script lang="ts" setup>
import {
useDraggable,
useElementBounding,
useEventBus,
useEventListener,
useLocalStorage,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import type { Ref } from 'vue'
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyQueueButton from './ComfyQueueButton.vue'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const tabContainer = document.querySelector('.workflow-tabs-container')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
@@ -63,11 +82,9 @@ const {
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
if (position.value === 'Top') {
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
if (event.y < minY) {
event.y = minY
}
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
if (event.y < minY) {
event.y = minY
}
}
})
@@ -202,39 +219,38 @@ const adjustMenuPosition = () => {
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {
if (!panelRef.value) {
return false
// Drop zone state
const isMouseOverDropZone = ref(false)
// Mouse event handlers for self-contained drop zone
const onMouseEnterDropZone = () => {
if (isDragging.value) {
isMouseOverDropZone.value = true
}
const { height } = panelRef.value.getBoundingClientRect()
const actionbarBottom = y.value + height
const topMenuBottom = topMenuBounds.bottom.value
}
const overlapPixels =
Math.min(actionbarBottom, topMenuBottom) -
Math.max(y.value, topMenuBounds.top.value)
return overlapPixels > overlapThreshold
})
const onMouseLeaveDropZone = () => {
if (isDragging.value) {
isMouseOverDropZone.value = false
}
}
watch(isDragging, (newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
isDocked.value = isOverlappingWithTopMenu.value
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {
// Starting to drag - undock if docked
if (isDocked.value) {
isDocked.value = false
}
} else {
// Start dragging
isDocked.value = false
// Stopped dragging - dock if mouse is over drop zone
if (isMouseOverDropZone.value) {
isDocked.value = true
}
// Reset drop zone state
isMouseOverDropZone.value = false
}
})
const eventBus = useEventBus<string>('topMenu')
watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
eventBus.emit('updateHighlight', {
isDragging: dragging,
isOverlapping: overlapping
})
})
</script>
<style scoped>
@@ -242,17 +258,27 @@ watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
.actionbar {
pointer-events: all;
position: fixed;
z-index: 1000;
}
.actionbar.is-docked {
position: static;
@apply bg-transparent border-none p-0;
.actionbar-drop-zone {
width: 265px;
border: 2px dashed var(--p-primary-color);
opacity: 0.8;
}
.actionbar-drop-zone.drop-zone-active {
background: var(--p-highlight-background-focus);
border-color: var(--p-primary-color);
border-width: 3px;
box-shadow: 0 0 20px var(--p-primary-color);
opacity: 1;
transform: scale(1.05);
}
.actionbar.is-dragging {
user-select: none;
pointer-events: none;
}
:deep(.p-panel-content) {

View File

@@ -0,0 +1,19 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -93,7 +93,7 @@ import {
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import BatchCountEdit from './BatchCountEdit.vue'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())

View File

@@ -0,0 +1,7 @@
import { defineAsyncComponent } from 'vue'
import { isCloud } from '@/platform/distribution/types'
export default isCloud
? defineAsyncComponent(() => import('./CloudRunButtonWrapper.vue'))
: defineAsyncComponent(() => import('./ComfyQueueButton.vue'))

View File

@@ -3,17 +3,42 @@
<Tabs
:key="$i18n.locale"
v-model:value="bottomPanelStore.activeBottomPanelTabId"
style="--p-tabs-tablist-background: var(--comfy-menu-bg)"
>
<TabList pt:tab-list="border-none">
<TabList
pt:tab-list="border-none h-full flex items-center py-2 border-b-1 border-solid"
class="bg-transparent"
>
<div class="flex w-full justify-between">
<div class="tabs-container">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="border-none p-3"
class="m-1 mx-2 border-none"
:class="{
'tab-list-single-item':
bottomPanelStore.bottomPanelTabs.length === 1
}"
:pt:root="
(x: TabPassThroughMethodOptions) => ({
class: {
'p-3 rounded-lg': true,
'pointer-events-none':
bottomPanelStore.bottomPanelTabs.length === 1
},
style: {
color: 'var(--fg-color)',
backgroundColor:
!x.context.active ||
bottomPanelStore.bottomPanelTabs.length === 1
? ''
: 'var(--bg-color)'
}
})
"
>
<span class="font-bold">
<span class="font-normal">
{{ getTabDisplayTitle(tab) }}
</span>
</Tab>
@@ -56,6 +81,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Tab from 'primevue/tab'
import type { TabPassThroughMethodOptions } from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { computed } from 'vue'
@@ -95,3 +121,9 @@ const closeBottomPanel = () => {
bottomPanelStore.activePanel = null
}
</script>
<style scoped>
:deep(.p-tablist-active-bar) {
display: none;
}
</style>

View File

@@ -64,7 +64,6 @@ const terminalCreated = (
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full w-full bg-black">
<div class="h-full w-full bg-transparent">
<p v-if="errorMessage" class="p-4 text-center">
{{ errorMessage }}
</p>
@@ -94,7 +94,6 @@ const terminalCreated = (
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -1,12 +1,13 @@
<template>
<div
class="subgraph-breadcrumb w-auto"
class="subgraph-breadcrumb w-auto drop-shadow-md"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
}"
:style="{
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
'--p-breadcrumb-gap': `0px`,
'--p-breadcrumb-item-margin': `${ITEM_GAP / 2}px`,
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
@@ -14,8 +15,9 @@
>
<Breadcrumb
ref="breadcrumbRef"
class="bg-transparent p-0"
class="w-fit rounded-lg p-0"
:model="items"
:pt="{ item: { class: 'pointer-events-auto' } }"
aria-label="Graph navigation"
>
<template #item="{ item }">
@@ -174,30 +176,65 @@ onUpdated(() => {
@apply overflow-hidden;
}
:deep(.p-breadcrumb) {
width: 100%;
background-color: transparent;
}
:deep(.p-breadcrumb-item) {
@apply flex items-center rounded-lg overflow-hidden;
@apply flex items-center overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-separator) {
display: flex;
padding: 0 var(--p-breadcrumb-item-margin);
}
:deep(.p-breadcrumb-item-link) {
padding: 0
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
:deep(.p-breadcrumb-separator),
:deep(.p-breadcrumb-item) {
@apply h-12;
border-top: 1px solid var(--p-panel-border-color);
border-bottom: 1px solid var(--p-panel-border-color);
background-color: var(--comfy-menu-bg);
}
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
}
:deep(.p-breadcrumb-item:first-child) {
@apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
border-left: 1px solid var(--p-panel-border-color);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
}
}
:deep(.p-breadcrumb-item:last-child) {
@apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
border-right: 1px solid var(--p-panel-border-color);
}
:deep(.p-breadcrumb-item:hover),
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
:deep(.p-breadcrumb-item-link:hover),
:deep(.p-breadcrumb-item-link-menu-visible) {
background-color: color-mix(
in srgb,
var(--fg-color) 10%,
var(--comfy-menu-bg)
) !important;
color: var(--fg-color);
}
</style>
@@ -214,7 +251,7 @@ onUpdated(() => {
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply block;
@apply flex;
}
}
</style>

View File

@@ -6,7 +6,7 @@
showDelay: 512
}"
href="#"
class="p-breadcrumb-item-link cursor-pointer"
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
@@ -15,7 +15,7 @@
}"
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
@@ -26,7 +26,7 @@
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-secondary-bg)'
style: 'background-color: var(--comfy-menu-bg)'
},
itemLink: {
class: 'py-2'
@@ -38,7 +38,7 @@
ref="itemInputRef"
v-model="itemLabel"
class="fixed z-10000 px-2 py-2 text-[.8rem]"
@blur="inputBlur(true)"
@blur="inputBlur(false)"
@click.stop
@keydown.enter="inputBlur(true)"
@keydown.esc="inputBlur(false)"
@@ -240,7 +240,6 @@ const inputBlur = async (doRename: boolean) => {
.p-breadcrumb-item-link {
@apply overflow-hidden;
padding: var(--p-breadcrumb-item-padding);
}
.p-breadcrumb-item-label {

View File

@@ -2,28 +2,46 @@
<!-- Load splitter overlay only after comfyApp is ready. -->
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady && betaMenuEnabled">
<template v-if="!workspaceStore.focusMode" #side-bar-panel>
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady">
<template v-if="showUI && workflowTabsPosition === 'Topbar'" #workflow-tabs>
<div
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
>
<!-- Native drag area for Electron -->
<div
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
class="app-drag fixed top-0 left-0 z-10 h-[var(--comfy-topbar-height)] w-full"
/>
<div class="flex">
<WorkflowTabs />
<TopbarBadges />
</div>
</div>
</template>
<template v-if="showUI" #side-toolbar>
<SideToolbar />
</template>
<template v-if="!workspaceStore.focusMode" #bottom-panel>
<template v-if="showUI" #side-bar-panel>
<div
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto"
>
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template #graph-canvas-panel>
<div class="pointer-events-auto absolute top-0 left-0 w-auto max-w-full">
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
<MiniMap
v-if="comfyAppReady && minimapEnabled"
v-if="comfyAppReady && minimapEnabled && showUI"
class="pointer-events-auto"
/>
</template>
</LiteGraphCanvasSplitterOverlay>
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
<canvas
id="graph-canvas"
ref="canvasRef"
@@ -81,7 +99,9 @@ import {
} from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
@@ -90,7 +110,8 @@ import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
@@ -129,6 +150,7 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
const emit = defineEmits<{
ready: []
@@ -160,6 +182,12 @@ const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const activeSidebarTab = computed(() => {
return workspaceStore.sidebarTab.activeSidebarTab
})
const showUI = computed(
() => !workspaceStore.focusMode && betaMenuEnabled.value
)
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))

View File

@@ -10,7 +10,7 @@
></div>
<ButtonGroup
class="absolute right-2 bottom-2 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>

View File

@@ -75,7 +75,7 @@ let promptInput = findPromptInput()
const previousPromptInput = ref<string | null>(null)
const getPreviousResponseId = (index: number) =>
index > 0 ? parsedHistory.value[index - 1]?.response_id ?? '' : ''
index > 0 ? (parsedHistory.value[index - 1]?.response_id ?? '') : ''
const storePromptInput = () => {
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')

View File

@@ -0,0 +1,32 @@
<template>
<svg
:class="iconClass"
:width="size"
:height="size"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.8193 0.600586C15.1248 0.600586 15.3296 0.70893 15.459 0.881836C15.5914 1.05888 15.6471 1.33774 15.5527 1.66895L14.8037 4.30176C14.7063 4.64386 14.4729 4.97024 14.1641 5.21191C13.8544 5.45415 13.496 5.58984 13.1699 5.58984H13.1689L9.5791 5.59668H7.90625C7.52654 5.59668 7.19496 5.84986 7.09082 6.21289L5.69434 11.0889C5.63007 11.3133 5.66134 11.5534 5.77734 11.7529L5.83203 11.8359C5.99177 12.0491 6.24252 12.1758 6.50977 12.1758H6.51074L8.88281 12.1709H11.4971C11.7643 12.171 11.9541 12.254 12.084 12.3906L12.1357 12.4521C12.2685 12.6295 12.3249 12.9089 12.2305 13.2402L11.4805 15.8721C11.383 16.2144 11.1498 16.5415 10.8408 16.7832C10.5314 17.0252 10.1736 17.161 9.84766 17.1611H9.84668L6.25684 17.168H3.64258C3.33762 17.1679 3.13349 17.0588 3.00391 16.8857C2.87135 16.7087 2.81482 16.43 2.90918 16.0986L3.39551 14.3887C3.46841 14.1327 3.41794 13.8576 3.25879 13.6445V13.6436C3.09901 13.4303 2.84745 13.3037 2.58008 13.3037H1.18066C0.875088 13.3037 0.670398 13.1953 0.541016 13.0225C0.408483 12.8451 0.351891 12.5655 0.446289 12.2344L2.11914 6.38965L2.30371 5.74707V5.74609C2.40139 5.40341 2.63456 5.07671 2.94336 4.83496C3.25302 4.59258 3.61143 4.45705 3.9375 4.45703H5.6123C5.94484 4.45703 6.24083 4.26316 6.37891 3.9707L6.42773 3.83984L6.98145 1.89551C7.07894 1.55317 7.31212 1.22614 7.62109 0.984375C7.93074 0.742127 8.2892 0.606445 8.61523 0.606445H8.61621L12.1982 0.600586H14.8193Z"
:stroke="color"
stroke-width="1"
/>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const {
size = 16,
color = 'currentColor',
class: className
} = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -106,7 +106,7 @@ const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : label ?? ''
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template

View File

@@ -1,30 +1,28 @@
<template>
<div
class="comfyui-logo-wrapper mr-2 flex cursor-pointer items-center justify-center rounded-md p-1"
class="comfy-menu-button-wrapper flex shrink-0 cursor-pointer flex-col items-center justify-center rounded-t-md p-2 transition-colors"
:class="{
'comfyui-logo-menu-visible': menuRef?.visible
}"
:style="{
minWidth: isLargeSidebar ? '4rem' : 'auto'
'comfy-menu-button-active': menuRef?.visible
}"
@click="menuRef?.toggle($event)"
>
<img
src="/assets/images/comfy-logo-mono.svg"
<ComfyLogoTransparent
alt="ComfyUI Logo"
class="comfyui-logo h-7"
@contextmenu="showNativeSystemMenu"
class="comfyui-logo h-[18px] w-[18px]"
/>
<i class="pi pi-angle-down ml-1 text-[10px]" />
<span
v-if="!isSmall"
class="side-bar-button-label mt-1 text-center text-[10px]"
>{{ t('sideToolbar.labels.menu') }}</span
>
</div>
<TieredMenu
ref="menuRef"
:model="translatedItems"
:popup="true"
class="comfy-command-menu"
:class="{
'comfy-command-menu-top': isTopMenu
}"
@show="onMenuShow"
>
<template #item="{ item, props }">
@@ -48,7 +46,7 @@
v-else-if="
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
"
class="p-menubar-item-icon"
class="p-menubar-item-icon text-sm"
:class="item.icon"
/>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
@@ -67,8 +65,6 @@
</a>
</template>
</TieredMenu>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
@@ -78,38 +74,34 @@ import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogoTransparent from '@/components/icons/ComfyLogoTransparent.vue'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { t } = useI18n()
const commandStore = useCommandStore()
const menuItemStore = useMenuItemStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const menuItemsStore = useMenuItemStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const managerState = useManagerState()
const { isSmall = false } = defineProps<{
isSmall?: boolean
}>()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const isLargeSidebar = computed(
() => settingStore.get('Comfy.Sidebar.Size') !== 'small'
)
const isTopMenu = computed(() => settingStore.get('Comfy.UseNewMenu') === 'Top')
const translateMenuItem = (item: MenuItem): MenuItem => {
const label = typeof item.label === 'function' ? item.label() : item.label
@@ -185,7 +177,7 @@ const extraMenuItems = computed(() => [
])
const translatedItems = computed(() => {
const items = menuItemsStore.menuItems.map(translateMenuItem)
const items = menuItemStore.menuItems.map(translateMenuItem)
let helpIndex = items.findIndex((item) => item.key === 'Help')
let helpItem: MenuItem | undefined
@@ -272,16 +264,24 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
return (
item.parentPath &&
(item.parentPath === 'theme' ||
menuItemsStore.menuItemHasActiveStateChildren[item.parentPath])
menuItemStore.menuItemHasActiveStateChildren[item.parentPath])
)
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.comfy-menu-button-wrapper {
width: var(--sidebar-width);
height: var(--sidebar-item-height);
}
:deep(.p-menubar-submenu.dropdown-direction-up) {
@apply top-auto bottom-full flex-col-reverse;
.comfy-menu-button-wrapper:hover {
background: var(--p-button-text-secondary-hover-background);
}
.comfy-menu-button-active,
.comfy-menu-button-active:hover {
background-color: var(--content-hover-bg);
}
.keybinding-tag {
@@ -289,11 +289,6 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
border-color: var(--p-content-border-color);
border-style: solid;
}
.comfyui-logo-menu-visible,
.comfyui-logo-wrapper:hover {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
}
</style>
<style>
@@ -309,22 +304,8 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
transparent
);
}
.comfy-command-menu ul {
background-color: var(--comfy-menu-secondary-bg) !important;
}
.comfy-command-menu-top .p-tieredmenu-submenu {
left: calc(100% + 15px) !important;
top: -4px !important;
}
@media (max-height: 700px) {
.comfy-command-menu .p-tieredmenu-submenu {
@apply absolute max-h-[90vh] overflow-y-auto;
}
/* Help (last) submenu upward offset in compact mode */
.p-tieredmenu-root-list
> .p-tieredmenu-item:last-of-type
.p-tieredmenu-submenu {
top: -188px !important;
}
background-color: var(--comfy-menu-bg) !important;
}
</style>

View File

@@ -1,48 +1,69 @@
<template>
<teleport :to="teleportTarget">
<nav class="side-tool-bar-container" :class="{ 'small-sidebar': isSmall }">
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
<SidebarBottomPanelToggleButton />
<SidebarShortcutsToggleButton />
</div>
</nav>
</teleport>
<div
v-if="selectedTab"
class="sidebar-content-container h-full overflow-x-hidden overflow-y-auto"
<nav
ref="sideToolbarRef"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
:class="{
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
'floating-sidebar': !isConnected,
'overflowing-sidebar': isOverflowing
}"
>
<ExtensionSlot :extension="selectedTab" />
</div>
<div
ref="contentMeasureRef"
:class="
isOverflowing
? 'side-tool-bar-container overflow-y-auto'
: 'flex flex-col h-full'
"
>
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton :is-small="isSmall" />
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
</div>
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
<SidebarLogoutIcon
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { cn } from '@/utils/tailwindUtil'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
@@ -53,16 +74,25 @@ const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const teleportTarget = computed(() =>
settingStore.get('Comfy.Sidebar.Location') === 'left'
? '.comfyui-body-left'
: '.comfyui-body-right'
)
const canvasStore = useCanvasStore()
const sideToolbarRef = ref<HTMLElement>()
const contentMeasureRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isConnected = computed(
() =>
selectedTab.value ||
isOverflowing.value ||
sidebarStyle.value === 'connected'
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
@@ -79,6 +109,68 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
)
return keybinding ? ` (${keybinding.combo.toString()})` : ''
}
const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group pointer-events-auto flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-md')
)
)
const ENTER_OVERFLOW_MARGIN = 20
const EXIT_OVERFLOW_MARGIN = 50
const checkOverflow = debounce(() => {
if (!sideToolbarRef.value || !topToolbarRef.value || !bottomToolbarRef.value)
return
const containerHeight = sideToolbarRef.value.clientHeight
const topHeight = topToolbarRef.value.scrollHeight
const bottomHeight = bottomToolbarRef.value.scrollHeight
const contentHeight = topHeight + bottomHeight
if (isOverflowing.value) {
isOverflowing.value = containerHeight < contentHeight + EXIT_OVERFLOW_MARGIN
} else {
isOverflowing.value =
containerHeight < contentHeight + ENTER_OVERFLOW_MARGIN
}
}, 16)
onMounted(() => {
if (!sideToolbarRef.value) return
const overflowObserver = useResizeObserver(
sideToolbarRef.value,
checkOverflow
)
checkOverflow()
onBeforeUnmount(() => {
overflowObserver.stop()
})
watch(
[isSmall, sidebarLocation],
async () => {
if (canvasStore.canvas) {
if (sidebarLocation.value === 'left') {
await nextTick()
canvasStore.canvas.fpsInfoLocation = [
sideToolbarRef.value?.getBoundingClientRect()?.right,
null
]
} else {
canvasStore.canvas.fpsInfoLocation = null
}
canvasStore.canvas.setDirty(false, true)
}
},
{ immediate: true }
)
})
</script>
<style>
@@ -88,36 +180,64 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
* but need to reference sidebar dimensions for proper positioning.
*/
:root {
--sidebar-width: 4rem;
--sidebar-padding: 8px;
--sidebar-icon-size: 1rem;
--sidebar-default-floating-width: 56px;
--sidebar-default-connected-width: calc(
var(--sidebar-default-floating-width) + var(--sidebar-padding) * 2
);
--sidebar-default-item-height: 56px;
--sidebar-small-floating-width: 48px;
--sidebar-small-connected-width: calc(
var(--sidebar-small-floating-width) + var(--sidebar-padding) * 2
);
--sidebar-small-item-height: 48px;
--sidebar-width: var(--sidebar-default-floating-width);
--sidebar-item-height: var(--sidebar-default-item-height);
}
:root:has(.side-tool-bar-container.small-sidebar) {
--sidebar-width: 2.5rem;
--sidebar-width: var(--sidebar-small-floating-width);
--sidebar-item-height: var(--sidebar-small-item-height);
}
:root:has(.side-tool-bar-container.connected-sidebar) {
--sidebar-width: var(--sidebar-default-connected-width);
}
:root:has(.side-tool-bar-container.small-sidebar.connected-sidebar) {
--sidebar-width: var(--sidebar-small-connected-width);
}
</style>
<style scoped>
.side-tool-bar-container {
display: flex;
flex-direction: column;
align-items: center;
@reference "tailwindcss";
width: var(--sidebar-width);
height: 100%;
background-color: var(--comfy-menu-secondary-bg);
color: var(--fg-color);
box-shadow: var(--bar-shadow);
.floating-sidebar {
padding: var(--sidebar-padding);
}
.side-tool-bar-container.small-sidebar {
--sidebar-width: 2.5rem;
--sidebar-icon-size: 1rem;
.floating-sidebar .sidebar-item-group {
border-color: var(--p-panel-border-color);
}
.side-tool-bar-end {
align-self: flex-end;
margin-top: auto;
.connected-sidebar {
padding: var(--sidebar-padding) 0;
background-color: var(--comfy-menu-bg);
}
.sidebar-item-group {
background-color: var(--comfy-menu-bg);
border: 1px solid transparent;
}
.overflowing-sidebar :deep(.comfy-menu-button-wrapper) {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--comfy-menu-bg);
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<SidebarIcon
label="sideToolbar.labels.console"
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.activePanel == 'terminal'"
@click="bottomPanelStore.toggleBottomPanel"

View File

@@ -3,8 +3,10 @@
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
label="menu.help"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
@@ -16,7 +18,7 @@
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
@@ -29,7 +31,7 @@
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
'small-sidebar': isSmall
}"
/>
</Teleport>
@@ -40,7 +42,7 @@
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
@@ -59,7 +61,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onMounted, toRefs } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -87,6 +89,11 @@ const { showNodeConflictDialog } = useDialogService()
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
const props = defineProps<{
isSmall: boolean
}>()
const { isSmall } = toRefs(props)
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
@@ -97,8 +104,6 @@ const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
helpCenterStore.toggle()
}

View File

@@ -8,10 +8,8 @@
text
:pt="{
root: {
class: `side-bar-button ${
selected
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
class: `side-bar-button p-button-secondary ${
selected ? 'side-bar-button-selected' : ''
}`,
'aria-label': computedTooltip
}
@@ -76,7 +74,7 @@ const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
const overlayValue = computed(() =>
typeof iconBadge === 'function' ? iconBadge() ?? '' : iconBadge
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
@@ -87,9 +85,13 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
font-size: var(--sidebar-icon-size) !important;
}
.side-bar-button-selected {
background-color: var(--content-hover-bg);
color: var(--content-hover-fg);
}
.side-bar-button-selected .side-bar-button-icon {
font-size: var(--sidebar-icon-size) !important;
font-weight: 700;
}
</style>
@@ -98,8 +100,9 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
.side-bar-button {
width: var(--sidebar-width);
height: calc(var(--sidebar-width) + 0.5rem);
height: var(--sidebar-item-height);
border-radius: 0;
flex-shrink: 0;
}
.side-tool-bar-end .side-bar-button {

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