Compare commits

...

22 Commits

Author SHA1 Message Date
sno
a5bb4c2524 Merge branch 'main' into copilot/standardize-triggering-and-docs 2025-10-19 11:53:58 +09: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
snomiao
eae81cf2eb Merge branch 'copilot/standardize-triggering-and-docs' of github.com:Comfy-Org/ComfyUI_frontend into copilot/standardize-triggering-and-docs 2025-10-17 02:46:25 +00:00
copilot-swe-agent[bot]
ae70e458ba refactor: Address code review feedback
- Extract duplicate label checking into addUniqueLabel helper
- Improve comments explaining trigger filtering logic
- Clarify which triggers are shown in Quick Reference vs detailed sections

Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-10-17 01:17:32 +00:00
copilot-swe-agent[bot]
254a03aba0 feat: Improve workflow documentation generation
- Add detection for needs-backport and Release labels
- Deduplicate label triggers in Quick Reference section
- Generate Quick Reference dynamically from workflows
- Show all label-triggered workflows with descriptions

Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-10-17 01:13:05 +00:00
copilot-swe-agent[bot]
dd0ae9e2ba feat: Add automated workflow documentation generation and standardization 2025-10-17 01:08:06 +00:00
copilot-swe-agent[bot]
2527b2fe33 Initial plan 2025-10-17 00:56:07 +00:00
76 changed files with 2224 additions and 252 deletions

View File

