Compare commits

...

29 Commits

Author SHA1 Message Date
Comfy Org PR Bot
736f0fa416 1.42.6 (#9986)
Patch version increment to 1.42.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9986-1-42-6-3256d73d365081a28bfad82022ce3440)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-15 17:29:44 -07:00
jaeone94
d8d8aeb99e feat: add Copy URL button to missing model rows for OSS (#9966) 2026-03-16 07:48:48 +09:00
Yourz
f4868894fa fix: tree explorer nodes not filling parent container width (#9964)
## Summary

Fix tree explorer nodes not filling the full width of the sidebar
container, causing text to overflow instead of truncating.

## Changes

- **What**: Add `min-w-0` to `TreeRoot` to allow flex shrinking within
sidebar. Add `w-full` and `min-w-0` to tree node rows so
absolutely-positioned virtualizer items fill the container width and
text truncates correctly.
<img width="365" height="749" alt="image"
src="https://github.com/user-attachments/assets/320910f3-52ad-4634-a935-6bd1a40aea7f"
/>


## Review Focus

The virtualizer renders each item with `position: absolute; left: 0` but
no explicit width, so rows would size to content rather than filling the
container. Adding `w-full` ensures rows stretch to 100% of the
virtualizer container, and `min-w-0` allows proper flex shrinking for
deep indentation levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9964-fix-tree-explorer-nodes-not-filling-parent-container-width-3246d73d36508138be38fdcac15ae4ef)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-15 15:40:06 -07:00
Jukka Seppänen
a96c61d2c2 fix: LGraphGroup paste position (#9962)
## Summary

Fix group paste position: groups now paste at the cursor location
instead of on top of the original.

## Changes

- **What**: Added LGraphGroup offset handling in _deserializeItems()
position adjustment loop, matching existing LGraphNode and Reroute
behavior.

## Screenshots

Before:


https://github.com/user-attachments/assets/e317af10-8009-4092-9d14-de79316cd853

After:


https://github.com/user-attachments/assets/f4ffefd5-519a-4592-812c-c88e3b5940fd

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9962-fix-LGraphGroup-paste-position-3246d73d365081eea5b2e2507da861de)
by [Unito](https://www.unito.io)
2026-03-15 18:47:47 +00:00
Christian Byrne
c420cff4f0 feat: add TBT/frameDuration metrics and new perf test scenarios (#9910)
## Summary

Adds Total Blocking Time (TBT) and frame duration metrics to the
performance testing infrastructure, plus three new test scenarios
covering zoom, pan, and many-nodes-idle.

## Changes

### New Metrics
- **`totalBlockingTimeMs`** — Computed from PerformanceObserver
`longtask` entries: `sum(duration - 50ms)` for tasks >50ms. Measures
main thread blocking.
- **`frameDurationMs`** — Average frame duration via rAF timing (16.67ms
= 60fps target). Measures rendering smoothness.

### New Test Scenarios
| Scenario | Description |
|---|---|
| `canvas-zoom-sweep` | 10 zoom-in + 10 zoom-out cycles on default
workflow |
| `canvas-pan-many-nodes` | 10 pan sweeps over 100-node workflow |
| `canvas-many-nodes-idle` | 2-second idle measurement with 100 nodes
rendered |

### Infrastructure
- `PerformanceHelper.ts`: Installs PerformanceObserver for longtask,
collects TBT, measures frame duration via rAF
- `perf-report.ts`: Reports TBT and frame duration in PR comment tables
- `browser_tests/assets/perf/many_nodes_100.json`: 100-node (10×10 grid)
test fixture

## Review Focus
- TBT collection clears entries at `startMeasuring()` and reads at
`stopMeasuring()` — ensure no race with observer buffering
- Frame duration sampling uses 10 frames — enough for signal without
slowing tests

Depends on: #9886, #9887

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9910-feat-add-TBT-frameDuration-metrics-and-new-perf-test-scenarios-3236d73d365081488ae3c594a8bf7cff)
by [Unito](https://www.unito.io)
2026-03-15 08:54:00 -07:00
Yourz
8ccfe852b4 fix: cloud subscribe redirect hangs waiting for billing init (#9965)
## Summary

Fix /cloud/subscribe route hanging indefinitely because billing context
never initializes during the onboarding flow.

## Changes

- **What**: Replace passive `await until(isInitialized).toBe(true)` with
explicit `await initialize()` in CloudSubscriptionRedirectView. Remove
unused `until` import.


![Kapture 2026-03-15 at 23 16
22](https://github.com/user-attachments/assets/0a12487b-b39a-4f96-9a4c-96a01facfdd8)

## Review Focus

In the onboarding flow, `useTeamWorkspaceStore().activeWorkspace` is not
set, so `useBillingContext`'s internal watch (which triggers
`initialize()` on workspace change) enters the `!newWorkspaceId` branch
— it resets `isInitialized` to `false` and returns without ever calling
`initialize()`. The old code then awaited `isInitialized` becoming
`true` forever.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9965-fix-cloud-subscribe-redirect-hangs-waiting-for-billing-init-3246d73d3650812d93ebd477c544fa0a)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-15 23:23:02 +08:00
jaeone94
e2ef041170 feat: surface missing models in Error Tab for OSS and remove legacy dialog (#9921)
## Summary
- Surface missing models in the Error Tab for OSS environments,
replacing the legacy modal dialog
- Add Download button per model and Download All button in group header
with file size display
- Move download business logic from `components/dialog/content` to
`platform/missingModel`
- Remove legacy missing models dialog components and composable

## Changes
- **Pipeline**: Remove `isCloud` guard from `scanAllModelCandidates` and
`surfaceMissingModels` so OSS detects missing models
- **Grouping**: Group non-asset-supported models by directory in OSS
instead of lumping into UNSUPPORTED
- **UI**: Add Download button (matching Install Node Pack design) and
Download All header button
- **Store**: Add `folderPaths`/`fileSizes` state with setter methods,
race condition guard
- **Cleanup**: Delete `MissingModelsContent`, `MissingModelsHeader`,
`MissingModelsFooter`, `useMissingModelsDialog`, `missingModelsUtils`
- **Tests**: Add OSS/Cloud grouping tests, migrate Playwright E2E to
Error Tab, improve test isolation
- **Snapshots**: Reset Playwright screenshot expectations since OSS
missing model error detection now causes red highlights on affected
nodes
- **Accessibility**: Add `aria-label` with model name, `aria-expanded`
on toggle, warning icon for unknown category

## Test plan
- [x] Unit tests pass (86 tests)
- [x] TypeScript typecheck passes
- [x] knip passes
- [x] Load workflow with missing models in OSS → Error Tab shows missing
models grouped by directory
- [x] Download button triggers browser download with correct URL
- [x] Download All button downloads all downloadable models
- [x] Cloud environment behavior unchanged
- [x] Playwright E2E: `pnpm test:browser:local -- --grep "Missing models
in Error Tab"`

## Screenshots


https://github.com/user-attachments/assets/12f15e09-215a-4c58-87ed-39bbffd1359c

 


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9921-feat-surface-missing-models-in-Error-Tab-for-OSS-and-remove-legacy-dialog-3236d73d365081f0a9dfc291978f5ecf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-15 22:46:47 +09:00
Dante
a4952e9c45 feat: add Ingest API codegen with Zod schema generation (#9932)
## Summary

- Add `packages/ingest-types/` package that auto-generates TypeScript
types and Zod schemas from the Ingest service OpenAPI spec
- Uses `@hey-api/openapi-ts` with built-in Zod plugin (Zod v3
compatible)
- Filters out overlapping endpoints shared with the local ComfyUI Python
backend
- Generates **493 TypeScript types** and **256 Zod schemas** covering
cloud-only endpoints
- Configure knip to ignore generated files

## CI automation

The cloud repo pushes generated types to this repo (push model, no
private repo cloning).
See: Comfy-Org/cloud#2858

## How endpoint filtering works

Codegen targets are controlled by the **exclude list** in
`packages/ingest-types/openapi-ts.config.ts`. Everything in the Ingest
`openapi.yaml` is included **except** overlapping endpoints that also
exist in the local ComfyUI Python backend:

**Excluded (overlapping with ComfyUI Python):**
`/prompt`, `/queue`, `/history`, `/object_info`, `/features`,
`/settings`, `/system_stats`, `/interrupt`, `/upload/*`, `/view`,
`/jobs`, `/userdata`, `/webhooks/*`, `/internal/*`

**Included (cloud-only, codegen targets):**
`/workspaces/*`, `/billing/*`, `/secrets/*`, `/assets/*`, `/tasks/*`,
`/auth/*`, `/workflows/*`, `/workspace/*`, `/user`, `/settings/{key}`,
`/tags`, `/feedback`, `/invite_code/*`, `/experiment/models/*`,
`/global_subgraphs/*`

## Follow-up: replace manual types with generated ones

This PR only sets up the codegen infrastructure. A follow-up PR should
replace manually maintained types with imports from
`@comfyorg/ingest-types`:

| File | Lines | Current | Replace with |
|------|-------|---------|-------------|
| `src/platform/workspace/api/workspaceApi.ts` | ~270 | TS interfaces |
`import type { ... } from '@comfyorg/ingest-types'` |
| `src/platform/secrets/types.ts` | ~32 | TS interfaces | `import type {
... } from '@comfyorg/ingest-types'` |
| `src/platform/assets/schemas/assetSchema.ts` | ~125 | Zod schemas |
`import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/assets/schemas/mediaAssetSchema.ts` | ~50 | Zod schemas
| `import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/tasks/services/taskService.ts` | ~70 | Zod schemas |
`import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/workspace/workspaceTypes.ts` | ~6 | TS interface |
`export type { ... } from '@comfyorg/ingest-types'` |

## Test plan

- [x] `pnpm generate` in `packages/ingest-types/` produces
`types.gen.ts` and `zod.gen.ts`
- [x] `pnpm typecheck` passes
- [x] Pre-commit hooks pass (lint, typecheck, format)
- [x] Generated Zod schemas validate correct data and reject invalid
data
- [x] No import conflicts with existing code (generated types are
isolated in separate package)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-15 21:53:26 +09:00
Christian Byrne
64c852bf82 test: add large-graph perf test with 245-node workflow (backlog N5) (#9940)
## What

Adds a 245-node workflow asset and two `@perf` tests to establish a
baseline for large-graph performance regressions (Tier 6 in the
performance backlog).

## Why

Backlog item N5: we need CI regression detection for compositor layer
management, GPU texture count, and transform pane cost at 245+ nodes.
This is PR1 of 2 — establishes baseline metrics on main. Future
optimization PRs will show improvement deltas against this baseline.

## Tests Added

- **`large graph idle rendering`** — 120 frames idle with 245 nodes,
measures style recalcs, layouts, task duration, heap delta
- **`large graph pan interaction`** — middle-click pan across 245 nodes,
stresses compositor layer management and transform recalculation

## Workflow Asset

`browser_tests/assets/large-graph-workflow.json` — 245 nodes (49
pipelines of CheckpointLoader → 2× CLIPTextEncode → KSampler +
EmptyLatentImage), 294 links. Minimal structure focused on node count.

## Verification

- [x] `pnpm typecheck:browser` passes
- [x] `pnpm lint` passes (eslint on changed file)
- [x] All link references in JSON validated programmatically

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9940-test-add-large-graph-perf-test-with-245-node-workflow-backlog-N5-3246d73d365081f6b5d8ddb9a85e6ad0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-15 03:04:39 -07:00
Dante
697087fdca draft: add red-green-fix skill for verified bug fix workflow (#9954)
## Summary

- Add a Claude Code skill (`/red-green-fix`) that enforces the red-green
commit pattern for bug fixes
- Ensures a failing test is committed first (red CI), then the fix is
committed separately (green CI)
- Gives reviewers proof that the test actually catches the bug
- Includes `reference/testing-anti-patterns.md` with common mistakes
contextualized to this codebase

## Structure

```
.claude/skills/red-green-fix/
├── SKILL.md                              # Main skill definition
└── reference/
    └── testing-anti-patterns.md          # Anti-patterns guide
```

## Test Plan

- [ ] Invoke `/red-green-fix <bug description>` in Claude Code and
verify the two-step workflow
- [ ] Confirm PR template includes red-green verification table
- [ ] Review anti-patterns reference for completeness

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9954-draft-add-red-green-fix-skill-for-verified-bug-fix-workflow-3246d73d365081339a83dc09263b0f33)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-15 03:02:02 -07:00
Christian Byrne
a7218b2922 fix: fix perf CI pipeline — z-score baselines, force-push staleness, baseline storage (#9886)
## Summary

Fixes three critical issues with the CI performance reporting pipeline
that made perf reports useless on PRs (demonstrated by PR #9248 — deep
watcher removal merged without useful perf signal).

## Changes

### 1. Fix z-score baseline variance collection (`0/5 runs`)
**Root cause:** PR #9305 added z-score statistical analysis code to
`perf-report.ts`, but the historical data download step was placed in
the wrong workflow file. The report is generated in
`pr-perf-report.yaml` (a `workflow_run`-triggered job), but the
historical download was in `ci-perf-report.yaml` (the test runner) —
different runners, different filesystems.

**Fix:** Implement `perf-data` orphan branch storage:
- On push to main: save `perf-metrics.json` to `perf-data` branch with
timestamped filename
- On PR report: fetch last 5 baselines from `perf-data` branch into
`temp/perf-history/`
- Rolling window of 20 baselines, oldest pruned automatically
- Same pattern used by `github-action-benchmark` (33.7k repos)

### 2. Fix force-push comment staleness
**Root cause:** `cancel-in-progress: true` kills the perf test run
before it uploads artifacts. The downstream report workflow only
triggers on `conclusion == 'success'` — cancelled runs are ignored, so
the comment from the first successful run goes stale.

**Fix:**
- Change `cancel-in-progress: false` — with GitHub's queue depth of 1,
rapid pushes (A,B,C,D) run A and D, skipping B and C
- Add SHA validation in `pr-perf-report.yaml` — before posting, check if
the workflow_run's head SHA still matches the PR's current head. Skip
posting stale results.

### 3. Add permissions for baseline operations
- `contents: write` on CI job (needed for pushing to perf-data branch)
- `actions: read` on both workflows (needed for artifact/baseline
access)

## One-time setup required
After merging, create the `perf-data` orphan branch:
```bash
git checkout --orphan perf-data
git rm -rf .
echo '# Performance Baselines' > README.md
mkdir -p baselines
git add README.md baselines
git commit -m 'Initialize perf-data branch'
git push origin perf-data
```

The first 2 pushes to main after setup will build up variance data, and
z-scores will start appearing in PR reports (threshold is
`historical.length >= 2`).

## Testing
- YAML validated with `yaml.safe_load()`
- `perf-report.ts` `loadHistoricalReports()` already reads from
`temp/perf-history/<index>/perf-metrics.json` — no code changes needed
- All new steps use `continue-on-error: true` for graceful degradation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9886-fix-fix-perf-CI-pipeline-z-score-baselines-force-push-staleness-baseline-storage-3226d73d365081538424c7945e71f308)
by [Unito](https://www.unito.io)
2026-03-15 02:46:10 -07:00
Christian Byrne
39a774bc15 feat: make Vue nodes (Nodes 2.0) default for new desktop installs (#9947)
## What

Makes Vue nodes (Nodes 2.0) the default renderer for new desktop app
installs (version ≥1.41.0), matching the behavior already live for cloud
new installs.

## Why

Step 2 of the Nodes 2.0 rollout sequence:
1.  Cloud new installs (≥1.41.0) — DONE
2. 👉 **Desktop app (new installs)** — this PR
3.  Local installs
4.  Remove Beta tag
5.  GTM announcement

No forced migration — only changes the default for new installs.
Existing users keep their setting. Rollback is a settings flip.

## Change

In `coreSettings.ts`, the `defaultsByInstallVersion` for
`Comfy.VueNodes.Enabled` changes from:
```typescript
defaultsByInstallVersion: { '1.41.0': isCloud },
```
to:
```typescript
defaultsByInstallVersion: { '1.41.0': isCloud || isDesktop },
```

## Gated on

- M2 perf target (≥52 FPS on 245-node workflow) — layer merge landed,
likely met
- M-DevRel migration docs (blocks Beta tag removal, not this flip)

Draft PR — ceremonial, to be merged when M2 checkpoint passes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9947-feat-make-Vue-nodes-Nodes-2-0-default-for-new-desktop-installs-3246d73d365081b280dfff932c7aa016)
by [Unito](https://www.unito.io)
2026-03-15 01:51:25 -07:00
Christian Byrne
74dedc314d fix: prevent live preview dimension flicker between frames (#9937)
## Summary

Fix "Calculating dimensions" text flickering during live sampling
preview in Vue renderer.

## Changes

- **What**: Stop resetting `actualDimensions` to `null` on every
`imageUrl` change. Previous dimensions are retained while the new frame
loads, eliminating the flicker. Error state is still reset correctly.

## Review Focus

The watcher on `props.imageUrl` previously reset both `actualDimensions`
and `imageError`. Now it only resets `imageError`, since
`handleImageLoad` updates dimensions when the new frame actually loads.
This means stale dimensions show briefly between frames, which is
intentionally better than showing "Calculating dimensions" text.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9937-fix-prevent-live-preview-dimension-flicker-between-frames-3246d73d36508154a676e5996112354f)
by [Unito](https://www.unito.io)
2026-03-15 01:39:24 -07:00
Dante
5ae747e7c1 fix: prevent white flash when opening mask editor (#9860)
## Summary

- Remove hardcoded `bg-white` from mask editor canvas background div to
prevent white flash on dialog open
- Add a loading spinner while the mask editor initializes (image
loading, canvas setup, GPU resources)
- Background color is set dynamically by `setCanvasBackground()` after
initialization

Fixes #9852


### AS IS


https://github.com/user-attachments/assets/7da61e32-671b-4056-b5ec-8cb246fc7689



### TO BE 

https://github.com/user-attachments/assets/bfdedc69-f690-42c5-8591-619623c04f55



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9860-fix-prevent-white-flash-when-opening-mask-editor-3226d73d365081de9b7ad4622438e6ed)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:15:04 -07:00
Christian Byrne
21069d54e7 feat: expand CDP perf metrics — add DOM nodes, script duration, event listeners (#9887)
## Summary

Expands the performance testing infrastructure to collect 4 additional
CDP metrics that are already returned by `Performance.getMetrics` but
were not being read. This is a zero-cost expansion — no additional CDP
calls, just reading more fields from the existing response.

## New Metrics

| Metric | CDP Source | What It Detects |
|---|---|---|
| `domNodes` | `Nodes` | DOM node count delta — widget DOM leaks during
node create/destroy |
| `jsHeapTotalBytes` | `JSHeapTotalSize` | Total heap delta — combined
with `heapDeltaBytes` shows GC pressure |
| `scriptDurationMs` | `ScriptDuration` | JS execution time vs total
task time — script vs rendering balance |
| `eventListeners` | `JSEventListeners` | Listener count delta — detects
listener accumulation across lifecycle |

## Changes

### `browser_tests/fixtures/helpers/PerformanceHelper.ts`
- Added 4 fields to `PerfSnapshot` interface
- Added 4 fields to `PerfMeasurement` interface
- Wired through `getSnapshot()` and `stopMeasuring()`

### `scripts/perf-report.ts`
- Added 4 fields to `PerfMeasurement` interface
- Expanded `MetricKey` type and `REPORTED_METRICS` array with 3 new
reported metrics (`domNodes`, `scriptDurationMs`, `eventListeners`)
- `jsHeapTotalBytes` is collected but not in `REPORTED_METRICS` — it's
used alongside `heapDeltaBytes` for GC pressure ratio analysis

## Why These 4

From a gap analysis of all ~30 CDP metrics, these were identified as
highest priority for ComfyUI:
- **`Nodes`** (P0): ComfyUI dynamically creates/destroys widget DOM. DOM
bloat from leaked widgets is a key performance risk, especially for Vue
Nodes 2.0.
- **`ScriptDuration`** (P1): Separates JS execution from layout/paint.
Reveals whether perf issues are script-heavy or rendering-heavy.
- **`JSEventListeners`** (P1): Widget lifecycle can leak listeners
across node add/remove cycles.
- **`JSHeapTotalSize`** (P1): With `JSHeapUsedSize`, the ratio shows GC
fragmentation pressure.

## Backward Compatibility

The `PerfMeasurement` interface is extended (not changed). Old baseline
`perf-metrics.json` files without these fields will have `undefined`
values, which the report script handles gracefully (shows `—` for
missing data).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9887-feat-expand-CDP-perf-metrics-add-DOM-nodes-script-duration-event-listeners-3226d73d3650818abea1d4a441667c38)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-14 23:24:02 -07:00
Christian Byrne
8a456043e8 test: add browser test for textarea right-click context menu in subgraph (#9891)
## Summary

Add E2E test coverage for the textarea widget right-click context menu
inside subgraphs.

The fix was shipped in #9840 — this PR adds the missing browser test.

## Test

- Loads a subgraph workflow with a CLIPTextEncode (textarea) node
- Navigates into the subgraph
- Right-clicks the textarea DOM element
- Asserts that the ComfyUI "Promote Widget" context menu option appears

## Related

- Fixes the test gap from #9840
- Notion ticket: d7a53160-e1e1-42bb-a5ac-c0c2702c629c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9891-test-add-browser-test-for-textarea-right-click-context-menu-in-subgraph-3226d73d365081a4be51f89b5d505361)
by [Unito](https://www.unito.io)
2026-03-14 22:36:43 -07:00
Christian Byrne
585e6f87fa fix: skip redundant appScalePercentage updates during zoom/pan (#9403)
## What
Add equality check before updating `appScalePercentage` reactive ref.

## Why
Firefox profiler shows 586 `setElementText` markers from continuous text
interpolation updates during zoom/pan. The rounded percentage value
often doesn't change between events.

## How
Extract `updateAppScalePercentage()` helper with equality guard —
compares new rounded value to current before assigning to the ref.

## Perf Impact
Expected: eliminates ~90% of `setElementText` markers during zoom/pan

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9403-fix-skip-redundant-appScalePercentage-updates-during-zoom-pan-31a6d73d3650812db8f2d68ac73c95b0)
by [Unito](https://www.unito.io)
2026-03-14 21:44:44 -07:00
Comfy Org PR Bot
4781775a78 1.42.5 (#9906)
Patch version increment to 1.42.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-14 19:22:21 -07:00
Alexander Brown
74a48ab2aa fix: stabilize subgraph promoted widget identity and rendering (#9896)
## Summary

Fix subgraph promoted widget identity/rendering so on-node widgets stay
correct through configure/hydration churn, duplicate names, and
linked+independent coexistence.

## Changes

- **Subgraph promotion reconciliation**: stabilize linked-entry identity
by subgraph slot id, preserve deterministic linked representative
selection, and prune stale alias/fallback entries without dropping
legitimate independent promotions.
- **Promoted view resolution**: bind slot mapping by promoted view
object identity (`getSlotFromWidget` / `getWidgetFromSlot`) to avoid
same-name collisions.
- **On-node widget rendering**: harden `NodeWidgets` identity and dedup
to avoid visual aliasing, prefer visible duplicates over hidden stale
entries, include type/source execution identity, and avoid collapsing
transient unresolved entries.
- **Mapping correctness**: update `useGraphNodeManager` promoted source
mapping to resolve by input target only when the promoted view is
actually bound to that input.
- **Subgraph input uniqueness**: ensure empty-slot promotion creates
unique input names (`seed`, `seed_1`, etc.) for same-name multi-source
promotions.
- **Safety fix**: guard against undefined canvas in slot-link
interaction.
- **Tests/fixtures**: add focused regressions for fixture path
`subgraph_complex_promotion_1`, linked+independent same-name cases,
duplicate-name identity mapping, dedup behavior, and input-name
uniqueness.

## Review Focus

Validate behavior around transient configure/hydration states (`-1` id
to concrete id), duplicate-name promotions, linked representative
recovery, and that dedup never hides legitimate widgets while still
removing true duplicates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9896-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3226d73d365081c8a1e8d0a5a22e826d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 11:30:31 -07:00
Christian Byrne
0875e2f50f fix: clear stale widget slotMetadata on link disconnect (#9885)
## Summary
Fixes text field becoming non-editable when a previously linked input is
removed from a custom node.

## Problem
When a widget's input was promoted to a slot, connected via a link, and
then the input was removed (e.g., by updating the custom node
definition), the widget retained stale `slotMetadata` with `linked:
true`. This prevented the widget from being editable.

## Solution
In `refreshNodeSlots`, removed the `if (slotInfo)` guard so
`widget.slotMetadata` is always assigned — either to valid metadata or
`undefined`. This ensures stale linked state is cleared when inputs no
longer match widgets.

## Acceptance Criteria
1. Text field remains editable after promote→connect→disconnect cycle
2. Text field returns to editable state when noodle disconnected
3. No mode switching needed to restore editability

## Testing
- Added regression test: "clears stale slotMetadata when input no longer
matches widget"
- All existing tests pass (18/18 in affected file)

---
**Note: This PR currently contains only the RED (failing test) commit
for TDD verification. The GREEN (fix) commit will be pushed after CI
confirms the test failure.**

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9885-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3226d73d365081269319c027b42d9f6b)
by [Unito](https://www.unito.io)
2026-03-14 08:13:34 -07:00
Johnpaul Chiwetelu
63442d2fb0 fix: restore native copy/paste events for image paste support (#9914)
## Summary

- Remove Ctrl+C and Ctrl+V keybindings from the keybinding service
defaults so native browser copy/paste events fire
- This restores image paste into LoadImage nodes, which broke after
#9459

## Problem

PR #9459 moved Ctrl+C/V into the keybinding service, which calls
`event.preventDefault()` on keydown. This prevents the browser `paste`
event from firing, so `usePaste` (which detects images in the clipboard)
never runs. The `PasteFromClipboard` command only reads from
localStorage, completely bypassing image detection.

**Repro:** Copy a node → copy an image externally → try to paste the
image into a LoadImage node → gets old node data from localStorage
instead.

## Fix

Remove Ctrl+C and Ctrl+V from `CORE_KEYBINDINGS` in `defaults.ts`. The
native browser events now fire as before, and `useCopy`/`usePaste`
handle them correctly. Ctrl+Shift+V, Ctrl+A, Delete, and Backspace
keybindings remain in the keybinding service.

Fixes #9459 (regression)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9914-fix-restore-native-copy-paste-events-for-image-paste-support-3236d73d365081c7ac53f983f316e10f)
by [Unito](https://www.unito.io)
2026-03-14 08:06:05 +00:00
Alexander Brown
bb6f00dc68 fix: hide template selector after shared workflow accept (#9913)
## Summary

Hide the template selector when a first-time cloud user accepts a shared
workflow from a share link, so the shared workflow opens without the
onboarding template dialog lingering.

## Changes

- **What**: Added shared-workflow loader behavior to close the global
template selector on accept actions (`copy-and-open` and `open-only`)
while keeping cancel behavior unchanged.
- **What**: Added targeted unit tests covering hide-on-accept and
no-hide-on-cancel behavior in the shared workflow URL loader.

## Review Focus

Confirm that share-link accept paths now dismiss the template selector
and that cancel still leaves it available.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9913-fix-hide-template-selector-after-shared-workflow-accept-3236d73d365081099c04e350d499fad2)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 01:05:31 -07:00
Christian Byrne
11fc09220c fix: reorder forwardPanEvent conditions to skip expensive hasTextSelection on non-middle-button events (#9892)
## Summary

Reorder condition checks in `forwardPanEvent` so the cheap
`isMiddlePointerInput()` check runs first, avoiding expensive
`hasTextSelection()` → `window.getSelection().toString().trim()` on
every pointermove event.

## Changes

- **What**: `forwardPanEvent` now early-returns on
`!isMiddlePointerInput(e)` before calling `shouldIgnoreCopyPaste`, which
internally calls `hasTextSelection()`. Since most pointermove events are
not middle-button, this skips the expensive `toString()` call entirely.

## Review Focus

Semantic equivalence: the original condition was `(A && B) || C →
return`. Rewritten as two guards: `if (C) return; if (A && B) return;`.
The logic is equivalent — if `C` (`!isMiddlePointerInput`) is true, we
return regardless of `A && B`. If `C` is false (middle button), we check
`A && B` (`shouldIgnoreCopyPaste && activeElement`).

## Evidence

Backlog item #19. rizumu's Chrome DevTools profiling (Mar 2026) of a
245-node workflow showed `toString()` in `hasTextSelection` called on
every pointermove. Source: Slack `#C095BJSFV24` thread
`p1772823346206479`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9892-fix-reorder-forwardPanEvent-conditions-to-skip-expensive-hasTextSelection-on-non-middle--3226d73d365081d38b89c8bb1dde3693)
by [Unito](https://www.unito.io)
2026-03-13 19:59:09 -07:00
Terry Jia
16f4f3f3ed fix: address PR review feedback for upstream value composable (#9908)
## Summary
follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/9851
fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9877 and
https://github.com/Comfy-Org/ComfyUI_frontend/issues/9878

- Make useUpstreamValue generic to eliminate as Bounds/CurvePoint[]
casts
- Change isBoundsObject to type predicate (value is Bounds)
- Reuse WidgetState from widgetValueStore instead of duplicate interface
- Add length >= 2 guard in isCurvePointArray for empty arrays
- Add disabled guard in effectiveBounds setter
- Add unit tests for singleValueExtractor and boundsExtractor

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9908-fix-address-PR-review-feedback-for-upstream-value-composable-3236d73d365081f7a01dcb416732544a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 19:58:30 -07:00
Deep Mehta
4c5a49860c feat: add model-to-node mappings for CogVideo, inpaint, and LayerDiffuse (#9890)
## Summary
- Add `quickRegister()` entries for 3 model directories missing UI
backlinks:
- `CogVideo` → `DownloadAndLoadCogVideoModel` (covers CogVideo,
CogVideo/ControlNet/*, CogVideo/VAE)
  - `inpaint` → `INPAINT_LoadInpaintModel`
  - `layer_model` → `LayeredDiffusionApply`

These mappings ensure the "Use" button in the model browser correctly
creates
the appropriate loader node when users click on models from these node
packs.

## Test plan
- [ ] Verify "Use" button works for CogVideo models in model browser
- [ ] Verify "Use" button works for inpaint models (fooocus, lama, MAT)
- [ ] Verify "Use" button works for LayerDiffuse models

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9890-feat-add-model-to-node-mappings-for-CogVideo-inpaint-and-LayerDiffuse-3226d73d3650816ea547fbcdf3a20c35)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:45:24 -07:00
Johnpaul Chiwetelu
db5e8961e0 chore: upgrade vite to 8.0.0 stable (#9903)
## Summary
- Upgrade Vite from `8.0.0-beta.13` to `8.0.0` stable release

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm build` passes
- [x] `pnpm test:unit` passes (489 files, 6436 tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9903-chore-upgrade-vite-to-8-0-0-stable-3226d73d365081feab2af46f0d95d6a4)
by [Unito](https://www.unito.io)
2026-03-13 21:45:41 +00:00
Terry Jia
9f9fa60137 feat: reactive upstream value display for disabled curve and imagecrop widgets (#9851)
Replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/9364

## Summary
Add a generic mechanism for widgets to reactively display upstream
linked values when disabled, with concrete implementations for curve and
imagecrop widgets.

## Changes

- What: When a widget input is linked to an upstream node, the widget
enters a disabled state and displays the upstream node's current value
reactively. This is built as a two-layer system:
- Infrastructure layer: Resolve link origin info (originNodeId,
originOutputName) in buildSlotMetadata, pass it through
SimplifiedWidget.linkedUpstream, and provide a generic useUpstreamValue
composable that reads upstream values from widgetValueStore.
- Widget layer: Each widget type provides its own ValueExtractor to
interpret upstream data. singleValueExtractor handles simple
type-matched values (e.g. CurvePoint[]); boundsExtractor composes a
Bounds object from either a single upstream widget or four individual
x/y/width/height number widgets.
- Curve widget: shows upstream curve points in read-only mode
- ImageCrop widget: shows upstream bounding box with disabled crop
handles and number inputs
- CurveEditor and WidgetBoundingBox: gain disabled prop support

## Adapting future widgets
The system is designed so that any widget needing upstream value display
only needs to:
1. Accept widget: SimplifiedWidget as a prop (provides linkedUpstream
automatically)
2. Call useUpstreamValue(() => widget.linkedUpstream, extractor) with a
suitable extractor
3. Use singleValueExtractor(typeGuard) for single-value types, or write
a custom ValueExtractor for composite cases like boundsExtractor
4. Compute an effectiveValue that switches between upstream and local
based on disabled state

No infrastructure changes are needed — linkedUpstream is already
populated for all widget types that have a corresponding input slot.

## Review Focus
- The buildSlotMetadata helper is shared between extractVueNodeData and
refreshNodeSlots — verify the graph ref is reliably available in both
paths
- boundsExtractor composing from 4 individual number widgets
(x/y/width/height) — this handles the BBox→ImageCrop case where upstream
exposes separate widgets rather than a single Bounds object

## Screenshots

https://github.com/user-attachments/assets/dbc57a44-c5df-44f0-acce-d347797ee8fb

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9851-feat-reactive-upstream-value-display-for-disabled-curve-and-imagecrop-widgets-3226d73d36508134b386ddc9b9f1266b)
by [Unito](https://www.unito.io)
2026-03-13 16:59:53 -04:00
Christian Byrne
7131c274f3 fix: clear stale progress bar on SubgraphNode after navigation (#9865)
## Problem

When navigating back from a subgraph to the root graph, the SubgraphNode
can retain a stale progress bar. This happens because the progress
watcher in `GraphCanvas.vue` watches `[nodeLocationProgressStates,
canvasStore.canvas]`, but neither value changes reference during
subgraph navigation:

- `nodeLocationProgressStates` is already `{}` (execution completed
while viewing the subgraph)
- `canvasStore.canvas` is a `shallowRef` set once at startup — only
`canvas.graph` changes (via `setGraph()`)

**Reproduction** (from PR #4382 comment thread by @guill):
1. Create a subgraph with a KSampler
2. Execute the workflow
3. While progress bar is halfway, enter the subgraph
4. Wait for execution to complete
5. Navigate back to root graph
6. Progress bar is stuck at 50%

## Root Cause

`canvasStore.canvas` is a `shallowRef` — subgraph navigation mutates
`canvas.graph` (a nested property) via `LGraphCanvas.setGraph()`, which
doesn't trigger a shallow watch. The watcher never re-fires to clear
stale `node.progress` values.

## Fix

Add `canvasStore.currentGraph` to the watcher's dependency array. This
is already a `shallowRef` in `canvasStore` that's updated on every
`litegraph:set-graph` event. Zero overhead, precise targeting.

## Context

- Original discussion:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/4382/files/BASE..868e047272f6c5d710db7e607b8997d4c243490f#r2202024855
- PR #9248 correctly removed `deep: true` from this watcher but missed
the subgraph edge case
- `deep: true` was the wrong fix — `canvasStore.currentGraph` is the
precise solution

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9865-fix-clear-stale-progress-bar-on-SubgraphNode-after-navigation-3226d73d3650811c8f9de612d81ef98a)
by [Unito](https://www.unito.io)
2026-03-13 13:04:10 -07:00
woctordho
82556f02a9 fix: respect 'always snap to grid' when auto-scale layout from nodes 1.0 to 2.0 (#9332)
## Summary

Previously when I switch from nodes 1.0 to 2.0, positions and sizes of
nodes do not follow 'always snap to grid'. You can guess what a mess it
is for people relying on snap to grid to retain sanity. This PR fixes
it.

## Changes

In `ensureCorrectLayoutScale`, we call `snapPoint` after the position
and the size are updated.

We also need to ensure that the snapped size is larger than the minimal
size required by the content, so I've added 'ceil' mode to `snapPoint`,
and the patch is larger than I thought first.

I'd happily try out nodes 2.0 once this is addressed :)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9332-fix-respect-always-snap-to-grid-when-auto-scale-layout-from-nodes-1-0-to-2-0-3176d73d365081f5b6bcc035a8ffa648)
by [Unito](https://www.unito.io)
2026-03-13 11:40:51 -07:00
250 changed files with 18602 additions and 1115 deletions

View File

@@ -0,0 +1,221 @@
---
name: red-green-fix
description: 'Bug fix workflow that proves test validity with a red-then-green CI sequence. Commits a failing test first (CI red), then the minimal fix (CI green). Use when fixing a bug, writing a regression test, or when asked to prove a fix works.'
---
# Red-Green Fix
Fixes bugs as two commits so CI automatically proves the test catches the bug.
## Why Two Commits
If you commit the test and fix together, the test always passes — reviewers cannot tell whether the test actually detects the bug or is a no-op. Splitting into two commits creates a verifiable CI trail:
1. **Commit 1 (test-only)** — adds a test that exercises the bug. CI runs it → test fails → red X.
2. **Commit 2 (fix)** — adds the minimal fix. CI runs the same test → test passes → green check.
The red-then-green sequence in the commit history proves the test is valid.
## Input
The user provides a bug description as an argument. If no description is given, ask the user to describe the bug before proceeding.
Bug description: $ARGUMENTS
## Step 0 — Setup
Create an isolated branch from main:
```bash
git fetch origin main
git checkout -b fix/<bug-name> origin/main
```
## Step 1 — Red: Failing Test Only
Write a test that reproduces the bug. **Do NOT write any fix code.**
### Choosing the Test Framework
| Bug type | Framework | File location |
| --------------------------------- | ---------- | ------------------------------- |
| Logic, utils, stores, composables | Vitest | `src/**/*.test.ts` (colocated) |
| UI interaction, canvas, workflows | Playwright | `browser_tests/tests/*.spec.ts` |
For Playwright tests, follow the `/writing-playwright-tests` skill for patterns, fixtures, and tags.
### Rules
- The test MUST fail against the current codebase (this is the whole point)
- Do NOT modify any source code outside of test files
- Do NOT include any fix, workaround, or behavioral change
- Do NOT add unrelated tests or refactor existing tests
- Keep the test minimal — only what is needed to reproduce the bug
- Avoid common anti-patterns — see `reference/testing-anti-patterns.md`
### Vitest Example
```typescript
// src/utils/pathUtil.test.ts
import { describe, expect, it } from 'vitest'
import { resolveModelPath } from './pathUtil'
describe('resolveModelPath', () => {
it('handles absolute paths from folder_paths API', () => {
const result = resolveModelPath(
'/absolute/models',
'/absolute/models/checkpoints'
)
expect(result).toBe('/absolute/models/checkpoints')
})
})
```
### Playwright Example
```typescript
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Model Download', { tag: ['@smoke'] }, () => {
test('downloads model when path is absolute', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('missing-model')
const downloadBtn = comfyPage.page.getByTestId('download-model-button')
await downloadBtn.click()
await expect(comfyPage.page.getByText('Download complete')).toBeVisible()
})
})
```
### Verify Locally First
Run the test locally before pushing to confirm it fails for the right reason:
```bash
# Vitest
pnpm test:unit -- <test-file>
# Playwright
pnpm test:browser:local -- --grep "<test name>"
```
If the test passes locally, it does not reproduce the bug — revisit your test before pushing.
### Quality Checks and Commit
```bash
pnpm typecheck
pnpm lint
pnpm format:check
git add <test-files-only>
git commit -m "test: add failing test for <concise bug description>"
git push -u origin HEAD
```
### Verify CI Failure
```bash
gh run list --branch $(git branch --show-current) --limit 1
```
**STOP HERE.** Inform the user of the CI status and wait for confirmation before proceeding to Step 2.
- If CI passes: the test does not catch the bug. Revisit the test.
- If CI fails for unrelated reasons: investigate and fix the test setup, not the bug.
- If CI fails because the test correctly catches the bug: proceed to Step 2.
## Step 2 — Green: Minimal Fix
Write the minimum code change needed to make the failing test pass.
### Rules
- Do NOT modify, weaken, or delete the test from Step 1 — it is immutable. If the test needs changes, restart from Step 1 and re-prove the red.
- Do NOT add new tests (tests were finalized in Step 1)
- Do NOT refactor, clean up, or make "drive-by" improvements
- Do NOT modify code unrelated to the bug
- The fix should be the smallest correct change
### Quality Checks and Commit
```bash
pnpm typecheck
pnpm lint
pnpm format
git add <fix-files-only>
git commit -m "fix: <concise bug description>"
git push
```
### Verify CI Pass
```bash
gh run list --branch $(git branch --show-current) --limit 1
```
- If CI passes: the fix is verified. Proceed to PR creation.
- If CI fails: investigate and fix. Do NOT change the test from Step 1.
## Step 3 — Open Pull Request
```bash
gh pr create --title "fix: <description>" --body "$(cat <<'EOF'
## Summary
<Brief explanation of the bug and root cause>
- Fixes #<issue-number>
## Red-Green Verification
| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: ...` | :red_circle: Red | Proves the test catches the bug |
| `fix: ...` | :green_circle: Green | Proves the fix resolves the bug |
## Test Plan
- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] Added/updated E2E regression under `browser_tests/` or explained why not applicable
- [ ] Manual verification (if applicable)
EOF
)"
```
## Gotchas
### CI fails on test commit for unrelated reasons
Lint, typecheck, or other tests may fail — not just your new test. Check the CI logs carefully. If the failure is unrelated, fix it in a separate commit before the `test:` commit so the red X is clearly attributable to your test.
### Test passes when it should fail
The bug may only manifest under specific conditions (e.g., Windows paths, external model directories, certain workflow structures). Make sure your test setup matches the actual bug scenario. Check that you're not accidentally testing the happy path.
### Flaky Playwright tests
If your e2e test is intermittent, it doesn't prove anything. Use retrying assertions (`toBeVisible`, `toHaveText`) instead of `waitForTimeout`. See the `/writing-playwright-tests` skill for anti-patterns.
### Pre-existing CI failures on main
If main itself is red, branch from the last green commit or fix the pre-existing failure first. A red-green proof is meaningless if the baseline is already red.
## Reference
| Resource | Path |
| --------------------- | -------------------------------------------------- |
| Unit test framework | Vitest (`src/**/*.test.ts`) |
| E2E test framework | Playwright (`browser_tests/tests/*.spec.ts`) |
| E2E fixtures | `browser_tests/fixtures/` |
| E2E assets | `browser_tests/assets/` |
| Playwright skill | `.claude/skills/writing-playwright-tests/SKILL.md` |
| Unit CI | `.github/workflows/ci-tests-unit.yaml` |
| E2E CI | `.github/workflows/ci-tests-e2e.yaml` |
| Lint CI | `.github/workflows/ci-lint-format.yaml` |
| Testing anti-patterns | `reference/testing-anti-patterns.md` |
| Related skill | `.claude/skills/perf-fix-with-proof/SKILL.md` |

View File

@@ -0,0 +1,214 @@
# Testing Anti-Patterns for Red-Green Fixes
Common mistakes that undermine the red-green proof. Avoid these when writing the test commit (Step 1).
## Testing Implementation Details
Test observable behavior, not internal state.
**Bad** — coupling to internals:
```typescript
it('uses cache internally', () => {
const service = new UserService()
service.getUser(1)
expect(service._cache.has(1)).toBe(true) // Implementation detail
})
```
**Good** — testing through the public interface:
```typescript
it('returns same user on repeated calls', async () => {
const service = new UserService()
const user1 = await service.getUser(1)
const user2 = await service.getUser(1)
expect(user1).toBe(user2) // Behavior, not implementation
})
```
Why this matters for red-green: if your test is coupled to internals, a valid fix that changes the implementation may break the test — even though the bug is fixed. The green commit should only require changing source code, not rewriting the test.
## Assertion-Free Tests
Every test must assert something meaningful. A test without assertions always passes — it cannot produce the red X needed in Step 1.
**Bad**:
```typescript
it('processes the download', () => {
processDownload('/models/checkpoints', 'model.safetensors')
// No expect()!
})
```
**Good**:
```typescript
it('processes the download to correct path', () => {
const result = processDownload('/models/checkpoints', 'model.safetensors')
expect(result.savePath).toBe('/models/checkpoints/model.safetensors')
})
```
## Over-Mocking
Mock only system boundaries (network, filesystem, Electron APIs). If you mock the module under test, you are testing your mocks — the test will not detect the real bug.
**Bad** — mocking everything:
```typescript
vi.mock('./pathResolver')
vi.mock('./validator')
vi.mock('./downloader')
it('downloads model', () => {
// This only tests that mocks were called, not that the bug exists
})
```
**Good** — mock only the boundary:
```typescript
vi.mock('./electronAPI') // Boundary: Electron IPC
it('resolves absolute path correctly', () => {
const result = resolveModelPath('/root/models', '/root/models/checkpoints')
expect(result).toBe('/root/models/checkpoints')
})
```
See also: [Don't Mock What You Don't Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
## Giant Tests
A test that covers the entire flow makes it hard to pinpoint which part catches the bug. Keep it focused — one concept per test.
**Bad**:
```typescript
it('full model download flow', async () => {
// 80 lines: load workflow, open dialog, select model,
// click download, verify path, check progress, confirm completion
})
```
**Good**:
```typescript
it('resolves absolute savePath without nesting under modelsDirectory', () => {
const result = getLocalSavePath(
'/models',
'/models/checkpoints',
'file.safetensors'
)
expect(result).toBe('/models/checkpoints/file.safetensors')
})
```
If the bug is in path resolution, test path resolution — not the entire download flow.
## Test Duplication
Duplicated test code hides what actually differs between cases. Use parameterized tests.
**Bad**:
```typescript
it('resolves checkpoints path', () => {
expect(resolve('/models', '/models/checkpoints', 'a.safetensors')).toBe(
'/models/checkpoints/a.safetensors'
)
})
it('resolves loras path', () => {
expect(resolve('/models', '/models/loras', 'b.safetensors')).toBe(
'/models/loras/b.safetensors'
)
})
```
**Good**:
```typescript
it.each([
['/models/checkpoints', 'a.safetensors', '/models/checkpoints/a.safetensors'],
['/models/loras', 'b.safetensors', '/models/loras/b.safetensors']
])('resolves %s/%s to %s', (dir, file, expected) => {
expect(resolve('/models', dir, file)).toBe(expected)
})
```
## Flaky Tests
A flaky test cannot prove anything — it may show red for reasons unrelated to the bug, or green despite the bug still existing.
**Common causes in this codebase:**
| Cause | Fix |
| -------------------------------------- | --------------------------------------- |
| Missing `nextFrame()` after canvas ops | Add `await comfyPage.nextFrame()` |
| `waitForTimeout` instead of assertions | Use `toBeVisible()`, `toHaveText()` |
| Shared state between tests | Isolate with `afterEach` / `beforeEach` |
| Timing-dependent logic | Use `expect.poll()` or `toPass()` |
## Gaming the Red-Green Process
These are ways the red-green proof gets invalidated during Step 2 (the fix commit). The test from Step 1 is immutable — if any of these happen, restart from Step 1.
**Weakening the assertion to make it pass:**
```typescript
// Step 1 (red) — strict assertion
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
// Step 2 (green) — weakened to pass without a real fix
expect(result).toBeDefined() // This proves nothing
```
**Updating snapshots to bless the bug:**
```bash
# Instead of fixing the code, just updating the snapshot to match buggy output
pnpm test:unit -- --update
```
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
**Adding mocks in Step 2 that hide the failure:**
```typescript
// Step 2 adds a mock that didn't exist in Step 1
vi.mock('./pathResolver', () => ({
resolve: () => '/expected/path' // Hardcoded to pass
}))
```
Step 2 should only change source code — not test infrastructure.
## Testing the Happy Path Only
The red-green pattern specifically requires the test to exercise the **broken path**. If you only test the case that already works, the test will pass (green) on Step 1 — defeating the purpose.
**Bad** — testing the default case that works:
```typescript
it('downloads to default models directory', () => {
// This already works — it won't produce a red X
const result = resolve('/models', 'checkpoints', 'file.safetensors')
expect(result).toBe('/models/checkpoints/file.safetensors')
})
```
**Good** — testing the case that is actually broken:
```typescript
it('downloads to external models directory configured via extra_model_paths', () => {
// This is the broken case — absolute path from folder_paths API
const result = resolve(
'/models',
'/external/drive/models/checkpoints',
'file.safetensors'
)
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
})
```

2
.gitattributes vendored
View File

@@ -3,4 +3,6 @@
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
packages/ingest-types/src/types.gen.ts linguist-generated=true
packages/ingest-types/src/zod.gen.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true

View File

@@ -10,7 +10,7 @@ on:
concurrency:
group: perf-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false
permissions:
contents: read
@@ -26,12 +26,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
contents: write
packages: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup frontend
uses: ./.github/actions/setup-frontend
@@ -68,3 +71,44 @@ jobs:
with:
name: perf-meta
path: temp/perf-meta/
- name: Save perf baseline to perf-data branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
continue-on-error: true
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
cp test-results/perf-metrics.json /tmp/perf-metrics.json
git fetch origin perf-data || {
echo "Creating perf-data branch"
git checkout --orphan perf-data
git rm -rf . 2>/dev/null || true
echo "# Performance Baselines" > README.md
mkdir -p baselines
git add README.md baselines
git commit -m "Initialize perf-data branch"
git push origin perf-data
git fetch origin perf-data
}
git worktree add /tmp/perf-data origin/perf-data
TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ)
SHA=$(echo "${{ github.sha }}" | cut -c1-8)
mkdir -p /tmp/perf-data/baselines
cp /tmp/perf-metrics.json "/tmp/perf-data/baselines/perf-${TIMESTAMP}-${SHA}.json"
# Keep only last 20 baselines
cd /tmp/perf-data
ls -t baselines/perf-*.json 2>/dev/null | tail -n +21 | xargs -r rm
git -C /tmp/perf-data add baselines/
git -C /tmp/perf-data commit -m "perf: add baseline for ${SHA}" || echo "No changes to commit"
git -C /tmp/perf-data push origin HEAD:perf-data
git worktree remove /tmp/perf-data --force 2>/dev/null || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,6 +10,7 @@ permissions:
contents: read
pull-requests: write
issues: write
actions: read
jobs:
comment:
@@ -73,7 +74,28 @@ jobs:
core.setOutput('number', String(pr.number));
core.setOutput('base', trustedBase);
- name: Check if results are still current
id: sha-check
uses: actions/github-script@v8
with:
script: |
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const runSha = context.payload.workflow_run.head_sha;
const currentSha = pr.head.sha;
if (runSha !== currentSha) {
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
core.setOutput('stale', 'true');
} else {
core.setOutput('stale', 'false');
}
- name: Download PR perf metrics
if: steps.sha-check.outputs.stale != 'true'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: perf-metrics
@@ -81,6 +103,7 @@ jobs:
path: test-results/
- name: Download baseline perf metrics
if: steps.sha-check.outputs.stale != 'true'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
@@ -90,10 +113,33 @@ jobs:
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Load historical baselines from perf-data branch
if: steps.sha-check.outputs.stale != 'true'
continue-on-error: true
run: |
mkdir -p temp/perf-history
git fetch origin perf-data 2>/dev/null || {
echo "perf-data branch not found, skipping historical data"
exit 0
}
INDEX=0
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Loaded $INDEX historical baselines"
- name: Generate perf report
if: steps.sha-check.outputs.stale != 'true'
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Post PR comment
if: steps.sha-check.outputs.stale != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [791.59912109375, 386.13336181640625],
"size": [140, 26],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,

View File

@@ -431,9 +431,9 @@ export const comfyPageFixture = base.extend<{
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,
// Browser tests should opt into missing-model warnings explicitly so
// workflows do not render differently based on models present on disk.
'Comfy.Workflow.ShowMissingModelsWarning': false
// Disable errors tab to prevent missing model detection from
// rendering error indicators on nodes during unrelated tests.
'Comfy.RightSidePanel.ShowErrorsTab': false
})
} catch (e) {
console.error(e)

View File

@@ -8,6 +8,10 @@ interface PerfSnapshot {
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
Nodes: number
JSHeapTotalSize: number
ScriptDuration: number
JSEventListeners: number
}
export interface PerfMeasurement {
@@ -19,6 +23,12 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
}
export class PerformanceHelper {
@@ -59,16 +69,100 @@ export class PerformanceHelper {
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
Timestamp: get('Timestamp'),
Nodes: get('Nodes'),
JSHeapTotalSize: get('JSHeapTotalSize'),
ScriptDuration: get('ScriptDuration'),
JSEventListeners: get('JSEventListeners')
}
}
/**
* Collect longtask entries from PerformanceObserver and compute TBT.
* TBT = sum of (duration - 50ms) for every task longer than 50ms.
*/
private async collectTBT(): Promise<number> {
return this.page.evaluate(() => {
const state = (window as unknown as Record<string, unknown>)
.__perfLongtaskState as
| { observer: PerformanceObserver; tbtMs: number }
| undefined
if (!state) return 0
// Flush any queued-but-undelivered entries into our accumulator
for (const entry of state.observer.takeRecords()) {
if (entry.duration > 50) state.tbtMs += entry.duration - 50
}
const result = state.tbtMs
state.tbtMs = 0
return result
})
}
/**
* Measure average frame duration via rAF timing over a sample window.
* Returns average ms per frame (lower = better, 16.67 = 60fps).
*/
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
return this.page.evaluate((frames) => {
return new Promise<number>((resolve) => {
const timeout = setTimeout(() => resolve(0), 5000)
const timestamps: number[] = []
let count = 0
function tick(ts: number) {
timestamps.push(ts)
count++
if (count <= frames) {
requestAnimationFrame(tick)
} else {
clearTimeout(timeout)
if (timestamps.length < 2) {
resolve(0)
return
}
const total = timestamps[timestamps.length - 1] - timestamps[0]
resolve(total / (timestamps.length - 1))
}
}
requestAnimationFrame(tick)
})
}, sampleFrames)
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(
'Measurement already in progress — call stopMeasuring() first'
)
}
// Install longtask observer if not already present, then reset the
// accumulator so old longtasks don't bleed into the new measurement window.
await this.page.evaluate(() => {
const win = window as unknown as Record<string, unknown>
if (!win.__perfLongtaskState) {
const state: { observer: PerformanceObserver; tbtMs: number } = {
observer: new PerformanceObserver((list) => {
const self = (window as unknown as Record<string, unknown>)
.__perfLongtaskState as {
observer: PerformanceObserver
tbtMs: number
}
for (const entry of list.getEntries()) {
if (entry.duration > 50) self.tbtMs += entry.duration - 50
}
}),
tbtMs: 0
}
state.observer.observe({ type: 'longtask', buffered: true })
win.__perfLongtaskState = state
}
const state = win.__perfLongtaskState as {
observer: PerformanceObserver
tbtMs: number
}
state.tbtMs = 0
state.observer.takeRecords()
})
this.snapshot = await this.getSnapshot()
}
@@ -82,6 +176,11 @@ export class PerformanceHelper {
return after[key] - before[key]
}
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
this.collectTBT(),
this.measureFrameDuration()
])
return {
name,
durationMs: delta('Timestamp') * 1000,
@@ -90,7 +189,13 @@ export class PerformanceHelper {
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
heapDeltaBytes: delta('JSHeapUsedSize'),
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,
eventListeners: delta('JSEventListeners'),
totalBlockingTimeMs,
frameDurationMs
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,4 +1,3 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings/types'
@@ -72,6 +71,10 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
test.describe('Execution error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
@@ -88,117 +91,58 @@ test.describe('Execution error', () => {
})
})
test.describe('Missing models warning', () => {
test('Should be disabled by default in browser tests', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test.describe('Missing models in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.page.evaluate((url: string) => {
return fetch(`${url}/api/devtools/cleanup_fake_model`)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
})
test('Should display a warning when missing models are found', async ({
test('Should show error overlay with missing models when workflow has missing models', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
test('Should show missing models from node properties', async ({
comfyPage
}) => {
// Load workflow that has a node with models metadata at the node level
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_from_node_properties'
)
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
test('Should not show missing models when widget values have changed', async ({
comfyPage
}) => {
const modelFoldersRes = {
status: 200,
body: JSON.stringify([
{
name: 'text_encoders',
folders: ['ComfyUI/models/text_encoders']
}
])
}
await comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
)
// Reload page to trigger indexing of model folders
await comfyPage.setup()
const clipModelsRes = {
status: 200,
body: JSON.stringify([
{
name: 'fake_model.safetensors',
pathIndex: 0
}
])
}
await comfyPage.page.route(
'**/api/experiment/models/text_encoders',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
comfyPage
}) => {
// This tests the scenario where outdated model metadata exists in the workflow
// but the actual selected models (widget values) have changed
await comfyPage.workflow.loadWorkflow(
'missing/model_metadata_widget_mismatch'
)
// The missing models warning should NOT appear
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).not.toBeVisible()
})
// Flaky test after parallelization
@@ -206,14 +150,10 @@ test.describe('Missing models warning', () => {
test.skip('Should download missing model when clicking download button', async ({
comfyPage
}) => {
// The fake_model.safetensors is served by
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
@@ -223,50 +163,6 @@ test.describe('Missing models warning', () => {
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
test.describe('Do not show again checkbox', () => {
let checkbox: Locator
let closeButton: Locator
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
checkbox = comfyPage.page.getByLabel("Don't show this again")
closeButton = comfyPage.page.getByLabel('Close')
})
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await checkbox.click()
await changeSettingPromise
await closeButton.click()
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(false)
})
test('Should keep warning dialog enabled when checkbox is unchecked', async ({
comfyPage
}) => {
await closeButton.click()
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(true)
})
})
})
test.describe('Settings', () => {

View File

@@ -9,6 +9,10 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Pasted group is offset from original position', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const positions = await comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
expect(positions).toHaveLength(2)
const dx = Math.abs(positions[0].x - positions[1].x)
const dy = Math.abs(positions[0].y - positions[1].y)
expect(dx).toBeCloseTo(50, 0)
expect(dy).toBeCloseTo(15, 0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,3 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { recordMeasurement } from '../helpers/perfReporter'
@@ -107,6 +109,51 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph idle rendering', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.perf.startMeasuring()
// Let the large graph idle for 2 seconds — measures compositor and
// style recalculation cost at scale (245 nodes).
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-graph-idle')
recordMeasurement(m)
console.log(
`Large graph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('large graph pan interaction', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
await comfyPage.perf.startMeasuring()
// Simulate panning across a large graph — stresses compositor
// layer management and transform recalculation.
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down({ button: 'middle' })
for (let i = 0; i < 60; i++) {
await comfyPage.page.mouse.move(centerX + i * 5, centerY + i * 2)
await comfyPage.nextFrame()
}
await comfyPage.page.mouse.up({ button: 'middle' })
const m = await comfyPage.perf.stopMeasuring('large-graph-pan')
recordMeasurement(m)
console.log(
`Large graph pan: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.taskDurationMs.toFixed(1)}ms task`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {
@@ -129,4 +176,70 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
recordMeasurement(m)
console.log(`Subgraph clipping: ${m.layouts} forced layouts`)
})
test('canvas zoom sweep', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Zoom in 10 steps, then zoom out 10 steps
for (let i = 0; i < 10; i++) {
await comfyPage.canvasOps.zoom(-100)
await comfyPage.nextFrame()
}
for (let i = 0; i < 10; i++) {
await comfyPage.canvasOps.zoom(100)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('canvas-zoom-sweep')
recordMeasurement(m)
console.log(
`Zoom sweep: ${m.layouts} layouts, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('minimap idle', async ({ comfyPage }) => {
// Enable minimap via setting, load workflow, then measure idle cost
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
// Wait for minimap to render
await comfyPage.page
.locator('.litegraph-minimap')
.waitFor({ state: 'visible', timeout: 5000 })
await comfyPage.perf.startMeasuring()
// Idle for 2 seconds with minimap open and 245 nodes
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('minimap-idle')
recordMeasurement(m)
console.log(
`Minimap idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
await comfyPage.perf.startMeasuring()
// Queue the prompt and wait for execution to complete
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
// Wait for the output widget to populate (execution_success)
const outputNode = await comfyPage.nodeOps.getNodeRefById(1)
await expect(async () => {
expect(await (await outputNode.getWidget(0)).getValue()).toBe('foo')
}).toPass({ timeout: 10000 })
const m = await comfyPage.perf.stopMeasuring('workflow-execution')
recordMeasurement(m)
console.log(
`Workflow execution: ${m.durationMs.toFixed(0)}ms total, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 103 KiB

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