@@ -1,5 +1,10 @@
# GitHub Workflows
This directory contains GitHub Actions workflow files that automate various aspects of the ComfyUI frontend development and release process.
> **Note:** This documentation is auto-generated from workflow files. Do not edit manually.
> Run `pnpm workflow:docs` to regenerate.
## Naming Convention
Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.yaml`
@@ -8,14 +13,286 @@ Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.
| 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` |
| `ci-` | Testing, linting, validation | `ci-json-validation.yaml` |
| `pr-` | PR automation (triggered by labels) | `pr-backport.yaml` |
| `release-` | Version management, publishing | `release-branch-create.yaml` |
| `api-` | External API type generation | `api-update-electron-api-types.yaml` |
| `i18n-` | Internationalization updates | `i18n-update-core.yaml` |
| `publish-` | Publishing and deployment | `publish-desktop-ui-on-merge.yaml` |
| `version-` | Version management | `version-bump-desktop-ui.yaml` |
## Quick Reference
For label-triggered workflows, add the corresponding label to a PR to trigger the workflow:
- `New Browser Test Expectations` - Updates Playwright test snapshots when triggered by label or comment
- `Release` - Triggers 3 workflows
- `claude-review` - AI-powered code review triggered by adding the 'claude-review' label to a PR
- `needs-backport` - Automatically backports merged PRs to release branches when 'needs-backport' label is applied
For manual workflows, use the "Run workflow" button in the Actions tab.
## Workflow Details
### CI
#### [`ci-json-validation.yaml`](./ci-json-validation.yaml)
**Name:** CI: JSON Validation
**Description:** Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq
**Triggers:** push
#### [`ci-lint-format.yaml`](./ci-lint-format.yaml)
**Name:** CI: Lint Format
**Description:** Linting and code formatting validation for pull requests
**Triggers:** pull_request
#### [`ci-python-validation.yaml`](./ci-python-validation.yaml)
**Name:** CI: Python Validation
**Description:** Validates Python code in tools/devtools directory
**Triggers:** pull_request, push
#### [`ci-tests-e2e-forks.yaml`](./ci-tests-e2e-forks.yaml)
**Name:** CI: Tests E2E (Deploy for Forks)
**Description:** Deploys test results from forked PRs (forks can't access deployment secrets)
#### [`ci-tests-e2e.yaml`](./ci-tests-e2e.yaml)
**Name:** CI: Tests E2E
**Description:** End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
**Triggers:** pull_request, push
#### [`ci-tests-storybook-forks.yaml`](./ci-tests-storybook-forks.yaml)
**Name:** CI: Tests Storybook (Deploy for Forks)
**Description:** Deploys Storybook previews from forked PRs (forks can't access deployment secrets)
#### [`ci-tests-storybook.yaml`](./ci-tests-storybook.yaml)
**Name:** CI: Tests Storybook
**Description:** Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
**Triggers:** workflow_dispatch (manual), pull_request
#### [`ci-tests-unit.yaml`](./ci-tests-unit.yaml)
**Name:** CI: Tests Unit
**Description:** Unit and component testing with Vitest
**Triggers:** pull_request, push
#### [`ci-workflow-docs.yaml`](./ci-workflow-docs.yaml)
**Name:** CI: Workflow Documentation
**Description:** Validates that workflow documentation is up-to-date with workflow files
**Triggers:** pull_request
### PR
#### [`pr-backport.yaml`](./pr-backport.yaml)
**Name:** PR Backport
**Description:** Automatically backports merged PRs to release branches when 'needs-backport' label is applied
**Triggers:** workflow_dispatch (manual), pull_request_target (closed, labeled)
**Label Triggers:** `needs-backport`
#### [`pr-claude-review.yaml`](./pr-claude-review.yaml)
**Name:** PR: Claude Review
**Description:** AI-powered code review triggered by adding the 'claude-review' label to a PR
**Triggers:** pull_request (labeled)
**Label Triggers:** `claude-review`
#### [`pr-update-playwright-expectations.yaml`](./pr-update-playwright-expectations.yaml)
**Name:** PR: Update Playwright Expectations
**Description:** Updates Playwright test snapshots when triggered by label or comment
**Triggers:** pull_request (labeled), issue_comment (created)
**Label Triggers:** `New Browser Test Expectations`, `/update-playwright`
### RELEASE
#### [`release-branch-create.yaml`](./release-branch-create.yaml)
**Name:** Release Branch Create
**Description:** Creates release branch when version bump PR with 'Release' label is merged
**Triggers:** pull_request (closed)
**Label Triggers:** `Release`
#### [`release-draft-create.yaml`](./release-draft-create.yaml)
**Name:** Release Draft Create
**Description:** Creates GitHub release draft when version bump PR with 'Release' label is merged
**Triggers:** pull_request (closed)
**Label Triggers:** `Release`
#### [`release-npm-types.yaml`](./release-npm-types.yaml)
**Name:** Release NPM Types
**Description:** Manual workflow to publish TypeScript type definitions to npm
**Triggers:** workflow_dispatch (manual)
#### [`release-pypi-dev.yaml`](./release-pypi-dev.yaml)
**Name:** Release PyPI Dev
**Description:** Manual workflow to publish development version to PyPI
**Triggers:** workflow_dispatch (manual)
#### [`release-version-bump.yaml`](./release-version-bump.yaml)
**Name:** Release: Version Bump
**Description:** Manual workflow to increment package version with semantic versioning support
**Triggers:** workflow_dispatch (manual)
### API
#### [`api-update-electron-api-types.yaml`](./api-update-electron-api-types.yaml)
**Name:** Api: Update Electron API Types
**Description:** When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo
**Triggers:** workflow_dispatch (manual)
#### [`api-update-manager-api-types.yaml`](./api-update-manager-api-types.yaml)
**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
**Triggers:** workflow_dispatch (manual)
#### [`api-update-registry-api-types.yaml`](./api-update-registry-api-types.yaml)
**Name:** Api: Update Registry API Types
**Description:** When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
**Triggers:** workflow_dispatch (manual)
### I18N
#### [`i18n-update-core.yaml`](./i18n-update-core.yaml)
**Name:** i18n: Update Core
**Description:** Generates and updates translations for core ComfyUI components using OpenAI
**Triggers:** workflow_dispatch (manual), pull_request (opened, synchronize, reopened)
#### [`i18n-update-custom-nodes.yaml`](./i18n-update-custom-nodes.yaml)
**Name:** i18n Update Custom Nodes
**Description:** Updates translations for custom node repositories using OpenAI
**Triggers:** workflow_dispatch (manual)
#### [`i18n-update-nodes.yaml`](./i18n-update-nodes.yaml)
**Name:** i18n Update Nodes
**Description:** Updates translations for ComfyUI node definitions
**Triggers:** workflow_dispatch (manual)
### PUBLISH
#### [`publish-desktop-ui-on-merge.yaml`](./publish-desktop-ui-on-merge.yaml)
**Name:** Publish Desktop UI on PR Merge
**Description:** Automatically publishes desktop UI package to npm when version bump PR is merged
**Triggers:** pull_request (closed)
**Label Triggers:** `Release`
#### [`publish-desktop-ui.yaml`](./publish-desktop-ui.yaml)
**Name:** Publish Desktop UI
**Description:** Manual workflow to publish desktop UI package to npm with specified version
**Triggers:** workflow_dispatch (manual)
### VERSION
#### [`version-bump-desktop-ui.yaml`](./version-bump-desktop-ui.yaml)
**Name:** Version Bump Desktop UI
**Description:** Manual workflow to increment desktop UI package version with semantic versioning support
**Triggers:** workflow_dispatch (manual)
## 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 more information about GitHub Actions, see:
- [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
- [Workflow syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions)
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).
## Maintaining Workflows
### Adding a New Workflow
1. Create a new workflow file following the naming convention
2. Include `name` and `description` fields at the top of the workflow
3. Run `pnpm workflow:docs` to update this README
4. Commit both the workflow file and updated README
### Best Practices
1. **Always include a description**: Add a `description` field after the `name` field
2. **Use consistent prefixes**: Follow the established prefix conventions
3. **Label-triggered workflows**: For PR automation, use the `pr-` prefix
4. **Document triggers**: Make trigger conditions clear in the workflow description
5. **Keep docs in sync**: Run `pnpm workflow:docs` after any workflow changes

43
.github/workflows/ci-workflow-docs.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: "CI: Workflow Documentation"
description: "Validates that workflow documentation is up-to-date with workflow files"
on:
pull_request:
paths:
- '.github/workflows/*.yaml'
- '.github/workflows/*.yml'
- 'scripts/cicd/generate-workflow-docs.ts'
jobs:
check-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate workflow documentation
run: pnpm workflow:docs
- name: Check if documentation is up-to-date
run: |
if [ -n "$(git status --porcelain .github/workflows/README.md)" ]; then
echo "::error::Workflow documentation is out of date. Please run 'pnpm workflow:docs' and commit the changes."
git diff .github/workflows/README.md
exit 1
else
echo "✓ Workflow documentation is up-to-date"
fi

View File

@@ -1,4 +1,5 @@
name: i18n Update Custom Nodes
description: "Updates translations for custom node repositories using OpenAI"
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: i18n Update Nodes
description: "Updates translations for ComfyUI node definitions"
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: PR Backport
description: "Automatically backports merged PRs to release branches when 'needs-backport' label is applied"
on:
pull_request_target:
@@ -95,41 +96,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 +171,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 +192,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 +202,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 +236,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 +252,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 +275,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,5 +1,6 @@
# Setting test expectation screenshots for Playwright
name: "PR: Update Playwright Expectations"
description: "Updates Playwright test snapshots when triggered by label or comment"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Publish Desktop UI on PR Merge
description: "Automatically publishes desktop UI package to npm when version bump PR is merged"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Publish Desktop UI
description: "Manual workflow to publish desktop UI package to npm with specified version"
on:
workflow_dispatch:
@@ -44,6 +45,7 @@ jobs:
contents: read
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
ENABLE_MINIFY: 'true'
steps:
- name: Validate inputs
env:

View File

@@ -1,4 +1,5 @@
name: Release Branch Create
description: "Creates release branch when version bump PR with 'Release' label is merged"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Release Draft Create
description: "Creates GitHub release draft when version bump PR with 'Release' label is merged"
on:
pull_request:
@@ -55,6 +56,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: Release NPM Types
description: "Manual workflow to publish TypeScript type definitions to npm"
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: Release PyPI Dev
description: "Manual workflow to publish development version to PyPI"
on:
workflow_dispatch:
@@ -44,6 +45,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

@@ -15,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:
@@ -26,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
@@ -59,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,4 +1,5 @@
name: Version Bump Desktop UI
description: "Manual workflow to increment desktop UI 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-desktop-ui:
@@ -26,8 +32,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 +87,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

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

@@ -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: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 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: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -254,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.30.0",
"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",
@@ -41,6 +43,7 @@
"test:browser": "pnpm exec nx e2e",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"workflow:docs": "tsx scripts/cicd/generate-workflow-docs.ts",
"zipdist": "node scripts/zipdist.js"
},
"devDependencies": {
@@ -86,9 +89,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:",
@@ -109,6 +115,7 @@
"vue-component-type-helpers": "catalog:",
"vue-eslint-parser": "catalog:",
"vue-tsc": "catalog:",
"yaml": "catalog:",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:"
},

View File

@@ -159,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);
@@ -209,6 +210,7 @@
--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);
@@ -250,8 +252,9 @@
@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);

75
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
@@ -195,6 +201,9 @@ catalogs:
prettier:
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
@@ -270,6 +279,9 @@ catalogs:
vuefire:
specifier: ^3.2.1
version: 3.2.1
yaml:
specifier: ^2.8.1
version: 2.8.1
yjs:
specifier: ^13.6.27
version: 13.6.27
@@ -585,15 +597,24 @@ 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.6.2
pretty-bytes:
specifier: 'catalog:'
version: 7.1.0
rollup-plugin-visualizer:
specifier: 'catalog:'
version: 6.0.4(rollup@4.22.4)
@@ -647,13 +668,16 @@ 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))
vue-tsc:
specifier: 'catalog:'
version: 3.0.7(typescript@5.9.2)
yaml:
specifier: 'catalog:'
version: 2.8.1
zip-dir:
specifier: ^2.0.0
version: 2.0.0
@@ -4892,9 +4916,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 +6020,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 +6367,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}
@@ -6372,6 +6384,10 @@ packages:
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 +7489,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==}
@@ -12585,10 +12598,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 +13892,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.8: {}
nanoid@5.1.5: {}
napi-postinstall@0.3.3: {}
@@ -14264,14 +14271,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 +14296,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
@@ -14309,6 +14310,8 @@ snapshots:
prettier@3.6.2: {}
pretty-bytes@7.1.0: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -15321,7 +15324,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 +15676,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 +15741,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.6.2
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
@@ -91,6 +94,7 @@ catalog:
vue-router: ^4.4.3
vue-tsc: ^3.0.7
vuefire: ^3.2.1
yaml: ^2.8.1
yjs: ^13.6.27
zod: ^3.23.8
zod-to-json-schema: ^3.24.1

View File

@@ -0,0 +1,93 @@
// @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 application bundles',
patterns: [/^index-.*\.js$/],
order: 1
},
{
name: 'Core Views',
description: 'Major application views and screens',
patterns: [/GraphView-.*\.js$/, /UserSelectView-.*\.js$/],
order: 2
},
{
name: 'UI Panels',
description: 'Settings and configuration panels',
patterns: [/.*Panel-.*\.js$/],
order: 3
},
{
name: 'UI Components',
description: 'Reusable UI components',
patterns: [/Avatar-.*\.js$/, /Badge-.*\.js$/],
order: 4
},
{
name: 'Services',
description: 'Business logic and services',
patterns: [/.*Service-.*\.js$/, /.*Store-.*\.js$/],
order: 5
},
{
name: 'Utilities',
description: 'Helper functions and utilities',
patterns: [/.*[Uu]til.*\.js$/],
order: 6
},
{
name: 'Other',
description: 'Uncategorized bundles',
patterns: [/.*/], // Catch-all pattern
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,450 @@
#!/usr/bin/env tsx
/**
* Generate workflow documentation from GitHub Actions workflow files
*
* This script:
* 1. Scans all workflow YAML files in .github/workflows
* 2. Extracts metadata (name, description, triggers, labels)
* 3. Updates the workflows README.md with current information
*/
import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { parse } from 'yaml'
interface WorkflowMetadata {
filename: string
name: string
description?: string
prefix: string
triggers: string[]
labelTriggers: string[]
}
interface WorkflowsByPrefix {
[prefix: string]: {
description: string
workflows: WorkflowMetadata[]
}
}
const WORKFLOWS_DIR = join(process.cwd(), '.github/workflows')
const README_PATH = join(WORKFLOWS_DIR, 'README.md')
// Category descriptions for workflow prefixes
const PREFIX_DESCRIPTIONS: Record<string, string> = {
'ci-': 'Testing, linting, validation',
'release-': 'Version management, publishing',
'pr-': 'PR automation (triggered by labels)',
'api-': 'External API type generation',
'i18n-': 'Internationalization updates',
'publish-': 'Publishing and deployment',
'version-': 'Version management'
}
/**
* Add a label to the list if it's not already present
*/
function addUniqueLabel(labels: string[], label: string): void {
if (!labels.includes(label)) {
labels.push(label)
}
}
/**
* Extract label triggers from workflow content
*/
function extractLabelTriggers(content: string, workflowData: any): string[] {
const labels: string[] = []
// Check for label_trigger in anthropics/claude-code-action
const labelTriggerMatch = content.match(/label_trigger:\s*["']([^"']+)["']/i)
if (labelTriggerMatch) {
labels.push(labelTriggerMatch[1])
}
// Check for github.event.label.name == 'label-name' pattern
const labelNameMatches = content.matchAll(
/github\.event\.label\.name\s*==\s*['"]([^'"]+)['"]/gi
)
for (const match of labelNameMatches) {
addUniqueLabel(labels, match[1])
}
// Check for contains(github.event.pull_request.labels.*.name, 'label-name') pattern
const containsLabelMatches = content.matchAll(
/contains\(github\.event\.pull_request\.labels\.\*\.name,\s*['"]([^'"]+)['"]\)/gi
)
for (const match of containsLabelMatches) {
addUniqueLabel(labels, match[1])
}
// Check for startsWith patterns with comment commands (e.g., /update-playwright)
// These are included as they can trigger workflows through PR comments
const labelCommentMatches = content.matchAll(
/startsWith\(github\.event\.comment\.body,\s*['"]([^'"]+)['"]\)/gi
)
for (const match of labelCommentMatches) {
const command = match[1]
if (command) {
addUniqueLabel(labels, command)
}
}
return labels
}
/**
* Extract trigger information from workflow
*/
function extractTriggers(workflowData: any): string[] {
const triggers: string[] = []
const on = workflowData.on
if (!on) return triggers
if (typeof on === 'string') {
triggers.push(on)
} else if (Array.isArray(on)) {
triggers.push(...on)
} else if (typeof on === 'object') {
// Handle workflow_dispatch
if (on.workflow_dispatch !== undefined) {
triggers.push('workflow_dispatch (manual)')
}
// Handle pull_request with types
if (on.pull_request) {
if (typeof on.pull_request === 'object' && on.pull_request.types) {
const types = Array.isArray(on.pull_request.types)
? on.pull_request.types.join(', ')
: on.pull_request.types
triggers.push(`pull_request (${types})`)
} else {
triggers.push('pull_request')
}
}
// Handle pull_request_target
if (on.pull_request_target) {
if (
typeof on.pull_request_target === 'object' &&
on.pull_request_target.types
) {
const types = Array.isArray(on.pull_request_target.types)
? on.pull_request_target.types.join(', ')
: on.pull_request_target.types
triggers.push(`pull_request_target (${types})`)
} else {
triggers.push('pull_request_target')
}
}
// Handle push
if (on.push) {
triggers.push('push')
}
// Handle schedule
if (on.schedule) {
triggers.push('schedule')
}
// Handle issue_comment
if (on.issue_comment) {
if (typeof on.issue_comment === 'object' && on.issue_comment.types) {
const types = Array.isArray(on.issue_comment.types)
? on.issue_comment.types.join(', ')
: on.issue_comment.types
triggers.push(`issue_comment (${types})`)
} else {
triggers.push('issue_comment')
}
}
}
return triggers
}
/**
* Parse a single workflow file and extract metadata
*/
function parseWorkflowFile(filename: string): WorkflowMetadata | null {
try {
const filepath = join(WORKFLOWS_DIR, filename)
const content = readFileSync(filepath, 'utf-8')
const workflowData = parse(content)
if (!workflowData || !workflowData.name) {
console.warn(`Skipping ${filename}: no name field`)
return null
}
// Determine prefix from filename
const prefixMatch = filename.match(/^([a-z0-9]+)-/)
const prefix = prefixMatch ? prefixMatch[1] + '-' : 'other-'
const metadata: WorkflowMetadata = {
filename,
name: workflowData.name,
description: workflowData.description,
prefix,
triggers: extractTriggers(workflowData),
labelTriggers: extractLabelTriggers(content, workflowData)
}
return metadata
} catch (error) {
console.error(`Error parsing ${filename}:`, error)
return null
}
}
/**
* Group workflows by prefix
*/
function groupWorkflowsByPrefix(
workflows: WorkflowMetadata[]
): WorkflowsByPrefix {
const grouped: WorkflowsByPrefix = {}
for (const workflow of workflows) {
if (!grouped[workflow.prefix]) {
grouped[workflow.prefix] = {
description: PREFIX_DESCRIPTIONS[workflow.prefix] || 'Other workflows',
workflows: []
}
}
grouped[workflow.prefix].workflows.push(workflow)
}
// Sort workflows within each group by filename
for (const prefix in grouped) {
grouped[prefix].workflows.sort((a, b) =>
a.filename.localeCompare(b.filename)
)
}
return grouped
}
/**
* Generate markdown table for workflow categories
*/
function generateCategoryTable(grouped: WorkflowsByPrefix): string {
const prefixOrder = [
'ci-',
'pr-',
'release-',
'api-',
'i18n-',
'publish-',
'version-',
'other-'
]
let table =
'| Prefix | Purpose | Example |\n'
table +=
'| ---------- | ----------------------------------- | ------------------------------------ |\n'
for (const prefix of prefixOrder) {
if (grouped[prefix]) {
const example = grouped[prefix].workflows[0]?.filename || ''
const purpose = grouped[prefix].description
table += `| \`${prefix}\` | ${purpose} | \`${example}\` |\n`
}
}
return table
}
/**
* Generate detailed workflow list with descriptions
*/
function generateWorkflowList(grouped: WorkflowsByPrefix): string {
const prefixOrder = [
'ci-',
'pr-',
'release-',
'api-',
'i18n-',
'publish-',
'version-',
'other-'
]
let markdown = ''
for (const prefix of prefixOrder) {
if (!grouped[prefix]) continue
const category = grouped[prefix]
const prefixName =
prefix === 'other-'
? 'Other Workflows'
: prefix.replace('-', '').toUpperCase()
markdown += `\n### ${prefixName}\n\n`
for (const workflow of category.workflows) {
markdown += `#### [\`${workflow.filename}\`](./${workflow.filename})\n\n`
markdown += `**Name:** ${workflow.name}\n\n`
if (workflow.description) {
markdown += `**Description:** ${workflow.description}\n\n`
}
if (workflow.triggers.length > 0) {
markdown += `**Triggers:** ${workflow.triggers.join(', ')}\n\n`
}
if (workflow.labelTriggers.length > 0) {
markdown += `**Label Triggers:** \`${workflow.labelTriggers.join('`, `')}\`\n\n`
}
}
}
return markdown
}
/**
* Generate quick reference for label-triggered workflows
*/
function generateQuickReference(grouped: WorkflowsByPrefix): string {
const allWorkflows = Object.values(grouped).flatMap((g) => g.workflows)
const labelWorkflows = allWorkflows.filter((w) => w.labelTriggers.length > 0)
if (labelWorkflows.length === 0) {
return ''
}
// Group workflows by label to avoid duplicates
const labelMap = new Map<string, string[]>()
for (const workflow of labelWorkflows) {
for (const label of workflow.labelTriggers) {
// Filter out comment-based triggers (commands starting with /) from Quick Reference
// These are still shown in detailed workflow sections with full context
if (label.startsWith('/')) {
continue
}
const description = workflow.description || workflow.name
if (!labelMap.has(label)) {
labelMap.set(label, [])
}
labelMap.get(label)!.push(description)
}
}
let markdown = '## Quick Reference\n\n'
markdown +=
'For label-triggered workflows, add the corresponding label to a PR to trigger the workflow:\n'
// Sort labels alphabetically for consistency
const sortedLabels = Array.from(labelMap.keys()).sort()
for (const label of sortedLabels) {
const descriptions = labelMap.get(label)!
// Use the first description, or note if multiple workflows share the same label
const description =
descriptions.length === 1
? descriptions[0]
: `Triggers ${descriptions.length} workflows`
markdown += `- \`${label}\` - ${description}\n`
}
markdown +=
'\nFor manual workflows, use the "Run workflow" button in the Actions tab.\n'
return markdown
}
/**
* Generate the complete README content
*/
function generateReadme(grouped: WorkflowsByPrefix): string {
const categoryTable = generateCategoryTable(grouped)
const quickReference = generateQuickReference(grouped)
const workflowList = generateWorkflowList(grouped)
return `# GitHub Workflows
This directory contains GitHub Actions workflow files that automate various aspects of the ComfyUI frontend development and release process.
> **Note:** This documentation is auto-generated from workflow files. Do not edit manually.
> Run \`pnpm workflow:docs\` to regenerate.
## Naming Convention
Workflow files follow a consistent naming pattern: \`<prefix>-<descriptive-name>.yaml\`
### Category Prefixes
${categoryTable}
${quickReference}
## Workflow Details
${workflowList}
## Documentation
For more information about GitHub Actions, see:
- [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
- [Workflow syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions)
## Maintaining Workflows
### Adding a New Workflow
1. Create a new workflow file following the naming convention
2. Include \`name\` and \`description\` fields at the top of the workflow
3. Run \`pnpm workflow:docs\` to update this README
4. Commit both the workflow file and updated README
### Best Practices
1. **Always include a description**: Add a \`description\` field after the \`name\` field
2. **Use consistent prefixes**: Follow the established prefix conventions
3. **Label-triggered workflows**: For PR automation, use the \`pr-\` prefix
4. **Document triggers**: Make trigger conditions clear in the workflow description
5. **Keep docs in sync**: Run \`pnpm workflow:docs\` after any workflow changes
`
}
/**
* Main function
*/
function main() {
// Read all workflow files
const files = readdirSync(WORKFLOWS_DIR).filter(
(f) => f.endsWith('.yaml') || f.endsWith('.yml')
)
const workflowFiles = files.filter((f) => f !== 'README.md')
// Parse each workflow
const workflows: WorkflowMetadata[] = []
for (const filename of workflowFiles) {
const metadata = parseWorkflowFile(filename)
if (metadata) {
workflows.push(metadata)
}
}
// Group workflows by prefix
const grouped = groupWorkflowsByPrefix(workflows)
// Generate README
const readme = generateReadme(grouped)
writeFileSync(README_PATH, readme, 'utf-8')
// Show label-triggered workflows for validation
const labelWorkflows = workflows.filter((w) => w.labelTriggers.length > 0)
if (labelWorkflows.length > 0 && process.env.VERBOSE) {
for (const workflow of labelWorkflows) {
console.warn(
`Label-triggered: ${workflow.name}: ${workflow.labelTriggers.join(', ')}`
)
}
}
}
main()

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`))
}

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

@@ -0,0 +1,162 @@
// @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} SizeResult
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
/**
* @typedef {SizeResult & { file: string, category?: string }} BundleResult
*/
const currDir = path.resolve('temp/size')
const prevDir = path.resolve('temp/size-prev')
let output = '## Bundle Size Report\n\n'
const sizeHeaders = ['Size', 'Gzip', 'Brotli']
run()
/**
* Main function to generate 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)
}
await renderFiles()
process.stdout.write(output)
}
/**
* Renders file sizes and diffs between current and previous versions
*/
async function renderFiles() {
/**
* @param {string[]} files
* @returns {string[]}
*/
const filterFiles = (files) => files.filter((file) => file.endsWith('.json'))
const curr = filterFiles(await readdir(currDir))
const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : []
const fileList = new Set([...curr, ...prev])
// Group bundles by category
/** @type {Map<string, Array<{fileName: string, curr: BundleResult | undefined, prev: BundleResult | undefined}>>} */
const bundlesByCategory = new Map()
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 || ''
const category = curr?.category || prev?.category || 'Other'
if (!bundlesByCategory.has(category)) {
bundlesByCategory.set(category, [])
}
// @ts-expect-error - get is valid
bundlesByCategory.get(category).push({ fileName, curr, prev })
}
// Sort categories by their order
const sortedCategories = Array.from(bundlesByCategory.keys()).sort((a, b) => {
const metaA = getCategoryMetadata(a)
const metaB = getCategoryMetadata(b)
return (metaA?.order ?? 99) - (metaB?.order ?? 99)
})
let totalSize = 0
let totalCount = 0
// Render each category
for (const category of sortedCategories) {
const bundles = bundlesByCategory.get(category) || []
if (bundles.length === 0) continue
const categoryMeta = getCategoryMetadata(category)
output += `### ${category}\n\n`
if (categoryMeta?.description) {
output += `_${categoryMeta.description}_\n\n`
}
const rows = []
let categorySize = 0
for (const { fileName, curr, prev } of bundles) {
if (!curr) {
// File was deleted
rows.push([`~~${fileName}~~`])
} else {
rows.push([
fileName,
`${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`,
`${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`,
`${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}`
])
categorySize += curr.size
totalSize += curr.size
totalCount++
}
}
// Sort rows by file name within category
rows.sort((a, b) => {
const fileA = a[0].replace(/~~/g, '')
const fileB = b[0].replace(/~~/g, '')
return fileA.localeCompare(fileB)
})
output += markdownTable([['File', ...sizeHeaders], ...rows])
output += `\n\n**Category Total:** ${prettyBytes(categorySize)}\n\n`
}
// Add overall summary
if (totalCount > 0) {
output += '---\n\n'
output += `**Overall Total Size:** ${prettyBytes(totalSize)}\n`
output += `**Total Bundle Count:** ${totalCount}\n`
}
}
/**
* Imports JSON data from a specified path
*
* @template T
* @param {string} filePath - Path to the JSON file
* @returns {Promise<T | undefined>} The JSON content or undefined if the file does not exist
*/
async function importJSON(filePath) {
if (!existsSync(filePath)) return undefined
return (await import(filePath, { with: { type: 'json' } })).default
}
/**
* Calculates the difference between the current and previous sizes
*
* @param {number} curr - The current size
* @param {number} [prev] - The previous size
* @returns {string} The difference in pretty format
*/
function getDiff(curr, prev) {
if (prev === undefined) return ''
const diff = curr - prev
if (diff === 0) return ''
const sign = diff > 0 ? '+' : ''
return ` (**${sign}${prettyBytes(diff)}**)`
}

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

@@ -1,5 +1,6 @@
<template>
<div
ref="containerRef"
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
@@ -13,7 +14,6 @@
@mousedown="whileMouseDown($event, () => scroll(-1))"
/>
<ScrollPanel
ref="scrollPanelRef"
class="no-drag overflow-hidden"
:pt:content="{
class: 'p-0 w-full flex',
@@ -74,6 +74,7 @@ import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
@@ -108,7 +109,7 @@ const workflowService = useWorkflowService()
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const scrollPanelRef = ref()
const containerRef = ref<HTMLElement | null>(null)
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
@@ -221,26 +222,34 @@ const handleWheel = (event: WheelEvent) => {
})
}
const scrollContent = computed(
() =>
(containerRef.value?.querySelector(
'.p-scrollpanel-content'
) as HTMLElement | null) ?? null
)
const scroll = (direction: number) => {
const scrollElement = scrollPanelRef.value.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
scrollElement.scrollBy({ left: direction * 20 })
const el = scrollContent.value
if (!el) return
el.scrollBy({ left: direction * 20 })
}
const ensureActiveTabVisible = async () => {
const ensureActiveTabVisible = async (
options: { waitForDom?: boolean } = {}
) => {
if (!selectedWorkflow.value) return
await nextTick()
if (options.waitForDom !== false) {
await nextTick()
}
const scrollPanelElement = scrollPanelRef.value?.$el as
| HTMLElement
| undefined
if (!scrollPanelElement) return
const containerElement = containerRef.value
if (!containerElement) return
const activeTabElement = scrollPanelElement.querySelector(
const activeTabElement = containerElement.querySelector(
'.p-togglebutton-checked'
) as HTMLElement | null
)
if (!activeTabElement) return
activeTabElement.scrollIntoView({ block: 'nearest', inline: 'nearest' })
@@ -255,38 +264,56 @@ watch(
{ immediate: true }
)
const scrollContent = computed(
() =>
scrollPanelRef.value?.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let overflowWatch: ReturnType<typeof watch> | null = null
watch(scrollContent, (value) => {
const scrollState = useScroll(value)
let stopArrivedWatch: WatchStopHandle | null = null
let stopOverflowWatch: WatchStopHandle | null = null
watch(scrollState.arrivedState, () => {
leftArrowEnabled.value = !scrollState.arrivedState.left
rightArrowEnabled.value = !scrollState.arrivedState.right
})
watch(
scrollContent,
(el, _prev, onCleanup) => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
overflowObserver?.dispose()
overflowWatch?.stop()
overflowObserver = useOverflowObserver(value)
overflowWatch = watch(
overflowObserver.isOverflowing,
(value) => {
showOverflowArrows.value = value
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible()
})
},
{ immediate: true }
)
})
if (!el) return
const scrollState = useScroll(el)
stopArrivedWatch = watch(
[
() => scrollState.arrivedState.left,
() => scrollState.arrivedState.right
],
([atLeft, atRight]) => {
leftArrowEnabled.value = !atLeft
rightArrowEnabled.value = !atRight
},
{ immediate: true }
)
overflowObserver = useOverflowObserver(el)
stopOverflowWatch = watch(
overflowObserver.isOverflowing,
(isOverflow) => {
showOverflowArrows.value = isOverflow
if (!isOverflow) return
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible({ waitForDom: false })
})
},
{ immediate: true }
)
onCleanup(() => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
})
},
{ immediate: true }
)
onUpdated(() => {
if (!overflowObserver?.disposed.value) {

View File

@@ -1,9 +1,12 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
@@ -122,6 +125,47 @@ export const useFirebaseAuthActions = () => {
reportError
)
/**
* Recovery strategy for Firebase auth/requires-recent-login errors.
* Prompts user to reauthenticate and retries the operation after successful login.
*/
const createReauthenticationRecovery = <
TArgs extends unknown[],
TReturn
>(): ErrorRecoveryStrategy<TArgs, TReturn> => {
const dialogService = useDialogService()
return {
shouldHandle: (error: unknown) =>
error instanceof FirebaseError &&
error.code === AuthErrorCodes.CREDENTIAL_TOO_OLD_LOGIN_AGAIN,
recover: async (
_error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => {
const confirmed = await dialogService.confirm({
title: t('auth.reauthRequired.title'),
message: t('auth.reauthRequired.message'),
type: 'default'
})
if (!confirmed) {
return
}
await authStore.logout()
const signedIn = await dialogService.showSignInDialog()
if (signedIn) {
await retry(...args)
}
}
}
}
const updatePassword = wrapWithErrorHandlingAsync(
async (newPassword: string) => {
await authStore.updatePassword(newPassword)
@@ -132,18 +176,25 @@ export const useFirebaseAuthActions = () => {
life: 5000
})
},
reportError
reportError,
undefined,
[createReauthenticationRecovery<[string], void>()]
)
const deleteAccount = wrapWithErrorHandlingAsync(async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
}, reportError)
const deleteAccount = wrapWithErrorHandlingAsync(
async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
},
reportError,
undefined,
[createReauthenticationRecovery<[], void>()]
)
return {
logout,

View File

@@ -5,7 +5,11 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
import {
promoteRecommendedWidgets,
tryToggleWidgetPromotion
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { t } from '@/i18n'
import {
LGraphEventMode,
@@ -888,7 +892,7 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',
icon: 'icon-[lucide--shrink]',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
@@ -916,10 +920,9 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'pi pi-sitemap',
icon: 'icon-[lucide--expand]',
label: 'Unpack the selected Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
versionAdded: '1.26.3',
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
@@ -931,6 +934,20 @@ export function useCoreCommands(): ComfyCommand[] {
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Comfy.Graph.EditSubgraphWidgets',
label: 'Edit Subgraph Widgets',
icon: 'icon-[lucide--settings-2]',
versionAdded: '1.28.5',
function: showSubgraphNodeDialog
},
{
id: 'Comfy.Graph.ToggleWidgetPromotion',
icon: 'icon-[lucide--arrow-left-right]',
label: 'Toggle promotion of hovered widget',
versionAdded: '1.30.1',
function: tryToggleWidgetPromotion
},
{
id: 'Comfy.OpenManagerDialog',
icon: 'mdi mdi-puzzle-outline',

View File

@@ -1,6 +1,54 @@
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
/**
* Strategy for recovering from specific error conditions.
* Allows operations to be retried after resolving the error condition.
*
* @template TArgs - The argument types of the operation to be retried
* @template TReturn - The return type of the operation
*
* @example
* ```typescript
* const networkRecovery: ErrorRecoveryStrategy = {
* shouldHandle: (error) => error instanceof NetworkError,
* recover: async (error, retry) => {
* await waitForNetwork()
* await retry()
* }
* }
* ```
*/
export interface ErrorRecoveryStrategy<
TArgs extends unknown[] = unknown[],
TReturn = unknown
> {
/**
* Determines if this strategy should handle the given error.
* @param error - The error to check
* @returns true if this strategy can handle the error
*/
shouldHandle: (error: unknown) => boolean
/**
* Attempts to recover from the error and retry the operation.
* This function is responsible for:
* 1. Resolving the error condition (e.g., reauthentication, network reconnect)
* 2. Calling retry() to re-execute the original operation
* 3. Handling the retry result (success or failure)
*
* @param error - The error that occurred
* @param retry - Function to retry the original operation
* @param args - Original arguments passed to the operation
* @returns Promise that resolves when recovery completes (whether successful or not)
*/
recover: (
error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => Promise<void>
}
export function useErrorHandling() {
const toast = useToastStore()
const toastErrorHandler = (error: unknown) => {
@@ -13,9 +61,9 @@ export function useErrorHandling() {
}
const wrapWithErrorHandling =
<TArgs extends any[], TReturn>(
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => TReturn,
errorHandler?: (error: any) => void,
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void
) =>
(...args: TArgs): TReturn | undefined => {
@@ -29,15 +77,27 @@ export function useErrorHandling() {
}
const wrapWithErrorHandlingAsync =
<TArgs extends any[], TReturn>(
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: any) => void,
finallyHandler?: () => void
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void,
recoveryStrategies: ErrorRecoveryStrategy<TArgs, TReturn>[] = []
) =>
async (...args: TArgs): Promise<TReturn | undefined> => {
try {
return await action(...args)
} catch (e) {
for (const strategy of recoveryStrategies) {
if (strategy.shouldHandle(e)) {
try {
await strategy.recover(e, action, args)
return
} catch (recoveryError) {
console.error('Recovery strategy failed:', recoveryError)
}
}
}
;(errorHandler ?? toastErrorHandler)(e)
} finally {
finallyHandler?.()

View File

@@ -1,11 +1,14 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -63,7 +66,15 @@ function getParentNodes(): SubgraphNode[] {
//or by adding a new event for parent listeners to collect from
const { navigationStack } = useSubgraphNavigationStore()
const subgraph = navigationStack.at(-1)
if (!subgraph) throw new Error("Can't promote widget when not in subgraph")
if (!subgraph) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
return parentGraph.nodes.filter(
(node): node is SubgraphNode =>
@@ -96,6 +107,21 @@ export function addWidgetPromotionOptions(
})
}
}
export function tryToggleWidgetPromotion() {
const canvas = useCanvasStore().getCanvas()
const [x, y] = canvas.graph_mouse
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
if (!node) return
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',

View File

@@ -1,16 +1,12 @@
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudBadge',
// Only show badge when running in cloud environment
topbarBadges: isCloud
? [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
: undefined
topbarBadges: [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
})

View File

@@ -253,7 +253,7 @@ app.registerExtension({
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.options.canvasOnly = true
audioUIWidget.options.canvasOnly = false
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
@@ -376,10 +376,12 @@ app.registerExtension({
mediaRecorder.stop()
}
},
{ serialize: false, canvasOnly: true }
{ serialize: false, canvasOnly: false }
)
recordWidget.label = t('g.startRecording')
// Override the type for Vue rendering while keeping 'button' for LiteGraph
recordWidget.type = 'audiorecord'
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {

View File

@@ -151,7 +151,7 @@ interface DrawTitleTextOptions extends DrawTitleOptions {
default_title_color: string
}
interface DrawTitleBoxOptions extends DrawTitleOptions {
export interface DrawTitleBoxOptions extends DrawTitleOptions {
box_size?: number
}

View File

@@ -2,6 +2,7 @@ import type { BaseLGraph, LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { DrawTitleBoxOptions } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
@@ -9,6 +10,7 @@ import type {
ISubgraphInput,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
ISlotType,
@@ -32,6 +34,10 @@ import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
@@ -555,6 +561,31 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
}
override drawTitleBox(
ctx: CanvasRenderingContext2D,
{
scale,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
box_size = 10
}: DrawTitleBoxOptions
): void {
if (this.onDrawTitleBox) {
this.onDrawTitleBox(ctx, title_height, this.renderingSize, scale)
return
}
ctx.save()
ctx.fillStyle = '#3b82f6'
ctx.beginPath()
ctx.roundRect(6, -24.5, 22, 20, 5)
ctx.fill()
if (!low_quality) {
ctx.translate(25, 23)
ctx.scale(-1.5, 1.5)
ctx.drawImage(workflowSvg, 0, -title_height, box_size, box_size)
}
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the

View File

@@ -79,7 +79,6 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IAudioRecordWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -228,11 +227,6 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
type: 'audiorecord'
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'

View File

@@ -128,6 +128,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Edit Subgraph Widgets"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Exit Subgraph"
},
@@ -137,6 +140,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Group Selected Nodes"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Toggle promotion of hovered widget"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},

View File

@@ -1102,6 +1102,7 @@
"overwriteBlueprintTitle": "Overwrite existing blueprint?",
"overwriteBlueprint": "Saving will overwrite the current blueprint with your changes",
"blueprintName": "Subgraph name",
"promoteOutsideSubgraph": "Can't promote widget when not in subgraph",
"publish": "Publish Subgraph",
"publishSuccess": "Saved to Nodes Library",
"publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"",
@@ -1212,9 +1213,11 @@
"Export": "Export",
"Export (API)": "Export (API)",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Edit Subgraph Widgets": "Edit Subgraph Widgets",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Toggle promotion of hovered widget": "Toggle promotion of hovered widget",
"Unpack the selected Subgraph": "Unpack the selected Subgraph",
"Convert selected nodes to group node": "Convert selected nodes to group node",
"Manage group nodes": "Manage group nodes",
@@ -1863,6 +1866,12 @@
"success": "Account Deleted",
"successDetail": "Your account has been successfully deleted."
},
"reauthRequired": {
"title": "Re-authentication Required",
"message": "For security reasons, this action requires you to sign in again. Would you like to proceed?",
"confirm": "Sign In Again",
"cancel": "Cancel"
},
"loginButton": {
"tooltipHelp": "Login to be able to use \"API Nodes\"",
"tooltipLearnMore": "Learn more..."

View File

@@ -1532,10 +1532,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -10373,6 +10375,11 @@
"type": {
"name": "type"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SkipLayerGuidanceDiT": {

View File

@@ -17,4 +17,4 @@ const DISTRIBUTION: Distribution = __DISTRIBUTION__
/** Distribution type checks */
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
export const isCloud = DISTRIBUTION === 'cloud'
// export const isLocalhost = !isDesktop && !isCloud
// export const isLocalhost = DISTRIBUTION === 'localhost' || (!isDesktop && !isCloud)

View File

@@ -2,7 +2,7 @@
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container absolute right-2 bottom-[66px] z-1000 flex"
class="minimap-main-container absolute right-0 bottom-[58px] z-1000 flex"
>
<MiniMapPanel
v-if="showOptionsPanel"

View File

@@ -2,7 +2,11 @@ import { useThrottleFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphNode,
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
@@ -14,6 +18,7 @@ interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
onTrigger?: (event: LGraphTriggerEvent) => void
}
export function useMinimapGraph(
@@ -53,7 +58,8 @@ export function useMinimapGraph(
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
onConnectionChange: g.onConnectionChange,
onTrigger: g.onTrigger
}
originalCallbacksMap.set(g.id, originalCallbacks)
@@ -72,6 +78,22 @@ export function useMinimapGraph(
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChangedThrottled()
}
g.onTrigger = function (event: LGraphTriggerEvent) {
originalCallbacks.onTrigger?.call(this, event)
// Listen for visual property changes that affect minimap rendering
if (
event.type === 'node:property:changed' &&
(event.property === 'mode' ||
event.property === 'bgcolor' ||
event.property === 'color')
) {
// Invalidate cache for this node to force redraw
nodeStatesCache.delete(String(event.nodeId))
void handleGraphChangedThrottled()
}
}
}
const cleanupEventListeners = (oldGraph?: LGraph) => {
@@ -89,6 +111,7 @@ export function useMinimapGraph(
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
g.onTrigger = originalCallbacks.onTrigger
originalCallbacksMap.delete(g.id)
}

View File

@@ -37,6 +37,7 @@
</IconButton>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"

View File

@@ -33,16 +33,20 @@ const selectedItems = computed(() => {
})
const chevronClass = computed(() =>
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
'rotate-180': props.isOpen
})
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-button-icon',
{
'rotate-180': props.isOpen
}
)
)
const theButtonStyle = computed(() =>
cn('bg-transparent border-0 outline-none text-zinc-400', {
cn('bg-transparent border-0 outline-none text-text-secondary', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
'cursor-not-allowed': props.disabled,
'text-text-primary': selectedItems.value.length > 0
})
)
</script>

View File

@@ -1,24 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
import type {
AudioRecordInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
const {
name,
default: defaultValue = '',
options = {}
} = inputSpec as AudioRecordInputSpec
const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
serialize: true,
...options
}) as IAudioRecordWidget
return widget
}
}

View File

@@ -1,5 +1,6 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -23,10 +24,6 @@ export function getAudioUrlFromPath(
return api.apiURL(getResourceURL(subfolder, filename, type))
}
function getRandParam() {
return '&rand=' + Math.random()
}
export function getResourceURL(
subfolder: string,
filename: string,
@@ -36,7 +33,7 @@ export function getResourceURL(
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
getRandParam().substring(1)
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -152,13 +152,6 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
.optional()
})
const zAudioRecordInputSpec = zBaseInputOptions.extend({
type: z.literal('AUDIORECORD'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -174,7 +167,6 @@ const zInputSpec = z.union([
zColorInputSpec,
zFileUploadInputSpec,
zImageInputSpec,
zAudioRecordInputSpec,
zImageCompareInputSpec,
zMarkdownInputSpec,
zTreeSelectInputSpec,
@@ -230,7 +222,6 @@ export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>
export type OutputSpec = z.infer<typeof zOutputSpec>

View File

@@ -16,6 +16,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -336,6 +337,7 @@ export class ComfyApp {
}
getRandParam() {
if (isCloud) return ''
return '&rand=' + Math.random()
}

View File

@@ -6,7 +6,6 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -305,6 +304,5 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
}

View File

@@ -8,7 +8,6 @@ import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtil
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { st, t } from '@/i18n'
import {
LGraphBadge,
LGraphCanvas,
LGraphEventMode,
LGraphNode,
@@ -135,19 +134,6 @@ export const useLitegraphService = () => {
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
this.badges.push(
new LGraphBadge({
text: '',
iconOptions: {
unicode: '\ue96e',
fontFamily: 'PrimeIcons',
color: '#ffffff',
fontSize: 12
},
fgColor: '#ffffff',
bgColor: '#3b82f6'
})
)
}
/**
@@ -845,7 +831,7 @@ export const useLitegraphService = () => {
)
}
if (this.graph && !this.graph.isRootGraph) {
const [x, y] = canvas.canvas_mouse
const [x, y] = canvas.graph_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
addWidgetPromotionOptions(options, overWidget, this)

View File

@@ -54,7 +54,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
event: MouseEvent
) => void | Promise<void>
/** Function to handle errors */
handleError?: (this: TreeExplorerNode<T>, error: Error) => void
handleError?: (this: TreeExplorerNode<T>, error: unknown) => void
/** Extra context menu items */
contextMenuItems?:
| MenuItem[]

View File

@@ -0,0 +1,353 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { useErrorHandling } from '@/composables/useErrorHandling'
describe('useErrorHandling', () => {
let errorHandler: ReturnType<typeof useErrorHandling>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
errorHandler = useErrorHandling()
})
describe('wrapWithErrorHandlingAsync', () => {
it('should execute action successfully', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
expect(action).toHaveBeenCalledOnce()
})
it('should call error handler when action throws', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should call finally handler after success', async () => {
const action = vi.fn(async () => 'success')
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
it('should call finally handler after error', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
describe('error recovery', () => {
it('should not use recovery strategy when no error occurs', async () => {
const action = vi.fn(async () => 'success')
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).not.toHaveBeenCalled()
expect(recoveryStrategy.recover).not.toHaveBeenCalled()
})
it('should use recovery strategy when it matches error', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn((error) => error === testError),
recover: vi.fn(async () => {
// Recovery succeeds, does nothing
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
it('should pass action and args to recovery strategy', async () => {
const testError = new Error('test error')
const action = vi.fn(async (_arg1: string, _arg2: number) => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy<[string, number], void> =
{
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test', 123)
expect(recoveryStrategy.recover).toHaveBeenCalledWith(
testError,
action,
['test', 123]
)
})
it('should retry operation when recovery succeeds', async () => {
let attemptCount = 0
const action = vi.fn(async (value: string) => {
attemptCount++
if (attemptCount === 1) {
throw new Error('first attempt failed')
}
return `success: ${value}`
})
const recoveryStrategy: ErrorRecoveryStrategy<[string], string> = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async (_error, retry, args) => {
await retry(...args)
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test-value')
expect(action).toHaveBeenCalledTimes(2)
expect(recoveryStrategy.recover).toHaveBeenCalledOnce()
})
it('should not call error handler when recovery succeeds', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).not.toHaveBeenCalled()
})
it('should call error handler when recovery fails', async () => {
const originalError = new Error('original error')
const recoveryError = new Error('recovery error')
const action = vi.fn(async () => {
throw originalError
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
throw recoveryError
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(originalError)
})
it('should try multiple recovery strategies in order', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const strategy1: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const strategy2: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const strategy3: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[strategy1, strategy2, strategy3]
)
await wrapped()
expect(strategy1.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy1.recover).not.toHaveBeenCalled()
expect(strategy2.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy2.recover).toHaveBeenCalled()
// Strategy 3 should not be checked because strategy 2 handled it
expect(strategy3.shouldHandle).not.toHaveBeenCalled()
expect(strategy3.recover).not.toHaveBeenCalled()
})
it('should fall back to error handler when no strategy matches', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const strategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[strategy]
)
await wrapped()
expect(strategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy.recover).not.toHaveBeenCalled()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should work with synchronous actions', async () => {
const testError = new Error('test error')
const action = vi.fn(() => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
})
describe('backward compatibility', () => {
it('should work without recovery strategies parameter', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
})
it('should work with empty recovery strategies array', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
})
})
})