Compare commits

..

16 Commits

Author SHA1 Message Date
Comfy Org PR Bot
e68d50e677 1.45.4 (#12118)
Patch version increment to 1.45.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12118-1-45-4-35d6d73d365081fcb5f5d06dec17bb59)
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-05-11 04:43:31 +00:00
Comfy Org PR Bot
48b5e0165a 1.45.3 (#12113)
Patch version increment to 1.45.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12113-1-45-3-35c6d73d365081468180cefef02dca03)
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-05-11 00:01:07 +00:00
Christian Byrne
fe1de3b254 refactor: remove dedup complexity from reportInactiveTrackerCall (#11833)
## Summary

Remove the module-level `reportedInactiveCalls: Set<string>` and the
early-return dedup check from `reportInactiveTrackerCall()` in
`src/scripts/changeTracker.ts`. Every invocation now emits
`console.warn` and (on Desktop) `Sentry.captureMessage` unconditionally.

## Why

The dedup was added in #11328 but is unnecessary:
- Every first-party call site already goes through the
`activeWorkflow?.changeTracker` guard, so flooding from in-repo code is
unlikely.
- Repeated identical alerts may actually provide more diagnostic signal
than the first-only approach suppresses.

## Changes

- Drop `reportedInactiveCalls` Set
- Drop the per-`(method, workflowPath)` early-return
- Trim the JSDoc accordingly

No behavior change for callers (`deactivate`, `captureCanvasState`);
only the reporting frequency increases.

## Verification

- `pnpm test:unit -- src/scripts/changeTracker.test.ts` — 16/16 passing
- `pnpm typecheck` — clean
- ESLint / oxfmt — clean

- Fixes #11372

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11833-refactor-remove-dedup-complexity-from-reportInactiveTrackerCall-3546d73d365081fabf57cbf1fa17051f)
by [Unito](https://www.unito.io)
2026-05-10 05:22:30 +00:00
Christian Byrne
1c2ae70343 chore(#11843): replace bare string NodeId typings in parameters tab components (#12014)
## Summary

Replace `nodeId: string` with canonical `NodeId` type in right-side
panel parameters tab components, eliminating redundant `String()`
conversions at call sites.

## Changes

- `TabNodes.vue`: `isSectionCollapsed` and `setSectionCollapsed` now
accept `NodeId` instead of `string`; callers updated to pass `node.id`
directly (removing `String()` wrapping)
- `TabNormalInputs.vue`: same pattern

## Notes

The other 6 files listed in the issue use `nodeId` parameters that carry
execution IDs (`NodeExecutionId = string`), not graph node IDs (`NodeId
= number | string`). Changing those to `NodeId` would be semantically
incorrect. The two files changed here are the clear-cut cases where
`node.id` (a `NodeId`) was being unnecessarily stringified before being
passed.

## Testing

### Automated

- `pnpm typecheck` — passes
- `pnpm lint` — passes (0 warnings, 0 errors)
- `pnpm format:check` — passes

### E2E Verification Steps

1. Open ComfyUI frontend
2. Load a workflow with multiple nodes
3. Open the right side panel (Parameters tab)
4. Verify node sections collapse/expand correctly per node
5. Verify "Collapse All" / "Expand All" toggle works correctly
6. Repeat with both TabNodes and TabNormalInputs views

Fixes #11843

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12014-chore-11843-replace-bare-string-NodeId-typings-in-parameters-tab-components-3586d73d365081ed84caf560277f0553)
by [Unito](https://www.unito.io)
2026-05-10 05:21:36 +00:00
jaeone94
8f68be5699 fix: handle annotated output media paths in missing media scan (#12069)
## Summary

This PR fixes missing-media false positives for annotated media widget
values such as:

```txt
photo.png [output]
clip.mp4 [input]
147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]
clip.mp4[input]  // Cloud compact form
```

The change is intentionally scoped to the missing-media detection
pipeline for:

- `LoadImage`
- `LoadImageMask`
- `LoadVideo`
- `LoadAudio`

It preserves the raw widget value on `MissingMediaCandidate.name` for UI
display, grouping, replacement, and user-facing missing-media rows.
Normalized values are used only as comparison keys during verification.

## Diff Size

`main...HEAD` line diff is currently:

- Production/runtime code: `+478 / -37` (`515` changed lines)
- Unit test code: `+960 / -47` (`1,007` changed lines)
- Total: `+1,438 / -84` (`1,522` changed lines)

The PR looks large mostly because it locks both Cloud and OSS/Core
runtime paths with unit coverage; the production/runtime change is about
one third of the total diff.

## What Changed

- Added missing-media-scoped annotation helpers for detection-only path
normalization.
  - Core/OSS recognizes spaced suffixes like `file.png [output]`.
  - Cloud also recognizes compact suffixes like `file.png[output]`.
- User-selectable trailing `input` and `output` annotations are
normalized for matching.
- Unknown annotations and middle-of-filename annotations are left
unchanged.
- Added shared file-path helpers in `formatUtil`:
  - `joinFilePath(subfolder, filename)`
  - `getFilePathSeparatorVariants(filepath)`
- Updated media verification to compare candidates against both raw and
normalized match keys.
- Kept input candidates and generated output candidates in separate
identifier sets so an input asset cannot accidentally satisfy an output
reference with the same name.
- Moved missing-media source loading into `missingMediaAssetResolver` so
`missingMediaScan` remains focused on scan/verification orchestration.
- Updated Cloud generated-media verification to use the Cloud assets API
instead of job history:
  - Cloud input candidates use input/public assets.
  - Cloud output candidates use `output` tagged assets.
- Kept OSS/Core generated-media verification history-based, matching the
current generated-picker/widget availability model.

## Runtime Verification Paths

### Cloud

Cloud stores generated outputs as asset records. For an annotated output
value, this PR verifies against the `output` asset tag rather than job
history.

```txt
Widget value
  "147257...d6e.png [output]"
        |
        v
Detection keys
  "147257...d6e.png [output]"
  "147257...d6e.png"
        |
        v
Cloud asset sources
  input candidates  -> /api/assets?include_tags=input&include_public=true
  output candidates -> /api/assets?include_tags=output&include_public=true
        |
        v
Match against
  asset.name
  asset.asset_hash
  subfolder/asset.name
  subfolder/asset.asset_hash
  slash and backslash separator variants
```

Example:

```ts
candidate.name = 'abc123.png [output]'
asset.name = 'ComfyUI_00001_.png'
asset.asset_hash = 'abc123.png'
asset.tags = ['output']

// Result: not missing
```

### OSS / Core

Core widget options for the normal loader nodes are input-folder based.
Annotated output values are resolved by Core through
`folder_paths.get_annotated_filepath()`, but the current generated
picker path is history-backed. This PR keeps OSS generated verification
aligned with that widget availability model instead of treating the full
output folder as the source of truth.

```txt
Widget value
  "subfolder/photo.png [output]"
        |
        v
Detection keys
  "subfolder/photo.png [output]"
  "subfolder/photo.png"
        |
        v
OSS generated source
  fetchHistoryPage(...)
        |
        v
History preview_output
  filename: "photo.png"
  subfolder: "subfolder"
        |
        v
Generated match keys
  "subfolder/photo.png"
  "subfolder\\photo.png"
```

This means OSS/Core verification is about whether the generated media is
currently available through the same generated/history-backed path the
widget uses, not a full disk-level executability check across the entire
output directory.

## Why Not Consolidate All Annotated Path Parsers

There are existing annotated-path parsers in image widget, Load3D, and
path creation code. This PR does not replace them.

The helper added here is detection-only: it strips annotations to build
comparison keys for missing-media verification. Parser consolidation
across widget implementations is intentionally left out of scope to keep
this fix narrow.

## Known Follow-Ups / Out Of Scope

- FE-620 tracks the separate video drag-and-drop upload race between
upload completion and missing-media detection.
- Published/shared workflow assets are still not fully represented by
`/api/assets?include_public=true`; that remains a backend/API contract
issue.
- A future backend/API contract that answers “is this workflow media
executable?” would be preferable to stitching together runtime-specific
FE sources.
- OSS/Core full output-folder scanning via `/internal/files/output` was
considered, but that endpoint is internal, shallow (`os.scandir`), and
not the same source currently used by the generated picker flow.

## Validation

- `pnpm test:unit -- missingMediaAssetResolver missingMediaScan
mediaPathDetectionUtil formatUtil`
- touched files `oxfmt`
- touched files `oxlint --fix`
- touched files `eslint --cache --fix --no-warn-ignored`
- `pnpm typecheck`
- pre-commit `pnpm knip --cache`
- pre-push `pnpm knip --cache`

`knip` passes with the existing tag hint:

```txt
Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes
```

## Screenshots

Before 


https://github.com/user-attachments/assets/50eab565-3160-4a57-a758-87ec2c09071e


After 


https://github.com/user-attachments/assets/08adcbbd-c3fc-43f9-b86c-327e4eb5abd8


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12069-fix-handle-annotated-output-media-paths-in-missing-media-scan-3596d73d365081f4afa3d4dd45cad3da)
by [Unito](https://www.unito.io)
2026-05-09 05:36:09 +00:00
Terry Jia
653ef1a4f0 Handle Load3D "none" model selection in frontend (#11178)
## Summary
Load3D now supports panoramic images and HDRI loading, it can serve as a
viewer for those without requiring a 3D model. Previously, the node
required a model file to execute. Rather than making the input optional
(which would break existing workflows that rely on it being required), a
"none" option is added to the combo list, allowing users to run Load3D
with no model loaded.

BE change is https://github.com/Comfy-Org/ComfyUI/pull/13379

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11178-Handle-Load3D-none-model-selection-in-frontend-3416d73d365081e589b3d89bc67f75e7)
by [Unito](https://www.unito.io)
2026-05-09 01:26:37 -04:00
Christian Byrne
c16052e2e3 feat: sort right-click context menu categories alphabetically (#12039)
*PR Created by the Glary-Bot Agent*

---

## Summary

Sort the canvas right-click "Add Node" context menu by display name
(case-insensitive, natural numeric). Previously, both category submenus
and leaf nodes appeared in node-registration order, making the menu hard
to scan for users browsing for nodes.

This change is scoped specifically to the **smaller right-click
contextual menu**. It does NOT affect the double-click search menu or
the left-side Nodes panel.

## Changes

- `src/lib/litegraph/src/LGraphCanvas.ts` — In `onMenuAdd` →
`inner_onMenuAdded`, sort the deduplicated category submenu entries and
the leaf-node entries by `content` using `localeCompare` with `{
numeric: true, sensitivity: 'base' }`. Categories still appear before
leaf nodes within a level (preserves existing UX).
- `src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts` — New unit
tests that mock `LiteGraph.ContextMenu` and assert: case-insensitive
sort, natural numeric ordering (`Cat1`, `Cat2`, `Cat10`), leaf-node
sorting inside a category, and category-before-leaf placement.

## Verification

- `pnpm vitest run src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts`
— 4/4 pass
- `pnpm typecheck` — clean (ran via pre-commit hook on initial commit)
- `oxfmt` / `oxlint` / `eslint` — clean
- Oracle review against `main` returned 0 critical / 1 warning (test
coverage) / 1 suggestion (numeric sort) — both addressed in this PR.

## Notes

- The sort is applied at the menu-build site rather than inside
`LiteGraphGlobal.getNodeTypesCategories`/`getNodeTypesInCategory` to
keep the change scoped to the menu UX and avoid changing the iteration
order seen by extensions that consume those public methods.
- Per user request, this is opening as a draft PR for self-review +
CodeRabbit feedback in a single follow-up pass; manual browser
verification (right-click screenshots) was deferred to that pass.
- Slack thread context: user reported the contextual menu is "a mess"
for discovering native nodes; alphabetical sorting addresses the
discoverability problem without touching the search-oriented menus.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12039-feat-sort-right-click-context-menu-categories-alphabetically-3596d73d36508107a87ffec1c353994e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexis@comfy.org>
2026-05-09 03:31:11 +00:00
Comfy Org PR Bot
3e94459340 1.45.2 (#12096)
Patch version increment to 1.45.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12096-1-45-2-35b6d73d36508193be00c1c878d42c2a)
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-05-09 02:07:47 +00:00
Henry Lee
ca54877f9d fix(assets): strip directory annotation from input filenames (#12086)
## Summary

Imported assets render as a generic check-check icon instead of a
thumbnail because the OSS `/internal/files/{type}` endpoint returns
annotated filenames (`photo.png [input]`) that the assets-sidebar mapper
passes through verbatim, which breaks extension-based media-type
detection.

## Changes

- **What**: Strip ComfyUI's trailing directory-type annotation (`
[input]`, ` [output]`, `[temp]`) in `mapInputFileToAssetItem` so `name`,
`id`, and the generated `/view?filename=…` URL all use the canonical
on-disk filename. Adds a focused unit test.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

### Root cause

ComfyUI core PR
[comfyanonymous/ComfyUI#13078](https://github.com/comfyanonymous/ComfyUI/pull/13078)
(April 2026) changed `/internal/files/{type}` to append the directory
type to each entry:

```python
# api_server/routes/internal/internal_routes.py
return web.json_response(
    [f"{entry.name} [{directory_type}]" for entry in sorted_files], status=200
)
```

The annotation is the wire format `LoadImage`-style widgets expect, so
the backend change is correct. The assets-sidebar mapper treated the
response strings as raw filenames. After
[#8914](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8914) changed
`getMediaTypeFromFilename` to default unknown extensions to `'other'`,
every input asset now routes to `MediaOtherTop` and renders as
`icon-[lucide--check-check]`:

```
getMediaTypeFromFilename("photo.png [input]").split('.').pop() === "png [input]" → 'other'
```

The strip happens at data ingestion so every consumer of
`AssetItem.name` (sidebar grid, list, filter, gallery, drag-drop,
delete) gets the canonical filename automatically. OSS-only — Cloud
paths get clean names from the cloud API and are unaffected.

Reproduces locally on stock OSS ComfyUI on `main` of both repos; no
public issue tracker entry.

## Screenshots (if applicable)
Before:
<img width="1091" height="718" alt="image"
src="https://github.com/user-attachments/assets/ff1f070d-da39-4e5a-bc6d-99b7214f7da8"
/>

After:
<img width="1089" height="716" alt="image"
src="https://github.com/user-attachments/assets/7123d9bf-f7dd-4430-b6f7-f6702b70baaa"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12086-fix-assets-strip-directory-annotation-from-input-filenames-35a6d73d365081e9b9eed7d8630d6f0b)
by [Unito](https://www.unito.io)
2026-05-08 21:31:57 +00:00
Simon Pinfold
a4faaa0159 fix: disable ultralytics asset-browser registration (#12075)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disable the `UltralyticsDetectorProvider` model-to-node mapping so the
node falls back to the static combo populated from `/api/object_info`,
restoring pre-#8468 behavior on cloud.

## Why

PR #8468 opted `UltralyticsDetectorProvider` into the cloud asset-widget
path, which exposed a latent mismatch in cloud asset metadata for
nested-directory model folders. The bug has two independent halves, and
a fix that addresses only one will still leave the workflow broken at
execution time:

- **Tag lookup mismatch.** Cloud stores tags as combined values like
`ultralytics/bbox`, while the asset query asks for split tags (`models`
+ `ultralytics`) with exact-match filtering — so the dropdown returns no
results.
- **Submitted value mismatch.** Cloud stores filenames as basenames, but
the node expects subdirectory-prefixed values (e.g.
`bbox/face_yolov8m.pt`) that the static combo path normally produces.

Both halves require cloud-side fixes (asset ingestion + metadata) before
the asset-browser registration can be safely re-enabled. Until then,
removing the registration restores the working static-combo behavior so
users are unblocked.

## Changes

- `src/platform/assets/mappings/modelNodeMappings.ts`: comment out the
`['ultralytics', 'UltralyticsDetectorProvider', 'model_name']` entry
with a note pointing at BE-689 and the re-enablement criteria.
- `src/stores/modelToNodeStore.test.ts`: drop the now-stale ultralytics
expectations from `EXPECTED_DEFAULT_TYPES`, `MOCK_NODE_NAMES`, and the
hierarchical-fallback `it.each` cases.

## Verification

Local quality gates:
- `pnpm typecheck` — clean
- `pnpm lint` — clean (3 pre-existing warnings, 0 errors)
- `pnpm format:check` — clean
- `pnpm knip` — clean (1 pre-existing warning unrelated to this change)
- `pnpm test:unit -- src/stores/modelToNodeStore.test.ts` — 51/51
passing

Manual runtime verification (dev server + Playwright against the live
module):
- `MODEL_NODE_MAPPINGS` no longer contains any entry where `[0] ===
'ultralytics'` or `[1] === 'UltralyticsDetectorProvider'` (84 entries
total, 0 ultralytics).
- `useModelToNodeStore().getNodeProvider('ultralytics')` returns `null`
after `registerDefaults()`, so the asset-widget path is no longer
triggered for this node.
- `getNodeProvider('ultralytics/bbox')` also returns `null`, confirming
hierarchical fallback no longer resolves to the disabled mapping.
- `getNodeProvider('checkpoints')` still resolves to
`CheckpointLoaderSimple`, confirming unrelated mappings are intact.

End-to-end cloud verification (actually exercising the asset-browser
path against cloud-seeded ultralytics metadata) is not possible in the
local sandbox, since the regression depends on the cloud's asset
ingestion data shape. The change is a single mapping-table removal that
reverts to the well-exercised static-combo path covered by the updated
unit tests.

Long-term cloud-side fix is tracked in BE-689.

- Fixes BE-689

## Screenshots

![ComfyUI dev server loaded with the patched
build](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7282fc1868f7d89220fa1fbf9f0dcd5dbd55713288d3a3310e99d1cc5768e7d7/pr-images/1778229906648-a825191d-85d8-4a09-adc4-4fb3402d3e92.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12075-fix-disable-ultralytics-asset-browser-registration-35a6d73d36508179b394f0915e69742e)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-08 13:53:22 -07:00
Dante
8108967d49 feat(dialog): migrate Prompt + Confirmation dialogs to Reka-UI (Phase 1) (#12041)
## Summary

Phase 1 of the dialog migration kicked off in #11719. Migrates the two
simplest production dialogs — `PromptDialogContent` and
`ConfirmationDialogContent` — from PrimeVue `Dialog` onto the Reka-UI
primitives landed in Phase 0. Public API of `useDialogService` /
`dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-573](https://linear.app/comfyorg/issue/FE-573/phase-1-migrate-promptdialog-confirmationdialog-closes-11688)
Predecessor: #11719 (merged at `0788e7139`)

Refs #11688 (closed manually after Phase 0; the actual user-visible
max-width fix ships in this PR)

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size | Width override |
| --- | --- | --- | --- |
| `prompt()` | `'reka'` | `md` | — |
| `confirm()` | `'reka'` | `md` | — |
| `showBillingComingSoonDialog()` | `'reka'` | `sm` | `contentClass:
'max-w-[360px]'` |

### `src/components/dialog/content/ConfirmationDialogContent.vue`
- Drops `import Message from 'primevue/message'` — the only PrimeVue
dependency in the component
- Replaces `<Message>` with a Tailwind `role="status"` alert keeping the
`pi pi-info-circle` icon and muted-foreground severity

### `src/stores/dialogStore.ts` +
`src/components/dialog/GlobalDialog.vue`
- Adds `contentClass?: HTMLAttributes['class']` on
`CustomDialogComponentProps`
- Forwards it to `<DialogContent :class="...">` on the Reka branch
(PrimeVue path keeps using `pt`)

## Why this scope

1. **Smallest content surface** — `PromptDialogContent` is 43 LOC; the
only PrimeVue dependency in `ConfirmationDialogContent` is the
`<Message>` info banner.
2. **Closes #11688 ergonomics** — Reka's `md` size = `max-w-xl` (576px /
36rem), exactly the max-width the issue reporter asked for.
3. **Three known callers** — all in `dialogService.ts`. No other callers
needed to change.
4. **Renderer branch is already proven by Phase 0**; this PR just flips
the flag.

## Visual proof

Verified live in Storybook (`Components / Dialog / Dialog → Default` and
`… → All Sizes`) at viewport `1920×1080`. DOM inspection confirms the
rendered widths match the design intent:

| Story | size | Rendered width | Computed `max-width` |
| --- | --- | --- | --- |
| `Default` | `md` | **576 px** | **576 px (= 36rem)** |
| `All Sizes` (sm slot) | `sm` | 384 px | 384 px (= 24rem) |

The `md` measurement directly answers the #11688 reporter screenshot
(1558 px wide PrimeVue dialog → 576 px Reka dialog on the same display).
Local screenshot artifacts (not committed):
`temp/screenshots/phase1-md-576px-1920w.png`,
`temp/screenshots/phase1-md-allsizes-1920w.png`,
`temp/screenshots/phase1-sm-384px-1920w.png` — drag-drop into the PR
body before marking ready for review.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [x] `pnpm format` — applied (oxfmt)
- [x] `pnpm test:unit` (touched files): **26/26 passed**
- `ConfirmationDialogContent.test.ts` (9 tests, no longer needs PrimeVue
plugin)
  - `PromptDialogContent.test.ts` (5 tests, unchanged)
- `GlobalDialog.test.ts` (9 tests, Phase 0 coverage still passes after
the contentClass forwarder addition)
- `dialogService.renderer.test.ts` **new** — 3 tests asserting each call
site sets `renderer: 'reka'` (regression net)
- [ ] `pnpm test:browser:local --grep "@mobile confirm dialog"` —
**could not run locally** (no ComfyUI Python backend on `localhost:8188`
in this session); CI will gate the existing fixture, which is already
renderer-agnostic (`getByRole('dialog')` + `getByRole('button', ...)` in
`browser_tests/fixtures/components/ConfirmDialog.ts`).

## Public API impact

None. `useDialogService().prompt(...)` / `confirm(...)` /
`showBillingComingSoonDialog(...)` keep their existing signatures.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- `ErrorDialogContent`, `NodeSearchBox`, `SecretFormDialog`,
`VideoHelpDialog`, `CustomizationDialog` — Phase 2 (FE-574)
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog` imports + `<style>` cleanup in
`GlobalDialog.vue` — Phase 6 (FE-578)
- Legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`)
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue`


## Screenshot
<img width="865" height="497" alt="Screenshot 2026-05-08 at 4 35 45 PM"
src="https://github.com/user-attachments/assets/6aead2ad-2e0b-478a-9154-bb632a6bf3d1"
/>
<img width="1363" height="964" alt="Screenshot 2026-05-08 at 4 38 16 PM"
src="https://github.com/user-attachments/assets/10647752-a063-4901-a206-842799cc5d7a"
/>
<img width="889" height="486" alt="Screenshot 2026-05-08 at 4 46 57 PM"
src="https://github.com/user-attachments/assets/81899a81-205a-46f2-bddd-7639624607f6"
/>



## Test plan

- [x] Unit: 26/26 pass on touched files
- [ ] CI: `@mobile confirm dialog` spec on the migrated path
- [ ] Manual (post-CI on a real backend): open prompt and confirm
dialogs on 1920×1080 viewport, verify ≤ 36rem max-width, ESC closes,
backdrop click closes, Enter submits prompt, focus trap holds
- [ ] Manual: open Billing Coming Soon dialog — verify it stays at the
existing `max-w-[360px]` width
2026-05-08 12:11:06 +00:00
Dante
0ef98de8eb fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12072)
## Summary

The help icon (lucide circle-help) next to the credits balance in the
cloud user popover was a bare `<i>` with `v-tooltip` and `cursor-help`.
PrimeVue tooltip on a bare `<i>` did not fire reliably and the icon had
no focus/keyboard semantics, so users saw "no hover action and not
clickable".

Wrap the icon in `<Button variant="muted-textonly" size="icon-sm">`,
matching the existing pattern in `InfoButton.vue` and
`MissingPackGroupRow.vue`. Same change applied to
`CurrentUserPopoverLegacy.vue` and `CurrentUserPopoverWorkspace.vue`,
which shared the broken pattern.

- Fixes FE-617
-
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778191473621829

## Red-Green CI Verification

The branch was force-pushed back to the test-only commit so CI could run
against it, then restored to the fix commit.

| Commit | CI: Tests Unit | Outcome |
|--------|---------------|---------|
| `test:` (e7c83abd0) — adds the regression test only |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25532935842 |
failed — `Unable to find an element by:
[data-testid="credits-info-button"]` |
| `fix:` (64ec4cda4) — wraps the icon in `<Button>` |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25533224195 |
passed |



<img width="434" height="364" alt="Screenshot 2026-05-08 at 5 32 47 PM"
src="https://github.com/user-attachments/assets/d3088b90-813f-4a0f-ba35-0f040fc79a6a"
/>

## Test Plan

- [x] Component test asserts the icon renders as an interactive
`<button>` with the unified-credits tooltip text as `aria-label`
- [x] Red CI failed with the expected error on the test-only commit
- [x] Green CI passed on the fix commit
- [ ] Manual verification on `pnpm dev:cloud` — hover the help icon next
to the credits balance and confirm the unification tooltip appears
2026-05-08 11:56:35 +00:00
Dante
88866fc564 fix: restore nightly publish_types build (#12073)
## Summary

The nightly `Release Draft Create` -> `publish_types / Build types` job
has been failing on every run since 1.45.1 ([failed
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531376650)).
Reproduced locally on `main` (`c8c0e5386`).

## Root cause

`pnpm build:types` ends with `FATAL ERROR: Reached heap limit Allocation
failed - JavaScript heap out of memory`. The `vite-plugin-dts` rolled-up
type generation now exceeds Node's default ~4GB heap on the GH runner.
The `TS2742`/`TS4082` warnings printed earlier are non-fatal diagnostics
from the plugin pre-pass — the api-extractor rollup itself completes
once the heap is large enough.

## Fix

Set `NODE_OPTIONS='--max-old-space-size=8192'` in `build:types`,
matching the existing pattern already used by `build` and `build:cloud`
(only one-line change).

## Verification

- `pnpm build:types` exits 0 locally with the change (built in ~40s).
- `dist/index.d.ts` (1.9MB) emitted with the public types intact:
`ComfyExtension`, `ComfyApi`, `ComfyApp`, `ComfyNodeDef`, `InputSpec`,
`DOMWidget`, etc.
- `dist/package.json` correctly produced by `scripts/prepare-types.js`.
- Reverting the change reproduces the OOM crash.

## Test plan

- [ ] Trigger `Release NPM Types` workflow manually (or wait for next
nightly) and confirm `Build types` step succeeds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12073-fix-restore-nightly-publish_types-build-35a6d73d3650819e95aecfbd8a66847c)
by [Unito](https://www.unito.io)
2026-05-08 03:06:03 +00:00
Alexander Brown
1f4a4af079 docs: add subgraph promoted widgets ADR (#11997)
## Summary

Adds an ADR documenting the canonical subgraph promoted-widget model and
legacy proxy-widget ratchet.

## Changes

- **What**: Defines linked `SubgraphInput` promoted widgets, host-owned
sparse value overlays, proxy-widget repair/quarantine behavior,
primitive-node repair, and separate display-only preview exposures.
- **Breaking**: None; documentation only.
- **Dependencies**: None.

## Review Focus

- Whether the ADR cleanly separates value-owning promoted widgets from
display-only preview exposures.
- Whether the legacy ratchet, quarantine, primitive repair, and UI
identity decisions are clear enough for implementation review.

## Screenshots (if applicable)

N/A

@Coderabbitai why would a docs update need an end-to-end test?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11997-docs-add-subgraph-promoted-widgets-ADR-3576d73d36508133bf1ee8d49282cac1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-07 19:51:18 -07:00
jaeone94
c8c0e53865 fix: remove asset hash verification (#12061)
## Summary

This PR removes the `/api/assets/hash/:hash` verification path from
missing media/model detection.

I decided to remove this path for two reasons:

1. The Cloud runtime implementation and the OpenAPI/generated FE
contract do not agree on the hash format that this endpoint represents.
In current Cloud data, the dominant asset_hash shape is
<64-hex>.<extension> (for example, abc123....png), while the
OpenAPI/generated FE contract expects a blake3:<hash> style value. That
makes this path either dead code that should never be reached, or, when
it is reached, a request that always returns 400 and only adds
unnecessary noise.

2. Even if the format is reconciled, the Cloud implementation is a
global deduplication-oriented lookup, not an access-aware check for
whether the current workflow can use a resource. In theory, it can
return success for another user's personal asset, so it is the wrong
primitive for missing asset detection.

Because of that, this PR makes the existing asset list/store based
checks the primary verification path and removes the hash-specific
helpers, service method, and tests.

## Known follow-ups

These are known issues that are intentionally not solved in this PR:

1. Published assets are not exposed through
`/api/assets?...include_public=true`. This is a backend issue and can
still cause mismatch between missing-asset detection and resources that
preview/run successfully.
2. Shared workflow import has an ordering issue. The API contract issue
is being hotfixed separately under FE-603.
3. Annotated media paths can still be detected incorrectly.

I will prepare follow-up PRs for these, starting with the annotated
media path issue because that is the most critical frontend-side gap.

## Validation

- `pnpm exec vitest run
src/platform/assets/services/assetService.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingModel/missingModelScan.test.ts`
- `pnpm lint:unstaged`
- `pnpm typecheck`
- `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12061-fix-remove-asset-hash-verification-3596d73d365081a088f8dfc874724c1d)
by [Unito](https://www.unito.io)
2026-05-08 01:56:19 +00:00
Comfy Org PR Bot
c8360a092f 1.45.1 (#12070)
Patch version increment to 1.45.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12070-1-45-1-35a6d73d365081e9a4bffc19d791b727)
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-05-08 01:10:40 +00:00
355 changed files with 6430 additions and 28582 deletions

View File

@@ -1,88 +0,0 @@
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
#
# Runs on any PR touching extension-api declaration files, extension-api-v2
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
#
# Two jobs:
# test — vitest run against src/extension-api-v2/__tests__/
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
# blast_radius ≥ 2.0 category is missing a stub triple)
#
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
name: 'CI: Tests Extension API'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Extension API tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run extension-api test suite
run: pnpm test:extension-api
- name: Run with coverage (push only)
if: github.event_name == 'push'
run: pnpm test:extension-api:coverage
- name: Upload coverage to Codecov
if: github.event_name == 'push'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: extension-api
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
compat-floor:
name: Compat-floor gate (blast_radius ≥ 2.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML
run: pip install pyyaml
- name: Check compat floor
run: python3 scripts/check-compat-floor.py
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.

View File

@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.locator('.p-dialog-content input')
return this.page.getByRole('dialog').getByRole('textbox')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -116,9 +116,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
const confirmationDialog = this.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
}
private get page() {

View File

@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// The Save As dialog should appear
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

View File

@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |

View File

@@ -0,0 +1,328 @@
# 9. Subgraph promoted widgets use linked inputs
Date: 2026-05-05
Appendices:
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
## Status
Proposed
## Context
Subgraph widget promotion historically had two overlapping representations:
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
2. linked subgraph inputs, where an interior widget-bearing input is exposed
through the subgraph boundary.
This created ambiguous ownership. Runtime value reads could collapse to an
interior source widget, while host `widgets_values` could also carry an
exterior value. Multiple host instances of the same subgraph could therefore
stomp one another, and serialization could mutate interior widgets as a
persistence carrier for exterior values.
The ECS widget migration makes that ambiguity more expensive: widgets are
becoming entities with component state keyed by stable entity identity, and
subgraphs are modeled as graph boundary structure rather than a separate
promotion-specific entity kind.
## Decision
Promoted widgets are represented only as standard linked `SubgraphInput`
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
input on a host `SubgraphNode`. The interior source widget supplies schema,
type, options, tooltip, and default metadata, but it is not the owner of the
host value.
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
promoted widget. It is a separate preview-exposure system because it has no
host-owned widget value, does not feed prompt serialization, and often points at
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
those entries. The standard serialized representation is the existing subgraph
interface/input form plus host-node `widgets_values`.
Display-only preview exposures use their own host-node-scoped serialized entry,
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
language, not widget language:
```ts
type PreviewExposure = {
name: string
sourceNodeId: string
sourcePreviewName: string
}
```
Host-node scope preserves current behavior where different instances of the
same subgraph can choose different exposed previews.
The entry intentionally stores only host preview identity and source locator
identity. `name` is the host-scoped stable identity for this preview exposure,
analogous to `SubgraphInput.name`; it is not a display label. It is generated
with existing collision behavior, such as `nextUniqueName(...)`, when an
exposure is created. Media type, display labels, titles, image/video/audio URLs,
and other runtime preview details are derived from the current graph and output
state. Array order is the canonical display order. Preview exposures do not get
a separate persisted `label` in this slice; if a future rename UX needs one, it
should follow the same rule as subgraph inputs: `name` is identity and `label`
is display-only.
Preview exposures are persisted user choices after creation. Packing nodes into
a subgraph may auto-add recommended preview exposures for supported output
nodes, and users may explicitly add or remove additional preview exposures
afterward. Normal load/save does not re-derive previews from node type alone,
because that would make old workflows change when support for new preview node
types is added. Unresolved preview exposures remain persisted and inert;
automatic cleanup does not prune them. They are removed only by explicit user
action or by destruction/unpacking of the owning host.
Preview exposures compose through nested subgraph hosts by chaining immediate
boundaries. If an outer subgraph wants to show a preview exposed by an inner
subgraph host, the outer `previewExposures` entry points at the immediate inner
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
identity, not the deepest interior preview name. Runtime preview resolution may
then follow the inner host's own preview exposures to find media. Canonical JSON
does not persist flattened deep paths, because deep paths would couple host UI
state to private nested graph internals.
## Identity and value ownership
- UI/value identity is host-scoped: host node locator plus
`SubgraphInput.name`.
- Host-scoped identity means the host `SubgraphNode` instance within its
containing `graphScope`; the interior source node is not the state or
persistence owner.
- `SubgraphInput.name` is the stable internal identity.
- `SubgraphInput.label` / `localized_name` are display-only.
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
persisted widget value key.
- Source node/widget identity remains metadata for diagnostics, missing-model
lookup, schema projection, and migration only.
- The host/exterior value wins over the interior/source value during repair,
persistence, and prompt serialization.
This follows the existing widget/slot convention: `name` is identity, `label`
is display.
Promoted-widget value state is a host-scoped sparse overlay over source-widget
metadata and defaults. The source widget remains the schema/default provider;
host value state is materialized only when the exterior value differs from the
effective source default or when restored from persisted host state. Canonical
save/load must not eagerly mirror source defaults or use interior widgets as
persistence carriers.
## Forward migration
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
not quarantined.
3. Build a multi-pass association map before mutation:
- normalized legacy proxy entry;
- projected legacy promoted-widget order;
- host `widgets_values` value, preserving sparse holes;
- repair strategy or failure reason;
- whether the entry is a value widget or display-only preview exposure.
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
is configured.
5. On flush, re-resolve against current graph state, because clone/paste/load
flows may have remapped or created nodes and links.
6. If already represented by a linked `SubgraphInput`, consider the legacy
entry resolved and consume it.
7. Otherwise repair through existing subgraph input/link systems.
8. If the entry is display-only preview surfacing, migrate it into the separate
preview-exposure representation instead of creating a linked `SubgraphInput`.
9. If value-widget repair fails, write inert quarantine metadata and warn.
The repair is idempotent. Pending plans store tuple/value data and re-check the
current graph before applying mutations.
Legacy entries are classified as preview exposures when either:
- the legacy source name starts with `$$`; or
- the source node resolves to a matching pseudo-preview widget, such as a
`serialize: false` preview/video/audio UI widget.
Everything else is treated as a value-widget promotion candidate. An unresolved
preview-shaped entry remains inert at runtime and is still persisted, because
preview-capable pseudo-widgets and output media can be removed and re-added
dynamically. It is not quarantined because it has no user value to preserve. A
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
failure and follows the quarantine path unless it can resolve to a
pseudo-preview widget.
## Proxy widget error quarantine
Valid legacy entries that cannot be repaired are persisted in
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
not hydrate runtime promoted widgets, do not participate in execution, and are
not used for app-mode/favorites identity.
Quarantine entries preserve enough information to avoid data loss and support
future tooling:
```ts
type ProxyWidgetErrorQuarantineEntry = {
originalEntry: ProxyWidgetTuple
reason:
| 'missingSourceNode'
| 'missingSourceWidget'
| 'missingSubgraphInput'
| 'ambiguousSubgraphInput'
| 'unlinkedSourceWidget'
| 'primitiveBypassFailed'
hostValue?: TWidgetValue
attemptedAtVersion: 1
}
```
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
Workflow-level promotion/value intent is preserved by
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
## Primitive-node repair
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
serve nearly the same purpose as subgraph inputs: they provide a widget value to
one or more target widget inputs. The migration repairs this expected legacy
shape in the first migration rather than quarantining it by default.
Primitive repair:
- coalesces exact duplicate legacy entries during planning;
- uses the primitive node's user title as the base input name when the node was
renamed, otherwise the primitive output widget name;
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
- uses the existing primitive merge/config compatibility logic;
- creates one `SubgraphInput` for the primitive fanout;
- reconnects every former primitive output target to that input in target
order, using standard connect/disconnect APIs;
- applies the host value when one exists, otherwise seeds from the source
primitive value;
- leaves the primitive node and its widget value in place, but disconnected and
inert.
Primitive repair is all-or-quarantine. If any target cannot be validated or
reconnected, the migration does not leave a partial rewrite; it quarantines the
entry with `hostValue` and logs the reason.
## Serialization
After repair/quarantine:
- `properties.proxyWidgets` is omitted for repaired entries;
- display-only preview entries are omitted from `properties.proxyWidgets` and
emitted through `properties.previewExposures`;
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
- preview exposures do not carry quarantine values because they do not own user
values; unresolved preview exposures remain inert in `previewExposures`;
- host `widgets_values` contains host-owned values only for canonical host
widgets, not source-owned defaults or interior persistence copies;
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
- array-form `widgets_values` remains for now.
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
preview rendering, but they do not create prompt inputs, do not create
`widgets_values`, do not alter node execution order, do not become executable
graph edges, and do not participate in prompt serialization. Runtime mapping
from backend `display_node`/output messages to a host preview exposure is a UI
projection only.
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
values into connected interior widgets is removed. A temporary TODO should mark
that removal point until the migration is proven stable. Host values are
serialized through standard subgraph-input widgets instead.
Longer term, `widgets_values` should move from array order to an object/map
keyed by stable widget name, but that migration is out of scope for this
decision.
## App mode, builder, and favorites
The runtime migration and UI identity migration ship in the same slice. The UI
must not persist promoted selections by source node/widget identity after this
change.
Canonical UI identity is:
```ts
type PromotedWidgetUiIdentity = {
hostNodeLocator: string
subgraphInputName: string
}
```
Legacy source-identity selections are migrated when they resolve through the
standard input created or confirmed by the migration. Unresolved selections are
dropped with a warning.
Preview exposure output selections are also host-scoped and must not persist
interior source node identity. Canonical preview/output identity is:
```ts
type PreviewExposureUiIdentity = {
hostNodeLocator: string
previewName: string
}
```
The UI references the explicit preview exposure itself. This keeps subgraphs
opaque: consumers select the host boundary contract, not the interior node that
currently supplies media. Legacy output selections that refer to interior
preview source nodes may migrate if they resolve to a preview-exposure chain;
otherwise they are dropped with `console.warn`. There is no separate preview UI
quarantine.
## PromotionStore
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
## Considered options
### Keep `proxyWidgets` as canonical serialized topology
Rejected. This preserves two representations for the same concept and keeps
source-widget identity in the value-ownership path.
### Preserve bare promoted widgets as degraded runtime state
Rejected. This would avoid some migration complexity, but it perpetuates the
ambiguity that caused host/source value bugs and makes ECS identity less clear.
### Quarantine primitive-node promotions by default
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
quarantining them would break users unnecessarily. They are repaired by bypassing
the primitive node when the repair can be validated all-or-nothing.
### Migrate `widgets_values` to object/map form now
Rejected for this slice. Name-keyed object form is the desired long-term
direction, but combining it with the promotion migration increases blast radius
for existing workflow consumers that still assume array order.
## Consequences
- Promoted widget values become host-instance-owned and ECS-compatible.
- Source widgets remain metadata/default providers, not persistence carriers.
- Legacy workflows are repaired toward one standard representation.
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
runtime promotion.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` has a clear removal path.

View File

@@ -0,0 +1,210 @@
# Appendix: Before and after flows
This appendix visualizes the ownership and migration flows described in
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
## Before: proxy widgets and linked inputs overlap
Historically, promoted widgets could be represented both as serialized
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
reads could collapse back to the interior source widget, while host
`widgets_values` could also carry an exterior value for the same promoted UI.
```mermaid
flowchart TD
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
workflow --> hostValues[host widgets_values]
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
promotionStore --> sourceWidget[Interior source widget]
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
sourceWidget --> hostWidget
hostValues --> hostWidget
hostWidget --> prompt[Prompt serialization]
hostWidget -. may copy value back .-> sourceWidget
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
class proxyWidgets,promotionStore legacy
class sourceWidget,hostValues ambiguous
class linkedInput,hostWidget canonical
```
Key problems in the old flow:
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
the same promotion.
- Interior source widgets supplied both schema metadata and, in some flows,
persisted host values.
- Multiple host instances of the same subgraph could stomp one another through
the shared interior widget value.
- Display-only previews were mixed into widget-promotion language even though
they do not own values or feed prompt serialization.
## After: linked inputs are the promoted-widget boundary
Promoted value widgets are now represented only as standard linked
`SubgraphInput` widgets. The source widget remains the schema/default provider,
but the host `SubgraphNode` owns the promoted value.
```mermaid
flowchart TD
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
workflow --> hostValues[host widgets_values]
subgraphInterface --> subgraphInput[SubgraphInput.name]
subgraphInput --> hostWidget[Host-scoped widget entity]
hostValues --> hostWidget
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
schema --> hostWidget
hostWidget --> prompt[Prompt serialization]
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
sourceWidget -. no host value ownership .-> schema
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
class sourceWidget,schema,diagnostics metadata
class workflow,hostValues persisted
```
Canonical ownership after the migration:
- UI/value identity is host-scoped: host node locator plus
`SubgraphInput.name`.
- `SubgraphInput.name` is stable identity; labels and localized names are
display-only.
- Host values win during repair, persistence, and prompt serialization.
- Source widgets provide metadata and defaults only.
- Canonical saves omit repaired `properties.proxyWidgets` entries.
## Legacy load migration
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
repair builds a plan before mutating graph state, then re-resolves against the
current graph when node IDs and links are stable.
```mermaid
flowchart TD
start[Load workflow] --> parse{Parse properties.proxyWidgets}
parse -->|invalid raw data| invalid[console.error and ignore]
parse -->|valid tuples| plan[Build repair plan]
plan --> classify{Classify entry}
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
valueRepair -->|yes| consume[Consume legacy proxy entry]
valueRepair -->|no| repair[Repair through subgraph input/link systems]
repair --> repairResult{Repair succeeded?}
repairResult -->|yes| consume
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
classify -->|primitive fanout| primitive[Validate all primitive targets]
primitive --> primitiveResult{All targets reconnectable?}
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
primitiveRepair --> consume
primitiveResult -->|no| quarantine
classify -->|display-only preview| preview[Create / keep previewExposures entry]
preview --> consume
consume --> save[Canonical save]
quarantine --> save
save --> omit[Omit repaired entries from proxyWidgets]
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
save --> keepPreview[Persist previews in previewExposures]
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
classDef error fill:#f8d7da,stroke:#842029,color:#330000
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
class quarantine,keepQuarantine warn
class invalid error
```
## Preview exposures are separate from value widgets
Display-only previews, such as `$$canvas-image-preview`, are not promoted
widgets. They have host-scoped serialized identity, but they do not create
prompt inputs, do not create `widgets_values`, and do not own user values.
```mermaid
flowchart TD
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
previewExposures --> exposure[PreviewExposure.name]
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
sourceLocator --> runtimePreview[Runtime preview/output state]
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
exposure --> uiIdentity[hostNodeLocator + previewName]
runtimePreview -. UI projection only .-> hostCanvas
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
previewExposures -. no value widget .-> noValue[No widgets_values entry]
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
class noPrompt,noValue,noEdge noValue
class hostNode persisted
```
For nested subgraphs, preview exposures chain across immediate host boundaries
instead of persisting flattened deep paths.
```mermaid
flowchart LR
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
outerExposure --> innerHost[Immediate inner SubgraphNode]
innerHost --> innerExposure[Inner previewExposures entry]
innerExposure --> deepestPreview[Interior preview source]
deepestPreview --> media[Resolved media]
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
class outerHost,innerHost boundary
class outerExposure,innerExposure,deepestPreview,media preview
class opaque note
```
## Serialization summary
```mermaid
flowchart TD
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
canonical --> values[widgets_values for host-owned values]
canonical --> previews[properties.previewExposures]
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
inputs --> valueWidgets[Promoted value widgets]
values --> valueWidgets
previews --> previewUi[Display-only preview UI]
quarantine --> futureTooling[Future recovery tooling]
valueWidgets --> prompt[Prompt serialization]
previewUi -. not serialized into prompt .-> prompt
quarantine -. inert .-> prompt
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
class inputs,values,valueWidgets,prompt,canonical canonical
class previews,previewUi,quarantine,futureTooling inert
class noProxy removed
```

View File

@@ -0,0 +1,147 @@
# Appendix: Removing `disambiguatingSourceNodeId`
This appendix explains where the existing promotion system needs
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
removes the pattern from promoted-widget identity.
## Why the disambiguator exists
The legacy promotion model identifies a promoted widget by source location:
```ts
type PromotedWidgetSource = {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
```
`sourceNodeId` is the immediate interior node visible from the host subgraph.
That is not always the original widget owner. When promotions pass through
nested subgraphs, two promoted widgets can have the same immediate
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
can choose the right promoted view.
```mermaid
flowchart TD
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
middleNode --> middleWidgetA[Promoted widget view: text]
middleNode --> middleWidgetB[Promoted widget view: text]
middleWidgetA --> leafA[Leaf source node 17 / widget text]
middleWidgetB --> leafB[Leaf source node 42 / widget text]
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
middleWidgetA -. requires .-> oldKeyA
middleWidgetB -. requires .-> oldKeyB
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
class outerHost host
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
class leafA,leafB leaf
```
The disambiguator is therefore not a domain concept. It is compensating for an
identity model that asks host UI state to identify private nested internals.
## Existing places that need it
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
## New promoted-widget identity
ADR 0009 moves promoted value identity to the host boundary:
```ts
type PromotedWidgetUiIdentity = {
hostNodeLocator: string
subgraphInputName: string
}
```
The canonical widget is owned by a `SubgraphInput` on the host
`SubgraphNode`. The host widget no longer needs to identify the deepest source
node to preserve value identity. The source widget is consulted for schema,
defaults, diagnostics, and migration, but it is not the value owner.
```mermaid
flowchart TD
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
host --> inputB[SubgraphInput.name: negative_prompt]
inputA --> hostWidgetA[Host-owned widget entity]
inputB --> hostWidgetB[Host-owned widget entity]
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
sourceA -. not part of host value key .-> identityA
sourceB -. not part of host value key .-> identityB
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
class sourceA,sourceB metadata
```
This is the same rule the subgraph interface already uses: `name` is stable
identity, and `label` / `localized_name` are display-only.
## How the new form removes each need
| Previous disambiguation site | New canonical replacement |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
## Boundary-by-boundary nested flow
The new form avoids flattened deep source paths. Each host boundary exposes its
own named input, and the next outer host links to that immediate boundary
contract.
```mermaid
flowchart LR
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
innerInput --> innerHostWidget[Inner host-owned widget]
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
outerInput --> outerHostWidget[Outer host-owned widget]
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
leaf -. schema/default source .-> innerHostWidget
leaf -. not persisted as outer value key .-> outerIdentity
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef source fill:#cff4fc,stroke:#055160,color:#032830
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
class leaf source
```
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
widgets no longer require a persisted leaf-node disambiguator at the outer host.
If the user exposes both, the collision is resolved when the host inputs are
created by assigning distinct input names with the existing unique-name
behavior.

View File

@@ -0,0 +1,37 @@
# Appendix: System comparison
This appendix compares the legacy promoted-widget systems with the canonical
linked-input model chosen by
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
## Practical migration summary
| Legacy shape | New result |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |

View File

@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
source node/widget identity is preserved only as migration and diagnostic
metadata.
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
source node/widget identity.
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic

View File

@@ -1,128 +0,0 @@
# v2 Extension API — Touch-Point Database
This directory is the **canonical compatibility-surface map** for the upcoming
v2 extension API redesign. Every API surface that real-world ComfyUI
extensions touch is enumerated here, weighted by usage frequency and ecosystem
star count, with citations to verifiable evidence (file paths and line
numbers in real custom-node repos).
It exists so the v2 redesign can answer two questions deterministically:
1. **What will silently break?** — every entry maps to a v2 replacement (or to
an explicit "deprecated, no replacement" decision).
2. **What does the v2 test framework need to cover?** — every entry maps to
≥1 test target so the test floor reflects real extension shapes.
## Artifacts
| File | Role |
|---|---|
| [`touch-points-plan.md`](./touch-points-plan.md) | Methodology, schema, surface-family enumeration, severity rubric |
| [`touch-points-database.yaml`](./touch-points-database.yaml) | Source of truth — 52 patterns × 15 surface families with evidence rows |
| [`touch-points-star-cache.yaml`](./touch-points-star-cache.yaml) | GitHub star/fork/last-commit snapshot for every cited repo (drift detection) |
| [`touch-points-rollup.yaml`](./touch-points-rollup.yaml) | Computed blast-radius scores per pattern (sorted) — the prioritization output |
| [`scripts/fetch-stars.sh`](./scripts/fetch-stars.sh) | Refresh the star cache via `gh api` |
| [`scripts/rollup-blast-radius.py`](./scripts/rollup-blast-radius.py) | Recompute blast radius from database + star cache |
| [`scripts/add-evidence.py`](./scripts/add-evidence.py) | Idempotently merge new evidence rows / new patterns into the database |
## The 15 surface families
| Family | One-liner |
|---|---|
| **S1** | `ComfyExtension` lifecycle hooks (`init`, `setup`, `nodeCreated`, `beforeRegisterNodeDef`, …) |
| **S2** | `LGraphNode.prototype` methods extensions monkey-patch (`onConnectionsChange`, `onSerialize`, `onDrawForeground`, …) |
| **S3** | `LGraphCanvas.prototype` methods extensions monkey-patch (`processKey`, `processContextMenu`, `drawNode`, …) |
| **S4** | Widget-level patterns — `.callback` chaining, `.value` r/w, `.serializeValue`, `.options.*`, DOM widgets |
| **S5** | `ComfyApi` / `app.api` event surfaces — execution lifecycle WebSocket events |
| **S6** | `ComfyApp` god-object touch points — `app.graphToPrompt`, `app.queuePrompt`, `app.api.fetchApi`, … |
| **S7** | Window / global escape hatches — `window.app`, `window.LiteGraph`, `globalThis.LGraphCanvas` |
| **S8** | Special node properties (magic flags) — `isVirtualNode`, `serialize_widgets`, `category`, `color_on` |
| **S9** | Non-Node entity kinds (per [ADR 0008](../decisions/0008-entity-taxonomy.md)) — subgraphs, groups, reroutes, links |
| **S10** | Dynamic node API — `addInput` / `removeInput` / `addOutput` / `removeOutput` slot mutation at runtime |
| **S11** | Graph-level state and change-tracking — `graph.add`, `graph.remove`, `graph.serialize`, version bumps |
| **S12** | Shell UI registries — `extensionManager.registerSidebarTab`, bottom panel, commands, toasts |
| **S13** | Schema interpretation — `ComfyNodeDef` / `InputSpec` consumers (validation, default values, type coercion) |
| **S14** | Identity / Locator scheme — node IDs, slot keys, widget identity across reload |
| **S15** | Output system — preview-image / preview-any / display-text axis (per `widget-api-thoughts.md`) |
Full details, schema, and severity rubric are in [`touch-points-plan.md`](./touch-points-plan.md).
## Top 12 patterns by blast radius
Computed from [`touch-points-rollup.yaml`](./touch-points-rollup.yaml). Blast
radius is `log10(1+stars)·1.0 + log10(1+occurrences)·0.7 +
(signature_count-1)·0.5 + silent_breakage·0.5 + lifecycle_coupling·0.4`.
| Rank | BR | ★ sum | occ | sig | Pattern | Surface |
|---:|---:|---:|---:|---:|---|---|
| 1 | 6.67 | 17 101 | 7 | 1 | `S6.A1` | `app.graphToPrompt` monkey-patching ⚠️ CRITICAL |
| 2 | 5.42 | 2 567 | 1 | 1 | `S9.SG1` | Subgraph "set/get virtual node" pattern (KJNodes-style) |
| 3 | 5.27 | 4 314 | 4 | 1 | `S11.G2` | `graph.add` / `graph.remove` / `graph.findNodesByType` / `graph.findNodeById` / `graph.serialize` / `graph.configure` |
| 4 | 5.23 | 1 808 | 3 | 1 | `S10.D1` | `node.addInput` / `node.removeInput` / `node.addOutput` / `node.removeOutput` dynamic slot mutation |
| 5 | 5.18 | 3 049 | 5 | 1 | `S2.N13` | `nodeType.prototype.onConnectOutput` patching |
| 6 | 5.08 | 6 147 | 4 | 1 | `S4.W2` | `node.addDOMWidget(name, type, element, options)` |
| 7 | 5.01 | 412 | 6 | 1 | `S2.N15` | `nodeType.prototype.serialize` / `node.serialize` direct method patching |
| 8 | 4.89 | 1 789 | 4 | 1 | `S2.N14` | `nodeType.prototype.onWidgetChanged` patching |
| 9 | 4.89 | 7 932 | 6 | 1 | `S2.N4` | `nodeType.prototype.onRemoved` patching (de-facto teardown) |
| 10 | 4.66 | 1 837 | 6 | 1 | `S4.W3` | `widget.serializeValue` direct assignment |
| 11 | 4.61 | 1 788 | 1 | 1 | `S2.N12` | `nodeType.prototype.onConnectInput` patching |
| 12 | 4.55 | 1 793 | 5 | 1 | `S6.A3` | `api.fetchApi` — extensions hit backend HTTP endpoints |
The top three pattern categories — graph mutation (`S11.G2`, `S10.D1`),
prototype patching (`S2.*`), and the `app.graphToPrompt` god-object — together
account for the majority of the blast radius and define the v2 API's
non-negotiable compatibility surfaces.
## Refresh workflow
The database is curated by hand; the star cache and rollup are derived.
```bash
# from this directory
bash scripts/fetch-stars.sh # refresh GitHub stars (needs `gh` auth)
python3 scripts/rollup-blast-radius.py # recompute touch-points-rollup.yaml
```
To add new evidence or new patterns discovered during a future MCP
code-search sweep, edit `scripts/add-evidence.py` (the inline `APPEND` and
`NEW_PATTERNS` blocks are the source of truth for reproducibility) and run:
```bash
python3 scripts/add-evidence.py
python3 scripts/rollup-blast-radius.py
```
## Source documents
The 52 patterns were derived from three primary inputs, then expanded by an
MCP code-search sweep across 87 ecosystem repos:
1. **`AGENTS.md` §5** in this repo — 40+ repo callouts for contributor
conventions and known extension surfaces.
2. **[ADR 0008 — Entity Taxonomy](../decisions/0008-entity-taxonomy.md)** —
defines the non-Node entity kinds (subgraphs, groups, reroutes, links)
that drive surface family **S9**.
3. **`widget-api-thoughts.md`** (in the cross-repo workspace) — the output
system axis and widget lifecycle dependencies that drive surface family
**S15** plus the lifecycle-coupling weight.
## Cross-references
This database is consumed by, and consumes, the rest of the ECS architecture
docs:
- [`../ecs-target-architecture.md`](../ecs-target-architecture.md) — the
target ECS shape this v2 API redesign serves
- [`../ecs-world-command-api.md`](../ecs-world-command-api.md) — the World /
Command API that v2 extensions will program against
- [`../ecs-migration-plan.md`](../ecs-migration-plan.md) — how we get from
today's monkey-patched LiteGraph to v2 + ECS
- [`../ecs-lifecycle-scenarios.md`](../ecs-lifecycle-scenarios.md) — the
lifecycle scenarios the test framework must cover (every touch-point row
here ⇒ ≥1 scenario there)
- [`../entity-interactions.md`](../entity-interactions.md) /
[`../entity-problems.md`](../entity-problems.md) — the entity-model
problems v2 must not perpetuate
- [`../change-tracker.md`](../change-tracker.md) — the change-tracking
contract that S11 (graph state) and S2 (`onSerialize`/`onDeserialize`
patches) must remain compatible with

View File

@@ -1,265 +0,0 @@
#!/usr/bin/env python3
# add-evidence-pass2.py — second MCP sweep. Appends evidence to under-evidenced
# patterns and adds new patterns discovered in pass-2 (graph batching seam,
# window.* globals, setDirtyCanvas redraw idiom).
#
# Idempotent: skips evidence already present (matched by repo+file+lines).
#
# Run: python3 scripts/add-evidence-pass2.py
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "research" / "touch-points" / "database.yaml"
def url(repo: str, file: str, line: int) -> str:
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
def ev(repo, file, lines, **kw):
e = {
"repo": repo,
"file": file,
"lines": lines if isinstance(lines, list) else [lines],
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
}
e.update(kw)
return e
# ─── Evidence to append to existing patterns ──────────────────────────────
APPEND = {
"S2.N17": [ # onSelected / onDeselected
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider.js", 1, variant="prototype-patch", breakage_class="silent",
notes="mxToolkit Slider patches onSelected for highlight state"),
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider2D.js", 1, variant="prototype-patch", breakage_class="silent"),
],
"S2.N19": [ # onResize
ev("SKBv0/ComfyUI_SKBundle", "js/MultiFloat.js", 1, variant="prototype-patch", breakage_class="silent",
notes="MultiFloat widget syncs internal layout on resize"),
ev("PGCRT/CRT-Nodes", "js/Magic_Lora_Loader.js", 1, variant="prototype-patch", breakage_class="silent"),
ev("dorpxam/ComfyUI-LTX2-Microscope", "web/js/ui/visualizer.js", 1, variant="prototype-patch", breakage_class="silent",
notes="visualizer reflows DOM widget on resize"),
],
"S9.R1": [ # Reroute manipulation
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="graph.reroutes.values()", breakage_class="loud",
notes="iterates reroute map directly — fork of frontend, but represents real internal contract surface"),
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
notes="external doc treats graph.reroutes as part of subgraph contract"),
],
"S9.SG1": [ # Set/Get virtual node
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="virtual-node-companion", breakage_class="silent",
notes="Flux Continuum hint system depends on Set/Get virtual node graph"),
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1, variant="full-implementation",
breakage_class="loud", notes="another SetInput/GetOutput pack — variant of KJNodes pattern"),
],
"S13.SC1": [ # ComfyNodeDef inspection
ev("xeinherjer-dev/ComfyUI-XENodes", "web/js/combo_selector.js", 1, variant="nodeData.input.optional",
breakage_class="silent", notes="reads nodeData.input.optional to drive UI generation"),
ev("StableLlama/ComfyUI-basic_data_handling", "web/js/dynamicnode.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
ev("IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools", "js/sb_concat.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
ev("BennyKok/comfyui-deploy", "web-plugin/index.js", 1, variant="nodeData.input.required",
breakage_class="silent", notes="comfyui-deploy is widely used; treats schema as a public contract"),
ev("egormly/ComfyUI-EG_Tools", "web/dynamic_inputs.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
],
"S3.C1": [ # LGraphCanvas.prototype.* monkey-patching — drawNodeShape variant
ev("yolain/ComfyUI-Easy-Use-Frontend", "src/extensions/ui.js", 1, variant="drawNodeShape-patch",
breakage_class="silent", notes="Easy-Use is a major pack; patches LGraphCanvas.prototype.drawNodeShape"),
ev("melMass/comfy_mtb", "web/note_plus.js", 1, variant="canvas-draw-patch", breakage_class="silent",
notes="comfy_mtb (popular pack) — note_plus draws decorations via canvas patching"),
ev("lucafoscili/lf-nodes", "web/src/nodes/reroute.ts", 1, variant="onDrawForeground+canvas-draw",
breakage_class="silent"),
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 1, variant="onDrawForeground",
breakage_class="silent"),
],
"S10.D2": [ # disconnectInput / disconnectOutput / connect
ev("MockbaTheBorg/ComfyUI-Mockba", "js/slider.js", 1, variant="programmatic-disconnect",
breakage_class="loud", notes="app.graph.getNodeById(tlink.target_id).disconnectInput(tlink.target_slot)"),
ev("vjumpkung/comfyui-infinitetalk-native-sampler", "README.md", [1, 50], variant="documented-as-API",
breakage_class="loud", notes="3rd-party docs treat node.disconnect* as a stable extension surface"),
],
"S8.P1": [ # isVirtualNode = true
ev("ComfyNodePRs/PR-comfyui-pkg39-ccab78b5", "js/libs/image.js", [541, 1382], variant="filter-by-virtual",
breakage_class="loud", notes="extension code filters nodes by isVirtualNode — treats it as discovery API"),
],
}
# ─── Brand-new patterns discovered in pass-2 ──────────────────────────────
NEW_PATTERNS = [
{
"pattern_id": "S11.G3",
"surface_family": "S11",
"surface": "graph.beforeChange / graph.afterChange — explicit batching seam for multi-step mutations",
"fingerprint": "graph.beforeChange(); ...mutations...; graph.afterChange();",
"semantic": (
"extensions wrap multi-node/multi-link mutations in beforeChange/afterChange so undo, "
"dirty-tracking, and re-render coalesce around the batch instead of per-mutation"
),
"v2_replacement": "world.batch(() => { ...mutations... }) — typed batching API",
"decision_ref": (
"First-class batching is required for any reactive layer that wants stable diffs; "
"v2 should expose this as a mandatory wrapper for multi-mutation operations"
),
"test_target": "GRAPH_BATCH_BOUNDARY",
"lifecycle_coupling": 1,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
notes="docs use beforeChange/afterChange around subgraph promotion"),
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="paste-undo-batch",
breakage_class="loud", notes="paste flow batches mutations across clipboard restore"),
],
},
{
"pattern_id": "S7.G1",
"surface_family": "S7",
"surface": "window.LiteGraph / window.comfyAPI.* — globals as public surface",
"fingerprint": "window.LiteGraph.createNode(...); window.comfyAPI.app.app",
"semantic": (
"extensions reach into the global namespace for LiteGraph constructors/enums or for the "
"module-as-global comfyAPI registry. This is the closest thing to a 'public ABI' today"
),
"v2_replacement": (
"explicit `import { app, graph, LiteGraph } from '@comfy/extension'` + a typed registry "
"keyed by extension name; window.* should remain as a deprecated read-only mirror"
),
"decision_ref": (
"Cannot break window.LiteGraph immediately — too much ecosystem code reaches for it. "
"Must ship typed import path first, then deprecate. Similar story to S11.G2 graph globals."
),
"test_target": "GLOBAL_NAMESPACE_COMPAT",
"lifecycle_coupling": 0,
"severity": "CRITICAL",
"evidence_status": "swept",
"evidence": [
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1,
variant="window.LiteGraph", breakage_class="loud"),
ev("ArtHommage/HommageTools", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
ev("PROJECTMAD/PROJECT-MAD-NODES", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
ev("ryanontheinside/ComfyUI_RyanOnTheInside", "web/js/index.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
ev("stavzszn/comfyui-teskors-utils", "web/js/index.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
],
},
{
"pattern_id": "S11.G4",
"surface_family": "S11",
"surface": "graph.setDirtyCanvas(true, true) — imperative canvas-redraw trigger",
"fingerprint": "node.graph?.setDirtyCanvas?.(true, true); app.graph.setDirtyCanvas(true, true);",
"semantic": (
"after any imperative mutation extensions call setDirtyCanvas to force a redraw — the "
"ecosystem's de-facto 'reactivity flush' primitive. v2 reactivity should make this unnecessary"
),
"v2_replacement": (
"implicit — reactive system schedules redraw automatically when tracked entity mutates. "
"Provide an escape hatch `world.markDirty()` only for non-reactive third-party canvas use"
),
"decision_ref": (
"Replacing this surface is the strongest evidence that v2 reactivity actually buys something. "
"Should be in v2 'value proposition' demo extension"
),
"test_target": "REDRAW_NO_LONGER_NEEDED",
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 111,
variant="post-mutation-redraw", breakage_class="silent"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 285,
variant="post-mutation-redraw", breakage_class="silent"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/ui/module_node_picker_node_factory.js", 189,
variant="post-mutation-redraw", breakage_class="silent"),
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", [776, 1087],
variant="post-mutation-redraw", breakage_class="silent",
notes="multiple call sites — extension assumes manual flush is the contract"),
],
},
{
"pattern_id": "S10.D3",
"surface_family": "S10",
"surface": "node.setSize(node.computeSize()) — imperative resize after dynamic mutation",
"fingerprint": "node.setSize?.(node.computeSize())",
"semantic": (
"after dynamic widget/input/output mutation, extensions manually call computeSize+setSize "
"to reflow the node. Companion to S2.N11 (computeSize override) and S11.G4 (setDirtyCanvas)"
),
"v2_replacement": (
"automatic — reactive layout system recomputes node size when widget/slot collection changes. "
"Expose `nodeHandle.requestLayout()` only as escape hatch"
),
"decision_ref": "Pairs with S11.G4 — both are 'manual flush' idioms that v2 should obviate",
"test_target": "AUTO_RELAYOUT_ON_MUTATION",
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 283,
variant="setSize+computeSize", breakage_class="silent",
notes="exact 'node.setSize?.(node.computeSize())' canonical idiom"),
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_node.js", 466,
variant="manual-height", breakage_class="silent",
notes="commented-out manual setSize — shows the pattern is well-known"),
],
},
]
def normalize_evidence_key(e):
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
def main():
db = yaml.safe_load(DB.read_text())
appended = 0
skipped = 0
for pid, new_evs in APPEND.items():
for p in db["patterns"]:
if p["pattern_id"] == pid:
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
existing = {normalize_evidence_key(e) for e in p["evidence"]}
for e in new_evs:
if normalize_evidence_key(e) in existing:
skipped += 1
continue
p["evidence"].append(e)
appended += 1
p["evidence_status"] = "swept"
break
else:
print(f"⚠️ pattern {pid} not found")
added_new = 0
existing_ids = {p["pattern_id"] for p in db["patterns"]}
for np in NEW_PATTERNS:
if np["pattern_id"] in existing_ids:
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
continue
db["patterns"].append(np)
added_new += 1
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
if "evidence-sweep-pass-2" not in db["meta"].get("sweeps_done", []):
db["meta"]["sweeps_done"].append("evidence-sweep-pass-2")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
print(f"✅ added {added_new} new patterns")
print(f"✅ DB now has {len(db['patterns'])} patterns")
if __name__ == "__main__":
main()

View File

@@ -1,213 +0,0 @@
#!/usr/bin/env python3
# add-evidence.py — append evidence to existing patterns and add NEW patterns
# discovered during the MCP sweep. Idempotent: skips evidence already present
# (matched by repo+file+lines).
#
# Run: python3 scripts/add-evidence.py
#
# Source-of-truth for evidence is inline below — keeping it in version
# control makes the sweep reproducible and reviewable.
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "touch-points-database.yaml"
def url(repo: str, file: str, line: int) -> str:
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
def ev(repo, file, lines, **kw):
e = {
"repo": repo,
"file": file,
"lines": lines if isinstance(lines, list) else [lines],
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
}
e.update(kw)
return e
# ─── Evidence to merge into existing patterns ─────────────────────────────
APPEND = {
"S2.N12": [
# already has core dynamicWidgets entry
],
"S2.N13": [
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_relay.js", [90, 92], variant="subclass-override", breakage_class="loud", notes="rgthree — major pack. Subclass override pattern (calls super)."),
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_repeater.js", [21, 24], variant="subclass-override", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_relay.ts", [146, 153], variant="subclass-override-ts", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_repeater.ts", [46, 56], variant="subclass-override-ts", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "web/comfyui/base_any_input_connected_node.js", [136, 138], variant="subclass-override", breakage_class="loud"),
],
"S2.N14": [
ev("niknah/presentation-ComfyUI", "js/PresentationDropDown.js", [12, 75], variant="prototype-chain", breakage_class="silent", notes="captures original onWidgetChanged via prototype chain"),
ev("chyer/Chye-ComfyUI-Toolset", "web/comfyui/text_file_loader.js", [35, 115], variant="instance-method", breakage_class="silent"),
],
"S2.N15": [
ev("Azornes/Comfyui-LayerForge", "js/CanvasView.js", 1438, variant="prototype-replace", breakage_class="silent", notes="LayerForge (313★) — replaces serialize wholesale"),
ev("Azornes/Comfyui-LayerForge", "src/CanvasView.ts", 1657, variant="prototype-replace-ts", breakage_class="silent"),
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_wan_motion_presets.js", 598, variant="prototype-replace", breakage_class="silent"),
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_ltx2_extension_presets.js", 350, variant="prototype-replace", breakage_class="silent"),
ev("DazzleNodes/ComfyUI-Smart-Resolution-Calc", "web/utils/serialization.js", 32, variant="prototype-replace", breakage_class="silent"),
ev("alankent/ComfyUI-OA-360-Clip", "web/oa_360_clip.js", 900, variant="prototype-replace", breakage_class="silent"),
],
"S2.N16": [
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 328, variant="push", breakage_class="silent", notes="extension pushes to node.widgets directly"),
ev("max-dingsda/ComfyUI-AllinOne-LazyNode", "web/js/aio_core_preview.js", 170, variant="push", breakage_class="silent"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-set-get.js", 9, variant="indexed-read", breakage_class="loud", notes="reads node.widgets[0].value to get name"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-load-image.js", 56, variant="indexOf", breakage_class="loud"),
ev("viswamohankomati/ComfyUI-Copilot", "ComfyUI/custom_nodes/ComfyUI-Copilot/ui/src/utils/comfyuiWorkflowApi2Ui.ts", [305, 316], variant="widgets_values-push", breakage_class="silent", notes="touches node.widgets_values, the serialized array"),
],
"S11.G1": [
ev("FloyoAI/ComfyUI-SoundFlow", "js/PreviewAudio.js", 293, variant="post-mutation-bump", breakage_class="silent", notes="bumps version after node-internal mutation to trigger redraw"),
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 84, variant="post-mutation-bump", breakage_class="silent"),
ev("coeuskoalemoss/comfyUI-layerstyle-custom", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent"),
ev("40740/ComfyUI_LayerStyle_Bmss", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent", notes="duplicate-of-coeuskoalemoss pattern — fork"),
],
"S11.G2": [
ev("yolain/ComfyUI-Easy-Use", "web_version/v1/js/easy/easyExtraMenu.js", 439, variant="add+createNode", breakage_class="loud", notes="Easy-Use is a major pack; uses graph.add(LiteGraph.createNode(...))"),
ev("KumihoIO/kumiho-plugins", "comfyui/web/js/kumiho.js", 431, variant="add+createNode", breakage_class="loud"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-ui-enhancements.js", 29, variant="remove-then-add", breakage_class="loud", notes="swap nodes by remove+add — preserves layout via savedProps"),
ev("Comfy-Org/ComfyUI_frontend", "browser_tests/tests/workflowPersistence.spec.ts", [351, 413], variant="add+createNode", breakage_class="loud", notes="OUR OWN E2E TESTS rely on window.app.graph.add(window.LiteGraph.createNode(...))"),
],
"S12.UI1": [
ev("robertvoy/ComfyUI-Distributed", "web/main.js", [269, 270], variant="extensionManager.registerSidebarTab", breakage_class="loud", notes="real call site for sidebar registration"),
ev("criskb/Comfypencil", "web/comfy_pencil_extension.js", [955, 956], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
ev("maxi45274/ComfyUI_LinkFX", "js/LinkFX.js", [707, 709], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
],
"S10.D1": [
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/node.js", [18, 53], variant="dynamic-addInput-loop", breakage_class="loud", notes="real-world dynamic input expansion: this.addInput('infix '+i,'STRING')"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-mode-nodes.js", [42, 106], variant="virtual-node-setup", breakage_class="loud", notes="Eclipse uses addOutput within isVirtualNode setup"),
ev("Comfy-Org/ComfyUI_frontend", "src/lib/litegraph/src/canvas/LinkConnector.core.test.ts", [121, 158], variant="OUR-TESTS", breakage_class="loud", notes="OUR OWN TESTS depend on addOutput"),
],
"S9.S1": [
ev("lordwedggie/xcpNodes", "js/xcpDerpINT.js", 162, variant="output-color_on-assignment", breakage_class="silent", notes="this.outputs[0].color_on = templateSlotColorOn — direct slot visual override"),
ev("nodetool-ai/nodetool", "subgraphs.md", [267, 299], variant="documented-pattern", breakage_class="loud", notes="external docs reference color_on for subgraph slot inheritance"),
],
"S4.W4": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", [24, 27], variant="includes-then-push", breakage_class="silent", notes="checks values then mutates"),
ev("zzggi2024/shaobkj", "js/dynamic_inputs.js", [374, 376], variant="snapshot-then-mutate", breakage_class="silent", notes="saves __originalValues snapshot before mutating widget.options.values"),
ev("EnragedAntelope/EA_LMStudio", "web/ea_lmstudio.js", 11, variant="documented-fallback", breakage_class="loud", notes="explicit comment: 'Legacy LiteGraph frontend: full support via widget.options.values'"),
],
}
# ─── Brand-new patterns discovered during sweep ───────────────────────────
NEW_PATTERNS = [
{
"pattern_id": "S6.A3",
"surface_family": "S6",
"surface": "api.fetchApi — extensions hit backend HTTP endpoints",
"fingerprint": "await api.fetchApi('/upload/image', { method: 'POST', body: data })",
"semantic": "extensions call ComfyAPI.fetchApi as the canonical way to reach backend HTTP routes (auth, base URL, error handling all handled)",
"v2_replacement": "ctx.api.fetch(path, init) typed wrapper; same semantics, narrower surface",
"decision_ref": "Pattern is widely used and CORRECT — keep contract, just type it",
"test_target": "BACKEND_HTTP_CLIENT",
"lifecycle_coupling": 0,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 54, variant="POST-multipart", breakage_class="loud"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/api/module_node_picker_api.js", 43, variant="generic-wrapper", breakage_class="loud"),
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", 1227, variant="POST-upload", breakage_class="loud"),
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_window.js", 1379, variant="GET", breakage_class="loud"),
ev("Comfy-Org/ComfyUI_frontend", "src/components/common/BackgroundImageUpload.vue", 61, variant="POST-upload", breakage_class="loud", notes="OUR OWN UI uses api.fetchApi for image upload"),
],
},
{
"pattern_id": "S6.A4",
"surface_family": "S6",
"surface": "app.queuePrompt / app.api.queuePrompt patching or direct call",
"fingerprint": "const orig = window.app.api.queuePrompt; window.app.api.queuePrompt = async function(...args) {...; return orig(...args)}",
"semantic": "intercept or trigger workflow execution; auth tokens, custom payload mutation, sidebar 'Run' buttons",
"v2_replacement": "graph.run({ batch }) explicit API + app.on('beforeRun', payload => mutate(payload))",
"decision_ref": "Pairs with S6.A1 graphToPrompt as the OTHER half of the execute-pipeline interception story",
"test_target": "PROMPT_QUEUE_INTERCEPT",
"lifecycle_coupling": 2,
"severity": "CRITICAL",
"evidence_status": "swept",
"evidence": [
ev("gigici/ComfyUI_BlendPack", "js/ui/NodeUI.js", 99, variant="bind-then-replace", breakage_class="silent", notes="window.app.api.queuePrompt?.bind(window.app.api) — patches the API-level queue"),
ev("MajoorWaldi/ComfyUI-Majoor-AssetsManager", "js/features/viewer/workflowSidebar/sidebarRunButton.js", [317, 321], variant="multi-path-fallback", breakage_class="loud", notes="documents 4 distinct invocation paths: app.api.queuePrompt, app.queuePrompt, fetch /prompt, etc."),
ev("rohapa/comfyui-replay", "README.md", [497, 975], variant="call+fallback", breakage_class="loud", notes="app.queuePrompt(0,1) with raw fetch /prompt fallback"),
],
},
{
"pattern_id": "S5.A3",
"surface_family": "S5",
"surface": "api.addEventListener('execution_start' | 'execution_success' | 'execution_error' | 'execution_cached' | 'executing' | 'status' | 'reconnecting')",
"fingerprint": "api.addEventListener('execution_start', e => ...)",
"semantic": "extensions subscribe to backend execution lifecycle WebSocket events",
"v2_replacement": "ctx.execution.on('start' | 'success' | 'error' | 'cached', payload => ...) typed events",
"decision_ref": "Cross-references S5.A1 (existence-proof of events-everywhere)",
"test_target": "EXECUTION_LIFECYCLE_EVENTS",
"lifecycle_coupling": 0,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("zzw5516/ComfyUI-zw-tools", "entry/entry.js", [27, 28], variant="execution_start", breakage_class="loud"),
ev("flymyd/koishi-plugin-comfyui-client", "src/ComfyUINode.ts", 109, variant="execution_start-case", breakage_class="loud"),
ev("kyuz0/amd-strix-halo-comfyui-toolboxes", "scripts/benchmark_workflows.py", 52, variant="execution_start-message-type", breakage_class="loud"),
ev("philippjbauer/devint25-comfyui-api-demo", "README.md", [144, 179], variant="documented-event-list", breakage_class="loud"),
ev("philippjbauer/devint25-comfyui-api-demo", "Models/ComfyModels.cs", 159, variant="enum-of-event-names", breakage_class="loud", notes="C# wrapper enumerates the WebSocket event vocabulary as the public API"),
ev("huafitwjb/ComfyUI-GO-Mobile-app", "app/src/main/java/com/example/myapplication/util/Constants.kt", 26, variant="execution_success-const", breakage_class="loud"),
ev("hernantech/comfymcp", "src/comfymcp/client/types.py", 17, variant="execution_success-enum", breakage_class="loud"),
ev("choovin/comfyui-api", "README.md", [57, 1945], variant="execution_success-doc", breakage_class="loud", notes="explicit 'Sidecar-like tracing' depending on execution_* events as public API"),
],
},
]
def normalize_evidence_key(e):
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
def main():
db = yaml.safe_load(DB.read_text())
appended = 0
skipped = 0
for pid, new_evs in APPEND.items():
for p in db["patterns"]:
if p["pattern_id"] == pid:
existing = {normalize_evidence_key(e) for e in (p.get("evidence") or [])}
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
for e in new_evs:
if normalize_evidence_key(e) in existing:
skipped += 1
continue
p["evidence"].append(e)
appended += 1
# Mark evidence_status as swept now that we've sourced real data
p["evidence_status"] = "swept"
break
else:
print(f"⚠️ pattern {pid} not found")
added_new = 0
existing_ids = {p["pattern_id"] for p in db["patterns"]}
for np in NEW_PATTERNS:
if np["pattern_id"] in existing_ids:
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
continue
db["patterns"].append(np)
added_new += 1
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
if "evidence-sweep-pass-1" not in db["meta"].get("sweeps_done", []):
db["meta"]["sweeps_done"].append("evidence-sweep-pass-1")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
print(f"✅ added {added_new} new patterns")
print(f"✅ DB now has {len(db['patterns'])} patterns")
if __name__ == "__main__":
main()

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# fetch-stars.sh — populate research/touch-points/star-cache.yaml
# Reads database.yaml, extracts unique repo: entries, queries gh api for stars.
# Usage: bash scripts/fetch-stars.sh
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
DB="$DIR/../touch-points-database.yaml"
CACHE="$DIR/../touch-points-star-cache.yaml"
if ! command -v gh >/dev/null 2>&1; then
echo "❌ gh CLI not installed"
exit 1
fi
# Extract unique repo: entries from database
repos=$(grep -E '^\s*-\s*repo:\s' "$DB" | sed -E 's/^\s*-\s*repo:\s*//' | sort -u | grep -v '^$' || true)
today=$(date +%Y-%m-%d)
{
echo "# ───────────────────────────────────────────────────────────────────────"
echo "# GitHub star cache for repos referenced in database.yaml"
echo "# Refresh: bash scripts/fetch-stars.sh"
echo "# Asof dates allow drift detection"
echo "# ───────────────────────────────────────────────────────────────────────"
echo ""
echo "asof: $today"
echo "populated_via: scripts/fetch-stars.sh"
echo ""
echo "repos:"
} > "$CACHE.tmp"
count=0
err_count=0
for r in $repos; do
count=$((count + 1))
printf " [%3d] %s ... " "$count" "$r" >&2
if data=$(gh api "repos/$r" 2>/dev/null); then
stars=$(echo "$data" | jq -r '.stargazers_count')
archived=$(echo "$data" | jq -r '.archived')
forks=$(echo "$data" | jq -r '.forks_count')
last=$(echo "$data" | jq -r '.pushed_at' | cut -dT -f1)
echo "$stars" >&2
{
echo " - repo: $r"
echo " stars: $stars"
echo " archived: $archived"
echo " forks: $forks"
echo " last_commit: $last"
echo " asof: $today"
} >> "$CACHE.tmp"
else
err_count=$((err_count + 1))
echo "ERROR" >&2
{
echo " - repo: $r"
echo " stars: null"
echo " error: \"gh api failed (rate limit / repo missing / network)\""
echo " asof: $today"
} >> "$CACHE.tmp"
fi
done
mv "$CACHE.tmp" "$CACHE"
echo "" >&2
echo "✅ Wrote $CACHE$count repos, $err_count errors" >&2

View File

@@ -1,221 +0,0 @@
#!/usr/bin/env python3
# merge-staging-pass3.py — single-threaded merger for pass-3 staging files.
#
# Reads:
# research/touch-points/staging/r8-evidence.yaml (clone-grep)
# research/touch-points/staging/r9-security.yaml (security scan + proposed S16.* patterns)
# research/touch-points/staging/r9-guides.yaml (sanctioned surfaces from docs we ship)
# research/touch-points/staging/r9-cookiecutter.yaml (scaffolded = forced-public surfaces)
#
# Writes back to:
# research/touch-points/database.yaml
#
# Safe to re-run; per-(repo, file, lines) dedup is enforced.
# R8 evidence is capped at 6 rows per pattern (already capped per repo+pattern in producer).
#
# R9.popularity is metadata about repos, not evidence — skipped here.
# R9.qa is regression-scenario seeds for I-TF.3 — referenced but not merged into DB.
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "research" / "touch-points" / "database.yaml"
STAGING = ROOT / "research" / "touch-points" / "staging"
R8 = STAGING / "r8-evidence.yaml"
R9_SEC = STAGING / "r9-security.yaml"
R9_GUIDES = STAGING / "r9-guides.yaml"
R9_CK = STAGING / "r9-cookiecutter.yaml"
CAP_PER_PATTERN_FROM_R8 = 8 # adjust if DB explodes
def normalize_lines(lines):
if isinstance(lines, str):
# R8 emitted strings like "[119, 131]" — convert
try:
return tuple(eval(lines, {"__builtins__": {}}, {}))
except Exception:
return (lines,)
if isinstance(lines, list):
return tuple(lines)
return (lines,)
def evkey(e):
return (e.get("repo"), e.get("file"), normalize_lines(e.get("lines")))
def append_dedup(target_evidence, new_rows, cap=None):
existing = {evkey(e) for e in target_evidence}
appended = 0
skipped = 0
rows_to_consider = list(new_rows)
if cap and len(rows_to_consider) > cap:
# Prefer rows from higher-star repos when capping.
# Order is producer-defined; keep first `cap`.
rows_to_consider = rows_to_consider[:cap]
for e in rows_to_consider:
# Normalize line representation
if isinstance(e.get("lines"), str):
e["lines"] = list(normalize_lines(e["lines"]))
if evkey(e) in existing:
skipped += 1
continue
target_evidence.append(e)
existing.add(evkey(e))
appended += 1
return appended, skipped
def main():
db = yaml.safe_load(DB.read_text())
patterns_by_id = {p["pattern_id"]: p for p in db["patterns"]}
total_appended = 0
total_skipped = 0
new_patterns_added = 0
# ─── R8 (clone-grep) ────────────────────────────────────────────
r8 = yaml.safe_load(R8.read_text())
print(f"R8: {sum(len(v) for v in r8.values())} total rows across {len(r8)} patterns")
for pid, rows in r8.items():
if pid not in patterns_by_id:
print(f" ⚠️ R8 pattern {pid} not in DB — skipping")
continue
p = patterns_by_id[pid]
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
a, s = append_dedup(p["evidence"], rows, cap=CAP_PER_PATTERN_FROM_R8)
total_appended += a
total_skipped += s
p["evidence_status"] = "swept"
# ─── R9.security: proposed S16.* patterns ───────────────────────
sec = yaml.safe_load(R9_SEC.read_text())
for sp in sec.get("proposed_patterns", []):
pid = sp.get("proposed_pattern_id")
if not pid:
continue
if pid in patterns_by_id:
print(f" R9.sec pattern {pid} already exists — appending evidence only")
target = patterns_by_id[pid]
else:
# Materialize the new pattern
new_p = {
"pattern_id": pid,
"surface_family": sp.get("surface_family", "S16"),
"surface": sp.get("surface", ""),
"fingerprint": sp.get("fingerprint", ""),
"semantic": sp.get("semantic", ""),
"v2_replacement": sp.get("v2_replacement", ""),
"decision_ref": sp.get("rationale", ""),
"test_target": sp.get("test_target", ""),
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [],
}
db["patterns"].append(new_p)
patterns_by_id[pid] = new_p
target = new_p
new_patterns_added += 1
print(f" R9.sec NEW pattern {pid}: {sp.get('surface', '')[:60]}")
# Materialize evidence rows from R9.sec
evidence_field = sp.get("evidence")
if isinstance(evidence_field, str):
try:
evidence_field = eval(evidence_field, {"__builtins__": {}}, {})
except Exception:
evidence_field = []
if not isinstance(evidence_field, list):
evidence_field = []
rows = []
for e in evidence_field:
if not isinstance(e, dict):
continue
rows.append({
"pattern_id": pid,
"repo": e.get("repo", "unknown"),
"file": e.get("file", "unknown"),
"lines": e.get("lines", [1]) if isinstance(e.get("lines"), (list, int)) else [1],
"url": e.get("url", ""),
"rule": e.get("rule", ""),
"source": "security",
"variant": e.get("rule", "yara/bandit-hit"),
})
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── R9.cookiecutter: scaffolded surfaces ───────────────────────
ck = yaml.safe_load(R9_CK.read_text())
for entry in ck.get("scaffold_surfaces", []):
pid = entry.get("pattern_id")
if not pid or pid not in patterns_by_id:
continue
target = patterns_by_id[pid]
if "evidence" not in target or target["evidence"] is None:
target["evidence"] = []
rows = [{
"pattern_id": pid,
"repo": "cookiecutter-comfy-extension",
"file": entry.get("template_file", "unknown"),
"lines": entry.get("lines", [1]),
"url": "",
"source": "cookiecutter",
"variant": "scaffolded-by-default",
"excerpt": entry.get("excerpt", ""),
"notes": "FORCED-PUBLIC: this surface is generated by the default scaffold, so v2 cannot break it without breaking new-extension onboarding",
}]
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── R9.guides: surfaces we teach in docs ───────────────────────
guides = yaml.safe_load(R9_GUIDES.read_text())
for entry in guides.get("sanctioned_surfaces", []):
pid = entry.get("pattern_id")
if not pid or pid not in patterns_by_id:
continue
target = patterns_by_id[pid]
if "evidence" not in target or target["evidence"] is None:
target["evidence"] = []
rows = [{
"pattern_id": pid,
"repo": "comfyanonymous/custom-nodes-guides",
"file": entry.get("taught_in", "unknown"),
"lines": entry.get("lines", [1]),
"url": "",
"source": "guides",
"variant": "taught-in-official-docs",
"excerpt": entry.get("excerpt", ""),
"notes": "SANCTIONED-PUBLIC: this surface is taught in official docs we ship, so v2 must keep it stable",
}]
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── Update meta ────────────────────────────────────────────────
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
sweeps = db["meta"].setdefault("sweeps_done", [])
if "evidence-sweep-pass-3" not in sweeps:
sweeps.append("evidence-sweep-pass-3")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
total_evidence = sum(len(p.get("evidence") or []) for p in db["patterns"])
print()
print(f"✅ appended {total_appended} rows ({total_skipped} dupes skipped)")
print(f"✅ added {new_patterns_added} new patterns")
print(f"✅ DB now: {len(db['patterns'])} patterns, {total_evidence} evidence rows")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,121 +0,0 @@
#!/usr/bin/env python3
# rollup-blast-radius.py — compute per-pattern blast-radius metrics from
# database.yaml + star-cache.yaml, write to research/touch-points/rollup.yaml.
#
# Blast-radius formula (per PLAN.md):
# br = (log10(1 + cumulative_stars)) * w_stars (default 1.0)
# + (log10(1 + occurrence_count)) * w_occ (default 0.7)
# + (signature_count - 1) * w_sig (default 0.5)
# + silent_breakage_weight * w_silent (default 0.5)
# + lifecycle_coupling_weight * w_lifecycle (default 0.4)
#
# silent_breakage_weight & lifecycle_coupling_weight come from the per-pattern
# heuristics field; if absent they default to 0.
import math
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "touch-points-database.yaml"
STARS = ROOT / "touch-points-star-cache.yaml"
OUT = ROOT / "touch-points-rollup.yaml"
W = {
"stars": 1.0,
"occ": 0.7,
"sig": 0.5,
"silent": 0.5,
"lifecycle": 0.4,
}
def load_stars() -> dict[str, int]:
if not STARS.exists():
return {}
cache = yaml.safe_load(STARS.read_text())
out = {}
for r in cache.get("repos", []) or []:
if r.get("stars") is not None:
out[r["repo"]] = int(r["stars"])
return out
def main() -> int:
db = yaml.safe_load(DB.read_text())
stars = load_stars()
rows = []
for p in db.get("patterns", []) or []:
evidence = p.get("evidence") or []
repos = []
for e in evidence:
r = e.get("repo")
if r:
repos.append(r)
unique_repos = sorted(set(repos))
cum_stars = sum(stars.get(r, 0) for r in unique_repos)
occ = len(evidence)
sig_count = p.get("signature_count") or len(p.get("signatures") or []) or 1
# Pattern fields can be top-level or under 'heuristics'
h = p.get("heuristics") or {}
sev_map = {"CRITICAL": 2, "HIGH": 1.5, "MEDIUM": 1, "LOW": 0.5}
silent_w = float(h.get("silent_breakage", sev_map.get(p.get("severity", ""), 0)))
life_w = float(h.get("lifecycle_coupling", p.get("lifecycle_coupling", 0)))
br = (
math.log10(1 + cum_stars) * W["stars"]
+ math.log10(1 + occ) * W["occ"]
+ max(0, sig_count - 1) * W["sig"]
+ silent_w * W["silent"]
+ life_w * W["lifecycle"]
)
rows.append(
{
"pattern_id": p["pattern_id"],
"surface_family": p.get("surface_family"),
"name": p.get("name") or p.get("surface") or p.get("semantic_intent") or p.get("semantic"),
"occurrences": occ,
"unique_repos": len(unique_repos),
"cumulative_stars": cum_stars,
"signature_count": sig_count,
"silent_breakage": silent_w,
"lifecycle_coupling": life_w,
"blast_radius": round(br, 3),
"top_repos": [
{"repo": r, "stars": stars.get(r, 0)}
for r in sorted(unique_repos, key=lambda x: -stars.get(x, 0))[:5]
],
}
)
rows.sort(key=lambda r: -r["blast_radius"])
out = {
"meta": {
"generated_from": ["database.yaml", "star-cache.yaml"],
"weights": W,
"patterns_count": len(rows),
},
"patterns": rows,
}
OUT.write_text(yaml.safe_dump(out, sort_keys=False, width=120))
print(f"✅ wrote {OUT.relative_to(ROOT)} ({len(rows)} patterns)")
print()
print("Top 12 by blast radius:")
print(f" {'rank':>4} {'br':>6} {'★sum':>6} {'occ':>3} {'sig':>3} pattern")
for i, r in enumerate(rows[:12], 1):
print(
f" {i:>4} {r['blast_radius']:>6.2f} {r['cumulative_stars']:>6} "
f"{r['occurrences']:>3} {r['signature_count']:>3} {r['pattern_id']} {r['name']}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because one or more lines are too long

View File

@@ -1,313 +0,0 @@
---
source: in-house (no external URL — synthesized from R4 + database.yaml + meeting transcript)
date_accessed: 2026-05-06
created: 2026-05-06
purpose: Plan + schema for the canonical touch-point database
status: active
---
# Touch-Point Database — Plan
## Why we are building this
The v2 extension API redesign (P1, D3.x) and the eventual test framework need a **shared evidence layer**: every API surface that real-world extensions touch, frequency-weighted by usage, with citations to verify. Without this, two failure modes are guaranteed:
1. **Silent regressions in v2.** Surfaces we don't know about can't be re-implemented or formally deprecated. The v2 service ships, big custom-node packs break, ComfyUI looks unstable.
2. **Test framework with the wrong floor.** Tests that don't reflect real extension shapes will pass v2 while production extensions break.
The database is the input for:
- v2 API gap analysis (D4 G1G13, plus future Gs surfaced here)
- Test framework design (widget-api-thoughts.md "Test Framework" section): every entry maps to ≥1 test case
- Migration guide writing (P3, DEP3, DEP4)
- "What can we actually delete" decisions (e.g., R4 found `loadedGraphNode` has 1 real call site)
## What the v2 POC shipped (CONTEXT for the audit)
There are 5 untracked v2 files in `ComfyUI_frontend` worktree (proof-of-concept):
- `src/types/extensionV2.ts``NodeHandle`, `WidgetHandle`, `defineNodeExtension`, `defineWidgetExtension` interfaces
- `src/services/extensionV2Service.ts` — scope registry, reactive mount system, handle factories (with inline open-question comments)
- `src/extensions/core/dynamicPrompts.v2.ts` — POC migration
- `src/extensions/core/imageCrop.v2.ts` — POC migration (13→12 lines)
- `src/extensions/core/previewAny.v2.ts` — POC migration (90→35 lines)
**Open questions left in v2 service comments** (touch-points must answer these):
- `setLabel` — special vs just an option? `setHidden` — same?
- `on('change')` watches `WidgetValue.value` only — how do extensions watch options/props?
- `setSerializeValue` callback — should be `on('serialize')` or `onBeforeSerialize`?
- Get/set vs getters/setters — should NodeHandle expose `get pos()` accessors?
- `getProperties` — current `properties` bag is heavily used by extensions for "persist across teardown"; v2 must verify that pattern still works
- `addWidget` returns by what mechanism? sync dispatch? promise?
- Widget figler tree / coverage report of "strangler-figged vs re-implemented vs unsupported"
These open questions become *test cases*: for each, the database tells us how many extensions in the wild touch the underlying surface.
## Comprehensive surface enumeration
The audit covers **8 surface families**. Each family contains specific patterns to search for.
### S1 — `ComfyExtension` lifecycle hooks (17 hooks)
From `src/types/comfy.ts`, lines 144-266:
| Hook | Core extension files using it | Replacement direction |
|---|---:|---|
| `init` | 16 | unchanged in v2 (ExtensionOptions.init) |
| `setup` | 3 | unchanged in v2 (ExtensionOptions.setup) |
| `addCustomNodeDefs` | 1 | unknown — may need v2 registration API |
| `getCustomWidgets` | 4 | replaced by `defineWidgetExtension` |
| `beforeRegisterNodeDef` | 10 | replaced by `nodeTypes` filter + `inspectNodeDef` (G1) |
| `beforeRegisterVueAppNodeDefs` | 0 | candidate for removal |
| `registerCustomNodes` | 3 | NO v2 equivalent (D4-G2 BLOCKER) |
| `loadedGraphNode` | 0 (core), 1 (entire wild corpus) | candidate for removal |
| `nodeCreated` | 12 | `defineNodeExtension({ nodeCreated })` |
| `beforeConfigureGraph` | 1 | needs decision — graph lifecycle hook |
| `afterConfigureGraph` | 0 | candidate for removal |
| `getSelectionToolboxCommands` | 0 | candidate for removal |
| `getCanvasMenuItems` | 4 | EXISTS — replaces canvas right-click monkey-patching |
| `getNodeMenuItems` | 4 | EXISTS — replaces node right-click monkey-patching (P6 in R4) |
| `onAuthUserResolved` | 1 | unchanged |
| `onAuthTokenRefreshed` | 1 | unchanged |
| `onAuthUserLogout` | 1 | unchanged |
### S2 — `LGraphNode.prototype` methods commonly patched
Already-confirmed (R4): `onNodeCreated`, `onExecuted`, `onConnectionsChange`, `onRemoved`, `getExtraMenuOptions`, `convertWidgetToInput`, `onGraphConfigured`, `onConfigure`, `onInputDblClick`.
Add to search: `onAdded`, `onSerialize`, `onDeserialize`, `onDrawForeground`, `onDrawBackground`, `onSelected`, `onDeselected`, `onMouseDown`, `onMouseEnter`, `onMouseLeave`, `onDblClick`, `onPropertyChanged`, `onWidgetChanged`, `onResize`, `onAction`, `onConnectInput`, `onConnectOutput`, `onConfigure`, `onWorkflowConfigure`, `onConnectionsChange`, `onConfigure`, `onCreate`, `clone`, `computeSize`.
### S3 — `LGraphCanvas.prototype` methods commonly patched
Confirmed (R4 P7): `processKey`, `processContextMenu`, `computeVisibleNodes`. Our own core: `processMouseDown`, `processMouseMove` (simpleTouchSupport.ts).
Add to search: `drawNode`, `drawNodeShape`, `drawConnections`, `onMouseDown`, `onDblClick`, `getCanvasMenuOptions`, `getNodeMenuOptions`, `getGroupMenuOptions`, `processNodeWidgets`, `selectNodes`, `deselectAllNodes`, `setSelectedNodes`.
### S4 — Widget-level patterns (the heart of widget-api-thoughts.md)
- `.callback` chaining (R4 P1) — the dominant value-change pattern
- `.value` direct reads/writes (R4 evidence: imageCompare, widgetInputs, customWidgets, saveImageExtraOutput)
- `.serializeValue` assignment (dynamicPrompts.v2 uses it)
- `.options.*` direct mutation
- `.computedHeight`, `.y`, `.last_y` — layout-level reads
- `.options.values` — combo widget values
- `.options.serialize`, `.options.hidden`, `.options.readonly` — option flags
- Custom widget types declared via `getCustomWidgets`
- `addDOMWidget(name, type, element, options)` — DOM widget contribution (R4 P9)
**Widget thoughts file flags lifecycle dependencies** (widget-api-thoughts.md:25-30):
- 3D widgets: file uploads
- Webcam widgets: heavy perf
- Webcam widgets: lifecycle-dependent serialization
- Widgets whose post-serialize value depends on lifecycle steps
These need explicit DB entries with `lifecycle_dependent: true` flag.
### S5 — `ComfyApi` / `app.api` event surfaces
Confirmed (R4 P8): `addEventListener('executed', …)`, custom `'extName.eventName'` events.
Add to search: `addEventListener('executing', …)`, `'progress'`, `'progress_state'`, `'status'`, `'reconnecting'`, `'reconnected'`, `'execution_start'`, `'execution_success'`, `'execution_error'`, `'execution_cached'`, `'b_preview'`, `'logs'`.
### S6 — `ComfyApp` god-object touch points
- `app.graph` — direct LiteGraph object access
- `app.canvas` — direct LGraphCanvas access
- `app.canvasManager` — newer wrapper
- `app.queuePrompt` — submit a workflow
- `app.graphToPrompt` — serialize current graph to API payload
- `app.loadGraphData` — load a workflow JSON
- `app.extensionManager` — ExtensionManager registry access
- `app.api` — see S5
- `app.getNodeDefs` — node definition registry
- `app.registerExtension` — the entry point itself
- `app.ui` — legacy UI shim
### S7 — Window / global escape hatches
- `window.app` — escape hatch documented in index.ts
- `window.graph` — escape hatch documented in index.ts
- `window.LiteGraph` — direct LiteGraph access
- `window.LGraphCanvas` — direct canvas class access
- `window.comfyAPI.modules[...]` — production-only shim mechanism (per extension-development-guide.md)
### S8 — Special node properties (magic flags)
- `nodeType.prototype.isVirtualNode` (R4 P10) — virtual node flag
- `nodeType.prototype.serialize_widgets` — serialization toggle
- `nodeType.prototype.color`, `bgcolor` — visual override
- `nodeType.prototype.shape` — node shape override
- `nodeType['@<input>']` — input-type metadata (Eclipse pattern)
- `nodeType.category` — menu category override
### S9 — Non-Node entity kinds (per ADR 0008)
ADR 0008 enumerates **six** entity kinds; the bulk of the ecosystem touches more than just `Node` and `Widget`. These touch points are largely undocumented in the v1 extension API.
- **Reroute** (`Reroute`, `RerouteId`) — `LiteGraph.createRerouteOnLink`, `graph.reroutes`, `node.connectByRerouteId`
- **Group** (`LGraphGroup`) — `graph.groups`, `group.color`, `group.font`, `group.font_size`, `group.children`
- **Link** (`LLink`, `LinkId`) — `link.color`, `link._pos`, `link._dragging`, `link.data`
- **Slot** (`SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) — `slot.color_on/_off`, `slot.shape`, `slot.dir`, `slot.localized_name`
- **Subgraph virtual nodes** — set/get virtual node trick (KJNodes), `nodeType.isVirtualNode = true` (S8) coupled with `graphToPrompt` rewriting (S6.A1)
### S10 — Dynamic node API (slot/connection mutation at runtime)
- `node.addInput(name, type)` / `node.removeInput(slot)` — runtime input mutation (typically inside `onConnectionsChange`)
- `node.addOutput(name, type)` / `node.removeOutput(slot)` — runtime output mutation
- `node.connect(srcSlot, target, dstSlot)` / `node.disconnectInput(slot)` / `node.disconnectOutput(slot)` — programmatic linking
- `node.findOutputSlot(name)` / `node.findInputSlot(name)` — slot lookup by name
- `node.setDirtyCanvas(true, true)` — force redraw (extremely common after any mutation)
- `node.collapse()` / `node.setSize([w,h])` — imperative geometry
### S11 — Graph-level state and change-tracking
- `graph._version++` and `graph._version` reads — change-tracking signal **(project AGENTS.md §5: affects 40+ repos)**
- `graph.add(node)` / `graph.remove(node)` / `graph.findNodesByType(type)` / `graph.findNodeById(id)`
- `graph.serialize()` / `graph.configure(json)` — full-graph serialization (related to S6.A1 graphToPrompt but distinct)
- `graph.beforeChange()` / `graph.afterChange()` — explicit batching seam
- `graph.onNodeAdded` / `graph.onNodeRemoved` / `graph.onNodeConnectionChange` — graph-level callbacks (vs per-node)
### S12 — Shell UI registries (sidebar / bottom panel / commands / toasts)
These are *declarative* surfaces in v1 (extensions push registrations) but their semantics are still public API. Migration must preserve names and contracts.
- `extensionManager.registerSidebarTab(...)``SidebarTabExtension`
- `extensionManager.registerBottomPanelTab(...)``BottomPanelExtension`
- `commandManager.registerCommand(...)``CommandManager`
- `toastManager.add(...)` / `toastManager.remove(...)``ToastManager`
- `app.registerExtension({ settings: [...] })` — Settings system contributions
- `app.registerExtension({ keybindings: [...] })` — Keybinding contributions
- `app.registerExtension({ commands: [...], menuCommands: [...] })` — Menu/command contributions
### S13 — Schema interpretation (`ComfyNodeDef` / `InputSpec`)
Extensions inspect the node-def schema directly to drive UI/behavior — this is a public API by accident.
- `nodeData.input.required` / `nodeData.input.optional` / `nodeData.input.hidden` — input bag inspection
- `nodeData.output[]` / `nodeData.output_name[]` / `nodeData.output_is_list[]` — output schema inspection
- `nodeData.output_node` — special "output node" boolean flag
- `nodeData.category` / `nodeData.python_module` — origin metadata
- `InputSpec` sentinel objects — `["INT", { default, min, max, step }]`, `["STRING", { multiline }]`, `["COMBO", { values, default }]`, `["IMAGEUPLOAD", {...}]`, etc.
### S14 — Identity / Locator scheme
- `NodeLocatorId` — encodes `(graphScope, nodeId)` for cross-subgraph references
- `NodeExecutionId` — backend execution-graph identifier
- `parseNodeLocatorId` / `createNodeLocatorId` / `isNodeLocatorId` — public helpers exported from `src/types/index.ts`
- Implicit pattern: extensions resolve "node X in subgraph Y" — must work after subgraph promotion
### S15 — Output system (per `widget-api-thoughts.md`)
`widget-api-thoughts.md` flags this as a separate change axis from widgets:
- Dynamic output mutation via `node.addOutput` / `node.removeOutput` (cross-references S10)
- Schema-declared outputs (preferred end-state) — `OUTPUT_TYPES`-style explicit declaration
- `nodeData.output_node` flag — node is a terminal/sink
- `node.onExecuted({ images: [...] })` — output-display pattern (cross-references S2.N2)
- "Force declaration" goal: extensions must declare output types in the node schema, not mutate at runtime
## Database schema
Each entry is a YAML record:
```yaml
- pattern_id: P1.1 # stable ID for cross-reference
surface_family: S4 # S1-S8
surface: "widget.callback assignment" # human-readable name
fingerprint: 'w.callback = function(v) {...}' # regex-ish
semantic: "subscribe to widget value change" # what extensions are *trying* to do
v2_replacement: "widget.on('change', fn)" # proposed
decision_ref: D3.3 # which decision doc covers it
test_target: WIDGET_VALUE_CHANGE_LISTENER # test framework symbol
evidence:
- repo: crom8505/ComfyUI-Dynamic-Sigmas
file: web/js/graph_sigmas.js
lines: [79, 80]
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
stars: 12 # github stars (cached, asof date)
stars_asof: 2026-05-06
variant: canonical # canonical | unsafe | with-bind | tempCallback-swap | per-instance | prototype
breakage_class: silent # silent | loud | undefined-behavior | crash
notes: "fourteen instances in same file"
derived:
occurrences: 7 # rolled up from evidence
repos_touched: 5
cumulative_stars: 245
canonical_signatures: 1 # how many distinct shapes seen (P4 had 6 for onConnectionsChange!)
breakage_classes: [silent, undefined-behavior]
blast_radius: 3.2 # see formula
```
## Blast-radius scoring formula
Goal: rank patterns by how disruptive their breakage would be in v2 rollout.
```
blast_radius = 0.40 * log10(1 + cumulative_stars)
+ 0.20 * log10(1 + occurrences)
+ 0.15 * canonical_signatures # more shapes = more migration cases to support
+ 0.15 * silent_breakage_weight # silent > loud > crash for danger
+ 0.10 * lifecycle_coupling # 0/1/2; widgets that break on serialize timing get 2
```
Where:
- `silent_breakage_weight` = max over evidence: silent=1.0, undefined=0.6, loud=0.3, crash=0.2
- `lifecycle_coupling` = 0 (none) | 1 (depends on init/teardown order) | 2 (depends on serialization-timing or DOM-mount-timing)
Rationale:
- `log10` on stars + occurrences damps mega-popular packs from drowning out long-tail diversity
- Silent breakage scores higher than loud — these are the ones that destroy trust
- Lifecycle coupling captures widget-api-thoughts.md concerns (3D, webcam)
- Canonical signatures captures "the API has no schema" risk (R4 P4 with 6 sigs)
A blast_radius ≥ 3.0 = MUST have a v1-compat shim or the migration story breaks.
## Star-fetching strategy
For each unique repo:
```bash
gh api "repos/<owner>/<name>" --jq '.stargazers_count'
```
Cache in `research/touch-points/star-cache.yaml`:
```yaml
- repo: crom8505/ComfyUI-Dynamic-Sigmas
stars: 12
asof: 2026-05-06
```
Refresh quarterly. If gh CLI errors (rate limit, repo gone), record `stars: null` and `error: <reason>`.
## Workflow
1. **Plan + schema (this doc)**
2. **Build initial database** — start with the 12 patterns from R4, structured properly
3. **Sweep S1S8 systematically** — batched code search, populate evidence
4. **Star fetch pass**`gh api` for every unique repo, populate cache
5. **Compute derived fields** — script that rolls up evidence into derived metrics
6. **Generate ranked report**`database-by-blast-radius.md`
7. **Map to test framework** — each pattern_id → test symbol
## Dispatch strategy for queries
- ~50 queries needed across S1S8 (each surface gets 1-3 queries)
- Run in parallel batches of 4-6 (MCP tolerates this if no DNS error)
- Retry failed queries with 3-token reformulations (R4 workaround)
- After each batch: append findings to `database.yaml`, never overwrite
- After full sweep: run star-fetch script, run roll-up script
## Integration with the test framework
Each pattern in the database becomes a test triple:
1. **v1 contract test** (legacy): proves the v1 hook still works for shimmed extensions
2. **v2 contract test** (new): proves the v2 replacement covers the same semantic
3. **Migration test**: takes a real extension snippet from evidence, confirms it works in v2 (or fails with a documented compat error)
The test framework's "compatibility floor" is: every blast_radius ≥ 2.0 entry MUST pass all three tests before v2 ships.
## Out of scope (deferred)
- Sandboxing model (Chrome-extension-style isolation): noted in CONTEXT.md, deferred
- Performance benchmarks vs v1: separate workstream
- Documentation generation from the database: separate workstream
- npm package design for `@comfyui/extension-api`: separate workstream (per R4 P11 finding)

File diff suppressed because it is too large Load Diff

View File

@@ -1,724 +0,0 @@
# ───────────────────────────────────────────────────────────────────────
# GitHub star cache for repos referenced in database.yaml
# Refresh: bash scripts/fetch-stars.sh
# Asof dates allow drift detection
# ───────────────────────────────────────────────────────────────────────
asof: 2026-05-08
populated_via: scripts/fetch-stars.sh
repos:
- repo: 40740/ComfyUI_LayerStyle_Bmss
stars: 0
archived: false
forks: 0
last_commit: 2024-10-16
asof: 2026-05-08
- repo: 834t/ComfyUI_834t_scene_composer
stars: 5
archived: false
forks: 1
last_commit: 2026-04-03
asof: 2026-05-08
- repo: aicocoa981/WhatDreamsCost-ComfyUI-private
stars: 1
archived: false
forks: 0
last_commit: 2026-03-30
asof: 2026-05-08
- repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
stars: 405
archived: false
forks: 25
last_commit: 2025-10-27
asof: 2026-05-08
- repo: akawana/ComfyUI-Folded-Prompts
stars: 4
archived: false
forks: 2
last_commit: 2026-04-30
asof: 2026-05-08
- repo: AkihaTatsu/ComfyUI-Simple-Utility-Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-04-11
asof: 2026-05-08
- repo: alankent/ComfyUI-OA-360-Clip
stars: 0
archived: false
forks: 1
last_commit: 2025-11-16
asof: 2026-05-08
- repo: AlexZ1967/ComfyUI_ALEXZ_tools
stars: 0
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: ameliacode/comfyui-face3d
stars: 0
archived: false
forks: 0
last_commit: 2026-04-23
asof: 2026-05-08
- repo: andreszs/ComfyUI-Ultralytics-Studio
stars: 3
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: ArtHommage/HommageTools
stars: 4
archived: false
forks: 1
last_commit: 2025-05-20
asof: 2026-05-08
- repo: Azornes/Comfyui-LayerForge
stars: 312
archived: false
forks: 16
last_commit: 2026-05-01
asof: 2026-05-08
- repo: becky3/comfyui-workspace
stars: 0
archived: false
forks: 0
last_commit: 2026-04-13
asof: 2026-05-08
- repo: BennyKok/comfyui-deploy
stars: 1508
archived: false
forks: 222
last_commit: 2025-11-13
asof: 2026-05-08
- repo: brycecovert/ComfyUI-compass-images
stars: 0
archived: false
forks: 0
last_commit: 2026-04-23
asof: 2026-05-08
- repo: choovin/comfyui-api
stars: 3
archived: false
forks: 0
last_commit: 2026-03-08
asof: 2026-05-08
- repo: chyer/Chye-ComfyUI-Toolset
stars: 0
archived: false
forks: 1
last_commit: 2026-03-10
asof: 2026-05-08
- repo: coeuskoalemoss/comfyUI-layerstyle-custom
stars: 0
archived: false
forks: 0
last_commit: 2025-06-23
asof: 2026-05-08
- repo: ComfyNodePRs/PR-comfyui-pkg39-ccab78b5
stars: 0
archived: false
forks: 0
last_commit: 2024-07-31
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI_frontend
stars: 1787
archived: false
forks: 563
last_commit: 2026-05-08
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI-Manager
stars: 14564
archived: false
forks: 2187
last_commit: 2026-05-08
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI-test-framework
stars: 2
archived: false
forks: 1
last_commit: 2026-03-23
asof: 2026-05-08
- repo: ComfyUI-Kelin/ComfyUI_Image_Anything
stars: 3
archived: false
forks: 0
last_commit: 2026-04-20
asof: 2026-05-08
- repo: Creepybits/ComfyUI-Creepy_nodes
stars: 29
archived: false
forks: 5
last_commit: 2026-04-14
asof: 2026-05-08
- repo: criskb/Comfypencil
stars: 0
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: criskb/Fancy_Grid
stars: 0
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: crom8505/ComfyUI-Dynamic-Sigmas
stars: 8
archived: false
forks: 2
last_commit: 2026-03-30
asof: 2026-05-08
- repo: Damkohler/jlc-comfyui-nodes
stars: 16
archived: false
forks: 4
last_commit: 2026-04-17
asof: 2026-05-08
- repo: darth-veitcher/comfyui-ollama-model-manager
stars: 1
archived: false
forks: 1
last_commit: 2025-11-05
asof: 2026-05-08
- repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
stars: 7
archived: false
forks: 2
last_commit: 2026-04-22
asof: 2026-05-08
- repo: diodiogod/TTS-Audio-Suite
stars: 911
archived: false
forks: 101
last_commit: 2026-05-08
asof: 2026-05-08
- repo: dorpxam/ComfyUI-LTX2-Microscope
stars: 4
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: DumiFlex/ComfyUI-Wildcard-Pipeline
stars: 4
archived: false
forks: 2
last_commit: 2026-04-08
asof: 2026-05-08
- repo: egormly/ComfyUI-EG_Tools
stars: 0
archived: false
forks: 0
last_commit: 2025-11-19
asof: 2026-05-08
- repo: EmanuelRiquelme/comfyui-art-venture
stars: 0
archived: false
forks: 0
last_commit: 2024-09-04
asof: 2026-05-08
- repo: EnragedAntelope/EA_LMStudio
stars: 7
archived: false
forks: 4
last_commit: 2026-04-22
asof: 2026-05-08
- repo: Firetheft/ComfyUI-Animate-Progress
stars: 3
archived: false
forks: 1
last_commit: 2025-09-09
asof: 2026-05-08
- repo: FloyoAI/ComfyUI-SoundFlow
stars: 0
archived: false
forks: 0
last_commit: 2025-11-21
asof: 2026-05-08
- repo: flymyd/koishi-plugin-comfyui-client
stars: 0
archived: false
forks: 1
last_commit: 2025-08-21
asof: 2026-05-08
- repo: FunnyFinger/Dynamic_Sliders_stack
stars: 4
archived: false
forks: 2
last_commit: 2025-04-22
asof: 2026-05-08
- repo: gigici/ComfyUI_BlendPack
stars: 1
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: goodtab/ComfyUI-Custom-Scripts
stars: 0
archived: false
forks: 0
last_commit: 2024-09-02
asof: 2026-05-08
- repo: guido-gfv/gfv_pro_upgrade
stars: 0
archived: false
forks: 0
last_commit: 2026-04-20
asof: 2026-05-08
- repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
stars: 0
archived: false
forks: 1
last_commit: 2024-05-22
asof: 2026-05-08
- repo: hernantech/comfymcp
stars: 0
archived: false
forks: 0
last_commit: 2026-01-03
asof: 2026-05-08
- repo: hhayiyuan/ComfyUI-FFmpegURLMedia
stars: 2
archived: false
forks: 2
last_commit: 2026-01-02
asof: 2026-05-08
- repo: huafitwjb/ComfyUI-GO-Mobile-app
stars: 6
archived: false
forks: 1
last_commit: 2026-03-04
asof: 2026-05-08
- repo: ialhabbal/compare
stars: 1
archived: false
forks: 2
last_commit: 2026-03-31
asof: 2026-05-08
- repo: IAMCCS/IAMCCS-nodes
stars: 92
archived: false
forks: 6
last_commit: 2026-05-04
asof: 2026-05-08
- repo: IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools
stars: 1
archived: false
forks: 2
last_commit: 2026-03-15
asof: 2026-05-08
- repo: JichaoLiang/Immortal_comfyui_public
stars: 0
archived: false
forks: 0
last_commit: 2025-12-05
asof: 2026-05-08
- repo: jiekouai/ComfyUI-JieKou-API
stars: 1
archived: false
forks: 0
last_commit: 2026-03-25
asof: 2026-05-08
- repo: jonstreeter/comfyui-compressed-metadata
stars: 0
archived: false
forks: 1
last_commit: 2025-10-12
asof: 2026-05-08
- repo: ketle-man/ComfyUI-Workflow-Studio
stars: 2
archived: false
forks: 0
last_commit: 2026-04-28
asof: 2026-05-08
- repo: kijai/ComfyUI-KJNodes
stars: 2569
archived: false
forks: 292
last_commit: 2026-05-08
asof: 2026-05-08
- repo: koshimazaki/ComfyUI-Koshi-Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-03-12
asof: 2026-05-08
- repo: krismasdev/ComfyUI-Flux-Continuum
stars: 0
archived: false
forks: 0
last_commit: 2025-08-04
asof: 2026-05-08
- repo: KumihoIO/kumiho-plugins
stars: 2
archived: false
forks: 0
last_commit: 2026-03-28
asof: 2026-05-08
- repo: kyuz0/amd-strix-halo-comfyui-toolboxes
stars: 109
archived: false
forks: 14
last_commit: 2026-02-13
asof: 2026-05-08
- repo: LaoMaoBoss/ComfyUI-WBLESS
stars: 0
archived: false
forks: 1
last_commit: 2026-04-07
asof: 2026-05-08
- repo: Lightricks/ComfyUI-LTXVideo
stars: 3587
archived: false
forks: 390
last_commit: 2026-04-26
asof: 2026-05-08
- repo: linjm8780860/ljm_comfyui
stars: 0
archived: false
forks: 0
last_commit: 2026-04-29
asof: 2026-05-08
- repo: lordwedggie/xcpNodes
stars: 0
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: lucafoscili/lf-nodes
stars: 34
archived: false
forks: 3
last_commit: 2025-12-23
asof: 2026-05-08
- repo: m3rr/h4_Live
stars: 2
archived: false
forks: 2
last_commit: 2026-05-07
asof: 2026-05-08
- repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
stars: 97
archived: false
forks: 6
last_commit: 2026-05-08
asof: 2026-05-08
- repo: max-dingsda/ComfyUI-AllinOne-LazyNode
stars: 0
archived: false
forks: 1
last_commit: 2026-03-30
asof: 2026-05-08
- repo: maxi45274/ComfyUI_LinkFX
stars: 3
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: melMass/comfy_mtb
stars: 702
archived: false
forks: 82
last_commit: 2026-03-19
asof: 2026-05-08
- repo: MockbaTheBorg/ComfyUI-Mockba
stars: 1
archived: false
forks: 0
last_commit: 2026-04-13
asof: 2026-05-08
- repo: mudknight/comfyui-mudknight-utils
stars: 0
archived: false
forks: 1
last_commit: 2026-05-08
asof: 2026-05-08
- repo: MuhammadMuradKhan/efficiency-nodes-comfyui
stars: 0
archived: false
forks: 0
last_commit: 2024-02-11
asof: 2026-05-08
- repo: niknah/presentation-ComfyUI
stars: 2
archived: false
forks: 2
last_commit: 2026-04-21
asof: 2026-05-08
- repo: nodelee733/ComfyUI-mxToolkit
stars: 0
archived: false
forks: 0
last_commit: 2026-04-27
asof: 2026-05-08
- repo: nodetool-ai/nodetool
stars: 332
archived: false
forks: 40
last_commit: 2026-05-08
asof: 2026-05-08
- repo: nvmax/aspect-ratio-resizer
stars: 5
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
stars: 45
archived: false
forks: 4
last_commit: 2025-08-09
asof: 2026-05-08
- repo: PGCRT/CRT-Nodes
stars: 108
archived: false
forks: 14
last_commit: 2026-05-03
asof: 2026-05-08
- repo: philippjbauer/devint25-comfyui-api-demo
stars: 0
archived: false
forks: 0
last_commit: 2025-10-09
asof: 2026-05-08
- repo: pictorialink/ComfyUI-Easy-Use
stars: 1
archived: false
forks: 0
last_commit: 2025-07-15
asof: 2026-05-08
- repo: PioneerMNDR/ComfyUI-Polza
stars: 0
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: pixaroma/ComfyUI-Pixaroma
stars: 146
archived: false
forks: 9
last_commit: 2026-05-08
asof: 2026-05-08
- repo: PROJECTMAD/PROJECT-MAD-NODES
stars: 4
archived: false
forks: 1
last_commit: 2026-04-01
asof: 2026-05-08
- repo: Raykosan/ComfyUI_RaykoStudio
stars: 45
archived: false
forks: 2
last_commit: 2026-05-05
asof: 2026-05-08
- repo: rgthree/rgthree-comfy
stars: 3054
archived: false
forks: 226
last_commit: 2026-04-07
asof: 2026-05-08
- repo: robertvoy/ComfyUI-Distributed
stars: 544
archived: false
forks: 57
last_commit: 2026-04-26
asof: 2026-05-08
- repo: rohapa/comfyui-replay
stars: 0
archived: false
forks: 0
last_commit: 2026-03-27
asof: 2026-05-08
- repo: r-vage/ComfyUI_Eclipse
stars: 19
archived: false
forks: 2
last_commit: 2026-05-08
asof: 2026-05-08
- repo: ryanontheinside/ComfyUI_RyanOnTheInside
stars: 801
archived: false
forks: 50
last_commit: 2026-03-20
asof: 2026-05-08
- repo: sammykumar/ComfyUI-SwissArmyKnife
stars: 5
archived: false
forks: 1
last_commit: 2026-01-14
asof: 2026-05-08
- repo: SaturMars/ComfyUI-NVVFR
stars: 0
archived: false
forks: 1
last_commit: 2025-08-05
asof: 2026-05-08
- repo: ShakerSmith/ShakerNodesSuite
stars: 8
archived: false
forks: 0
last_commit: 2026-02-18
asof: 2026-05-08
- repo: shrimbly/willie-comfy-frontend
stars: 0
archived: false
forks: 0
last_commit: 2026-04-28
asof: 2026-05-08
- repo: SKBv0/ComfyUI_SKBundle
stars: 116
archived: false
forks: 7
last_commit: 2026-04-23
asof: 2026-05-08
- repo: SKBv0/ComfyUI_SpideyReroute
stars: 13
archived: false
forks: 2
last_commit: 2025-12-19
asof: 2026-05-08
- repo: sofakid/dandy
stars: 54
archived: false
forks: 4
last_commit: 2025-12-15
asof: 2026-05-08
- repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
stars: 0
archived: false
forks: 1
last_commit: 2026-03-30
asof: 2026-05-08
- repo: SparknightLLC/ComfyUI-EnumCombo
stars: 2
archived: false
forks: 1
last_commit: 2026-05-08
asof: 2026-05-08
- repo: StableLlama/ComfyUI-basic_data_handling
stars: 43
archived: false
forks: 7
last_commit: 2026-05-07
asof: 2026-05-08
- repo: stavzszn/comfyui-teskors-utils
stars: 0
archived: false
forks: 0
last_commit: 2026-03-29
asof: 2026-05-08
- repo: Stibo/comfyui-nifty-nodes
stars: 3
archived: false
forks: 1
last_commit: 2026-03-21
asof: 2026-05-08
- repo: Sunwood-ai-labs/ComfyUI-LTXLongAudio
stars: 0
archived: false
forks: 0
last_commit: 2026-04-01
asof: 2026-05-08
- repo: sypex6/ComfyUI_InstaRAW_Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-04-07
asof: 2026-05-08
- repo: tavyra/ComfyUI_Curves
stars: 1
archived: false
forks: 1
last_commit: 2025-05-08
asof: 2026-05-08
- repo: tetsuoo-online/Comfyui-TOO-Pack
stars: 4
archived: false
forks: 2
last_commit: 2026-04-02
asof: 2026-05-08
- repo: touge/ComfyUI-NCE_Utils
stars: 0
archived: false
forks: 0
last_commit: 2026-01-25
asof: 2026-05-08
- repo: treforyan-hue/comfyui-deploy
stars: 0
archived: false
forks: 0
last_commit: 2026-05-05
asof: 2026-05-08
- repo: Valiant-Cat/ComfyUI-WanMove-Trajectory
stars: 1
archived: false
forks: 0
last_commit: 2025-12-12
asof: 2026-05-08
- repo: viswamohankomati/ComfyUI-Copilot
stars: 0
archived: false
forks: 0
last_commit: 2025-09-17
asof: 2026-05-08
- repo: vjumpkung/comfyui-infinitetalk-native-sampler
stars: 1
archived: false
forks: 2
last_commit: 2026-03-31
asof: 2026-05-08
- repo: Winnougan/WINT8-ComfyUI
stars: 1
archived: false
forks: 2
last_commit: 2026-04-17
asof: 2026-05-08
- repo: xeinherjer-dev/ComfyUI-XENodes
stars: 1
archived: false
forks: 1
last_commit: 2026-05-07
asof: 2026-05-08
- repo: yardimli/SafetensorViewer
stars: 7
archived: false
forks: 0
last_commit: 2025-02-19
asof: 2026-05-08
- repo: yolain/ComfyUI-Easy-Use
stars: 2504
archived: false
forks: 195
last_commit: 2026-04-29
asof: 2026-05-08
- repo: yolain/ComfyUI-Easy-Use-Frontend
stars: 27
archived: false
forks: 9
last_commit: 2026-04-01
asof: 2026-05-08
- repo: yorkane/ComfyUI-KYNode
stars: 10
archived: false
forks: 4
last_commit: 2026-02-04
asof: 2026-05-08
- repo: zhupeter010903/ComfyUI-XYZ-prompt-library
stars: 1
archived: false
forks: 0
last_commit: 2026-04-26
asof: 2026-05-08
- repo: zzggi2024/shaobkj
stars: 1
archived: false
forks: 0
last_commit: 2026-04-27
asof: 2026-05-08
- repo: zzw5516/ComfyUI-zw-tools
stars: 6
archived: false
forks: 1
last_commit: 2025-12-03
asof: 2026-05-08

View File

@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
source node/widget identity is migration and diagnostic metadata only.
## 2. WidgetValueStore
**File:** `src/stores/widgetValueStore.ts`
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
identity (`host node locator + SubgraphInput.name`) instead of interior source
identity.
## 6. Extraction Map

View File

@@ -404,26 +404,21 @@ Whichever candidate is chosen:
instance-specific state beyond inputs — must remain reachable. This is a
constraint, not a current requirement.
### Recommendation and decision criteria
### Decision
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
truth: promotion is adding a typed input to a function signature. The type
system already handles widget creation for typed inputs. Building a parallel
mechanism for "promoted widgets" is building a second, narrower version of
something the system already does.
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
chooses Candidate A for promoted value widgets. It eliminates an entire
subsystem by recognizing a structural truth: promotion is adding a typed input
to a function signature. The type system already handles widget creation for
typed inputs. Building a parallel mechanism for "promoted widgets" is building
a second, narrower version of something the system already does.
The cost of A is a migration path for existing `proxyWidgets` serialization. On
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
inputs and boundary links. This is a one-time ratchet conversion — once
loaded and re-saved, the workflow uses the new format.
**Choose B if** the team determines that promoted widgets must remain
visually or behaviorally distinct from normal input widgets in ways the type →
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
the current release cycle's capacity.
**Decision needed before** Phase 3 of the ECS migration, when systems are
introduced and the widget/connectivity architecture solidifies.
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
into interface inputs and boundary links. Once loaded and re-saved, the workflow
uses the new format. ADR 0009 separates display-only preview exposures from
promoted value widgets; those previews use their own host-scoped serialized
representation instead of linked `SubgraphInput` widgets.
---
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
| Direction | Format | Notes |
| --------------- | ------------------------------- | ---------------------------------------- |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
| Direction | Format | Notes |
| --------------- | ------------------------------- | ------------------------------------------ |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
The "ratchet conversion" pattern: load any supported format, normalize to the
internal model. The system accepts old formats indefinitely but produces the
current format on save.
The migration pattern: load any supported format and normalize to the internal
model. The system accepts old formats indefinitely but produces the current
format on save.
### Widget identity at the boundary
@@ -511,13 +506,12 @@ SubgraphIO {
}
```
If Candidate A (connections-only promotion) is chosen: promoted widgets become
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
migration). On save, `proxyWidgets` is no longer written.
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
serialized in its current format.
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
widgets: they become interface inputs, serialized as additional `SubgraphIO`
entries. On load, legacy value-widget `proxyWidgets` data is converted to
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
are no longer written. Display-only preview exposures use separate
host-scoped `previewExposures` serialization.
### Backward-compatible loading contract
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.0",
"version": "1.45.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -11,7 +11,7 @@
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' 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",
@@ -47,9 +47,6 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:extension-api": "vitest run --config vitest.extension-api.config.mts",
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -1,3 +0,0 @@
docs-build/
build/
node_modules/

View File

@@ -1,50 +0,0 @@
# @comfyorg/extension-api
> **Status**: scaffolded. Package implementation pending PKG3 — see
> `../../../plans/P2-extension-api-package.md` and
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
The official TypeScript declaration package for ComfyUI extensions. This
package replaces the practice of vendoring `comfy.d.ts` files in custom
node repos.
## Install (post-publish)
```bash
pnpm add -D @comfyorg/extension-api
```
```ts
import { defineExtension } from '@comfyorg/extension-api'
export default defineExtension({
name: 'MyExtension',
setup(ctx) {
ctx.onNodeMounted((node) => {
// ...
})
}
})
```
## Source
This package is built from the source-of-truth folder
`../../src/extension-api/`. Do not edit the package's `build/` output
directly.
## Versioning
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
surface has stabilized.
- Breaking changes follow semver strictly from `1.0.0` onward.
## Cross-references
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
- `plans/P2-extension-api-package.md` — package structure plan
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration

View File

@@ -1,28 +0,0 @@
{
"name": "@comfyorg/extension-api",
"version": "0.1.0",
"description": "Official TypeScript extension API for ComfyUI custom nodes",
"type": "module",
"exports": {
".": "./build/index.js"
},
"types": "./build/index.d.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc --emitDeclarationOnly --outDir build",
"docs:build": "tsx scripts/build-docs.ts",
"docs:watch": "tsx scripts/build-docs.ts --watch"
},
"devDependencies": {
"tsx": "catalog:",
"typedoc": "0.28.19",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:api"
]
}
}

View File

@@ -1,470 +0,0 @@
#!/usr/bin/env tsx
/**
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
*
* Steps:
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
* 2. Post-process each markdown file:
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
* - Convert ``` fences without lang tag → ```ts
* - Replace raw [TypeName] cross-refs with MDX relative links
* - Wrap @example blocks in proper code fences
* 3. Write final .mdx files to docs-build/mintlify/
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
*
* Run: pnpm --filter @comfyorg/extension-api docs:build
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkgRoot = path.resolve(__dirname, '..')
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
const watchMode = process.argv.includes('--watch')
// ── Page metadata ────────────────────────────────────────────────────────────
// Controls frontmatter for each generated page. Key = TypeDoc output filename
// stem (lowercased). Unrecognised files get generic metadata.
interface PageMeta {
title: string
sidebarTitle?: string
description: string
icon?: string
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
order: number
}
const PAGE_META: Record<string, PageMeta> = {
// Top-level overview
index: {
title: 'Extension API Overview',
description: 'TypeScript API reference for ComfyUI custom node extensions.',
icon: 'puzzle-piece',
group: 'root',
order: 0
},
// Lifecycle / registration
defineextension: {
title: 'defineExtension',
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
icon: 'code',
group: 'core',
order: 1
},
definenodeextension: {
title: 'defineNodeExtension',
description: 'Register a node-scoped extension reacting to node lifecycle events.',
icon: 'code',
group: 'core',
order: 2
},
definewidgetextension: {
title: 'defineWidgetExtension',
description: 'Register a custom widget type with its own DOM rendering.',
icon: 'code',
group: 'core',
order: 3
},
extensionoptions: {
title: 'ExtensionOptions',
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
group: 'core',
order: 4
},
nodeextensionoptions: {
title: 'NodeExtensionOptions',
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
group: 'core',
order: 5
},
widgetextensionoptions: {
title: 'WidgetExtensionOptions',
description: 'Options object for defineWidgetExtension — custom widget rendering.',
group: 'core',
order: 6
},
onnoderemoved: {
title: 'onNodeRemoved',
sidebarTitle: 'onNodeRemoved',
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
group: 'core',
order: 7
},
onnodemounted: {
title: 'onNodeMounted',
sidebarTitle: 'onNodeMounted',
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
group: 'core',
order: 8
},
// Handles
nodehandle: {
title: 'NodeHandle',
description: 'Controlled access to node state, mutations, slots, and events.',
icon: 'circle-nodes',
group: 'handles',
order: 10
},
widgethandle: {
title: 'WidgetHandle',
description: 'Controlled access to widget state, mutations, and events.',
icon: 'sliders',
group: 'handles',
order: 11
},
slotinfo: {
title: 'SlotInfo',
description: 'Read-only snapshot of a node slot (input or output).',
group: 'handles',
order: 12
},
// Events
nodeexecutedevent: {
title: 'NodeExecutedEvent',
description: 'Payload fired when a node finishes execution.',
group: 'events',
order: 20
},
nodeconnectedevent: {
title: 'NodeConnectedEvent',
description: 'Payload fired when a slot connection is made.',
group: 'events',
order: 21
},
nodedisconnectedevent: {
title: 'NodeDisconnectedEvent',
description: 'Payload fired when a slot connection is removed.',
group: 'events',
order: 22
},
nodepositionchangedevent: {
title: 'NodePositionChangedEvent',
description: 'Payload fired when a node is moved on the canvas.',
group: 'events',
order: 23
},
nodesizechangedevent: {
title: 'NodeSizeChangedEvent',
description: 'Payload fired when a node is resized.',
group: 'events',
order: 24
},
nodemodechangedevent: {
title: 'NodeModeChangedEvent',
description: 'Payload fired when a node execution mode changes.',
group: 'events',
order: 25
},
nodebeforeserializeevent: {
title: 'NodeBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip node data.',
group: 'events',
order: 26
},
widgetvaluechangeevent: {
title: 'WidgetValueChangeEvent',
description: 'Payload fired when a widget value changes.',
group: 'events',
order: 27
},
widgetbeforeserializeevent: {
title: 'WidgetBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip widget value.',
group: 'events',
order: 28
},
widgetbeforequeueevent: {
title: 'WidgetBeforeQueueEvent',
description: 'Pre-queue validation payload — call reject() to cancel queue.',
group: 'events',
order: 29
},
// Shell UI
sidebartabextension: {
title: 'SidebarTabExtension',
description: 'Register a custom sidebar tab.',
group: 'shell',
order: 40
},
bottompanelextension: {
title: 'BottomPanelExtension',
description: 'Register a custom bottom panel tab.',
group: 'shell',
order: 41
},
toastmanager: {
title: 'ToastManager',
description: 'Show toast notifications to the user.',
group: 'shell',
order: 42
},
commandmanager: {
title: 'CommandManager',
description: 'Register keyboard shortcuts and command palette entries.',
group: 'shell',
order: 43
},
extensionmanager: {
title: 'ExtensionManager',
description: 'Access shell UI registration APIs.',
group: 'shell',
order: 44
},
// Identity
nodelocatorid: {
title: 'NodeLocatorId',
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
group: 'identity',
order: 50
},
nodeexecutionid: {
title: 'NodeExecutionId',
description: 'Branded string ID for a specific node execution run.',
group: 'identity',
order: 51
}
}
const GROUP_LABELS: Record<PageMeta['group'], string> = {
root: 'Extensions API',
core: 'Registration',
handles: 'Handles',
events: 'Events',
shell: 'Shell UI',
identity: 'Identity'
}
// ── Utilities ────────────────────────────────────────────────────────────────
function slug(stem: string): string {
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function metaFor(stem: string): PageMeta {
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
return (
PAGE_META[key] ?? {
title: stem,
description: `API reference for ${stem}.`,
group: 'core',
order: 99
}
)
}
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
function toMintlifyMdx(raw: string, stem: string): string {
const meta = metaFor(stem)
// Build frontmatter
const fm: string[] = [
`---`,
`title: "${meta.title}"`,
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
`description: "${meta.description}"`,
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
`---`
]
let body = raw
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
body = body.replace(/^# .+\n+/, '')
// Ensure opening code fences that have no lang tag get `ts`
// Only match a ``` that is immediately followed by a newline (opening fence),
// not a closing fence (which also has just ``` + newline but we can detect
// by context: opening fences follow non-fence lines; closing fences follow content).
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
// We track state via a flag pass instead of a single regex.
let inBlock = false
body = body
.split('\n')
.map((line) => {
if (inBlock) {
if (line.trim() === '```') { inBlock = false; return line }
return line
}
if (line.startsWith('```')) {
if (line.trim() === '```') {
// bare opening fence → add ts
inBlock = true
return '```ts'
}
// has a lang tag already
inBlock = true
return line
}
return line
})
.join('\n')
// TypeDoc emits `typescript` lang tag; normalize to `ts`
body = body.replace(/^```typescript\b/gm, '```ts')
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
body = body.replace(
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
(_match, label, _category, file) => `[${label}](./${slug(file)})`
)
// Same-dir links
body = body.replace(
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
(_match, label, file) => `[${label}](./${slug(file)})`
)
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
// code examples to be directly under prose without a sub-heading.
// Flatten "## Example\n\n```ts" → "```ts"
body = body.replace(/^## Example\s*\n+/gm, '')
// Stability tags: render as a <Tip> callout
body = body.replace(
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
(_match, level) => {
const label =
level === 'stable'
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
: level === 'experimental'
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
return label
}
)
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
body = body.replace(
/^Stability: (stable|experimental|deprecated)\s*$/gm,
(_match, level) => {
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
return '<Warning>**Stability:** Deprecated</Warning>'
}
)
return [...fm, '', body.trim(), ''].join('\n')
}
// ── Nav snippet builder ───────────────────────────────────────────────────────
interface NavPage {
group?: string
pages: (string | NavPage)[]
}
function buildNavSnippet(stems: string[]): NavPage {
const byGroup: Record<string, string[]> = {}
for (const stem of stems) {
const meta = metaFor(stem)
const group = meta.group
if (!byGroup[group]) byGroup[group] = []
byGroup[group].push(`extensions/api/${slug(stem)}`)
}
// Sort each group by order
const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order)
const sortedByGroup: Record<string, string[]> = {}
for (const stem of sortedStems) {
const group = metaFor(stem).group
if (!sortedByGroup[group]) sortedByGroup[group] = []
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
}
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
const pages: (string | NavPage)[] = []
// Overview at top level
if (sortedByGroup['root']) {
for (const p of sortedByGroup['root']) pages.push(p)
}
for (const grp of groupOrder) {
if (grp === 'root') continue
const grpPages = sortedByGroup[grp]
if (!grpPages?.length) continue
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
}
return { group: 'Extensions API', pages }
}
// ── Main pipeline ────────────────────────────────────────────────────────────
function runTypedoc(): void {
console.log('▶ Running TypeDoc...')
execSync(
`npx typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
{ cwd: pkgRoot, stdio: 'inherit' }
)
}
function processFiles(): void {
if (!fs.existsSync(rawDir)) {
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
}
fs.mkdirSync(mintlifyDir, { recursive: true })
const mdFiles = fs.readdirSync(rawDir, { recursive: true })
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
const stems: string[] = []
for (const relPath of mdFiles) {
const src = path.join(rawDir, relPath)
const stem = path.basename(relPath, '.md')
const raw = fs.readFileSync(src, 'utf8')
const mdx = toMintlifyMdx(raw, stem)
const destName = slug(stem) + '.mdx'
const dest = path.join(mintlifyDir, destName)
fs.writeFileSync(dest, mdx)
console.log(`${relPath} → mintlify/${destName}`)
stems.push(stem)
}
// Write nav snippet
const nav = buildNavSnippet(stems)
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
console.log(` ✔ nav-snippet.json`)
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
console.log(` ${stems.length} pages + nav-snippet.json`)
}
function run(): void {
runTypedoc()
processFiles()
}
if (watchMode) {
// Simple watch: re-run on change to source files
console.log('👁 Watch mode — watching src/extension-api/**')
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
let debounce: ReturnType<typeof setTimeout> | null = null
run()
fs.watch(srcDir, { recursive: true }, () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
console.log('\n🔄 Source changed — rebuilding...')
try { run() } catch (e) { console.error(e) }
}, 500)
})
} else {
run()
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["../../src/*"]
}
},
"include": [
"../../src/extension-api/**/*.ts"
],
"exclude": [
"../../src/**/*.test.ts",
"../../src/**/*.spec.ts",
"../../src/**/*.vue"
]
}

View File

@@ -1,37 +0,0 @@
{
"entryPoints": ["../../src/extension-api/index.ts"],
"tsconfig": "./tsconfig.docs.json",
"out": "./docs-build/raw",
"plugin": ["typedoc-plugin-markdown"],
"excludeInternal": true,
"excludePrivate": true,
"excludeProtected": true,
"readme": "none",
"skipErrorChecking": true,
"githubPages": false,
"blockTags": ["@stability", "@packageDocumentation", "@example", "@typeParam", "@returns", "@deprecated", "@remarks"],
"hideGenerator": true,
"useCodeBlocks": true,
"flattenOutputFiles": false,
"entryFileName": "index",
"fileExtension": ".md",
"outputFileStrategy": "members",
"hidePageHeader": false,
"hideBreadcrumbs": false,
"useHTMLAnchors": false,
"sanitizeComments": true,
"expandObjects": false,
"parametersFormat": "table",
"propertiesFormat": "table",
"typeDeclarationFormat": "table",
"indexFormat": "table",
"tableColumnSettings": {
"hideDefaults": false,
"hideInherited": false,
"hideModifiers": false,
"hideOverrides": false,
"hideSources": true,
"hideValues": false,
"leftAlignHeaders": false
}
}

View File

@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
})
})
describe('joinFilePath', () => {
it('joins subfolder and filename with normalized slash separators', () => {
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
'nested/folder/child/file.png'
)
})
it('trims boundary separators without changing the filename body', () => {
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
'nested/folder/file.png'
)
})
it('returns the normalized filename when no subfolder is provided', () => {
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
})
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
})
})
describe('getFilePathSeparatorVariants', () => {
it('returns slash and backslash variants for nested paths', () => {
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
'nested/folder/file.png',
'nested\\folder\\file.png'
])
})
it('returns a single value when no separator is present', () => {
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')

View File

@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
}
}
export function joinFilePath(
subfolder: string | null | undefined,
filename: string | null | undefined
): string {
const normalizedSubfolder = normalizeFilePathSeparators(
subfolder ?? ''
).replace(/^\/+|\/+$/g, '')
const normalizedFilename = normalizeFilePathSeparators(
filename ?? ''
).replace(/^\/+/g, '')
if (!normalizedSubfolder) return normalizedFilename
if (!normalizedFilename) return normalizedSubfolder
return `${normalizedSubfolder}/${normalizedFilename}`
}
export function getFilePathSeparatorVariants(filepath: string): string[] {
const slashPath = normalizeFilePathSeparators(filepath)
const backslashPath = slashPath.replace(/\//g, '\\')
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
}
function normalizeFilePathSeparators(filepath: string): string {
return filepath.replace(/[\\/]+/g, '/')
}
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
const normalizedPath = normalizeFilePathSeparators(filepath)
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash

View File

@@ -11,6 +11,7 @@
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>

View File

@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import { useDialogStore } from '@/stores/dialogStore'
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
type Props = ComponentProps<typeof ConfirmationDialogContent>
@@ -13,7 +13,23 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
messages: {
en: {
g: {
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
overwrite: 'Overwrite',
save: 'Save',
no: 'No',
ok: 'OK',
close: 'Close'
},
desktopMenu: {
reinstall: 'Reinstall'
}
}
},
missingWarn: false,
fallbackWarn: false
})
@@ -24,10 +40,9 @@ describe('ConfirmationDialogContent', () => {
})
function renderComponent(props: Partial<Props> = {}) {
return render(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
const user = userEvent.setup()
render(ConfirmationDialogContent, {
global: { plugins: [i18n] },
props: {
message: 'Test message',
type: 'default',
@@ -35,6 +50,7 @@ describe('ConfirmationDialogContent', () => {
...props
} as Props
})
return { user }
}
it('renders long messages without breaking layout', () => {
@@ -44,42 +60,103 @@ describe('ConfirmationDialogContent', () => {
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
it('renders the hint as a status alert when provided', () => {
renderComponent({ hint: 'This action cannot be undone.' })
const status = screen.getByRole('status')
expect(status).toHaveTextContent('This action cannot be undone.')
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
it('does not render a status alert when hint is omitted', () => {
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
describe('button surface per type', () => {
it("type='default' renders Cancel and Confirm", () => {
renderComponent({ type: 'default' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Confirm' })
).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
it("type='delete' renders Cancel and Delete", () => {
renderComponent({ type: 'delete' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
expect(onConfirm).toHaveBeenCalledWith(false)
it("type='overwrite' renders Cancel and Overwrite", () => {
renderComponent({ type: 'overwrite' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Overwrite' })
).toBeInTheDocument()
})
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
renderComponent({ type: 'dirtyClose' })
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it("type='info' renders only OK (no Cancel)", () => {
renderComponent({ type: 'info' })
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
})
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
it('confirm callback receives true and closes the dialog', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
const { user } = renderComponent({ type: 'default', onConfirm })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
await user.click(screen.getByRole('button', { name: 'Confirm' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(closeSpy).toHaveBeenCalledOnce()
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
describe('dirtyClose deny label', () => {
it('uses the provided denyLabel for the deny button', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'No' })
).not.toBeInTheDocument()
})
it('falls back to "No" when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
})
})

View File

@@ -9,16 +9,14 @@
{{ item }}
</li>
</ul>
<Message
<div
v-if="hint"
class="mt-2"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
role="status"
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
>
{{ hint }}
</Message>
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
<span>{{ hint }}</span>
</div>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-4">
<div
@@ -115,7 +113,6 @@
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -44,24 +44,24 @@ watch(
}
)
function isSectionCollapsed(nodeId: string): boolean {
function isSectionCollapsed(nodeId: NodeId): boolean {
// Defaults to collapsed when not explicitly set by the user
return collapseMap[nodeId] ?? true
}
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
return searchedWidgetsSectionDataList.value.every(({ node }) =>
isSectionCollapsed(String(node.id))
isSectionCollapsed(node.id)
)
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
setSectionCollapsed(String(node.id), collapse)
setSectionCollapsed(node.id, collapse)
}
}
})
@@ -101,7 +101,7 @@ async function searcher(query: string) {
:key="node.id"
:node
:widgets
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
:collapse="isSectionCollapsed(node.id) && !isSearching"
:tooltip="
isSearching || widgets.length
? ''
@@ -109,7 +109,7 @@ async function searcher(query: string) {
"
show-locate-button
class="border-b border-interface-stroke"
@update:collapse="setSectionCollapsed(String(node.id), $event)"
@update:collapse="setSectionCollapsed(node.id, $event)"
/>
</TransitionGroup>
</template>

View File

@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -68,19 +68,19 @@ watch(
}
)
function isSectionCollapsed(nodeId: string): boolean {
function isSectionCollapsed(nodeId: NodeId): boolean {
// When not explicitly set, sections are collapsed if multiple nodes are selected
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
}
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
({ node }) => isSectionCollapsed(String(node.id))
({ node }) => isSectionCollapsed(node.id)
)
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
return hasAdvanced
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
setSectionCollapsed(String(node.id), collapse)
setSectionCollapsed(node.id, collapse)
}
advancedCollapsed.value = collapse
}
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
:node
:label
:widgets
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
:collapse="isSectionCollapsed(node.id) && !isSearching"
:show-locate-button="isMultipleNodesSelected"
:tooltip="
isSearching || widgets.length
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="setSectionCollapsed(String(node.id), $event)"
@update:collapse="setSectionCollapsed(node.id, $event)"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">

View File

@@ -252,6 +252,20 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('Log Out')).toBeInTheDocument()
})
describe('credits help icon (FE-617)', () => {
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
renderComponent()
const helpButton = screen.getByTestId('credits-info-button')
expect(helpButton).toBeInTheDocument()
expect(helpButton.tagName).toBe('BUTTON')
expect(helpButton).toHaveAttribute(
'aria-label',
enMessages.credits.unified.tooltip
)
})
})
it('opens user settings and emits close event when settings item is clicked', async () => {
const { user, onClose } = renderComponent()

View File

@@ -41,10 +41,16 @@
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<i
<Button
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
variant="muted-textonly"
size="icon-sm"
class="mr-auto"
:aria-label="$t('credits.unified.tooltip')"
data-testid="credits-info-button"
>
<i class="icon-[lucide--circle-help]" />
</Button>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"

View File

@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true

View File

@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyCloudMediaCandidates
verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
// Cloud media scans return pending for asset verification. OSS scans only
// return pending for generated output/temp media.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyCloudMediaCandidates(pending)
await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)

View File

@@ -1,39 +0,0 @@
// Category: BC.01 — Node lifecycle: creation
// DB cross-ref: S2.N1, S2.N8
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 nodeCreated(node) + beforeRegisterNodeDef → v2 defineNodeExtension({ nodeCreated(handle) })
import { describe, it } from 'vitest'
describe('BC.01 migration — node lifecycle: creation', () => {
describe('nodeCreated parity (S2.N1)', () => {
it.todo(
'v1 nodeCreated and v2 nodeCreated are both invoked the same number of times when N nodes are created'
)
it.todo(
'side-effects applied to the node in v1 nodeCreated(node) are reproducible via NodeHandle methods in v2'
)
it.todo(
'v2 nodeCreated fires in the same relative order as v1 for extensions registered in the same order'
)
})
describe('beforeRegisterNodeDef → type-scoped defineNodeExtension (S2.N8)', () => {
it.todo(
'prototype mutation applied in v1 beforeRegisterNodeDef produces the same per-instance behavior as v2 type-scoped nodeCreated'
)
it.todo(
'v2 type-scoped extension does not affect node types that were excluded, matching v1 type-guard behavior'
)
})
describe('VueNode mount timing invariant', () => {
it.todo(
'both v1 and v2 nodeCreated fire before VueNode mounts — extensions relying on this ordering do not need changes'
)
it.todo(
'extensions that deferred DOM work to a callback in v1 can use onNodeMounted in v2 for the same guarantee'
)
})
})

View File

@@ -1,45 +0,0 @@
// Category: BC.01 — Node lifecycle: creation
// DB cross-ref: S2.N1, S2.N8
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
// Surface: S2.N1 = nodeCreated hook, S2.N8 = beforeRegisterNodeDef
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
// v1 contract: app.registerExtension({ nodeCreated(node) { ... } })
// Note: nodeCreated fires BEFORE the VueNode Vue component mounts; extensions needing
// VueNode-backed state must defer (see BC.37).
import { describe, it } from 'vitest'
describe('BC.01 v1 contract — node lifecycle: creation', () => {
describe('S2.N1 — nodeCreated hook', () => {
it.todo(
'nodeCreated is called once per node instance immediately after the node is constructed'
)
it.todo(
'nodeCreated receives the LGraphNode instance as its first argument'
)
it.todo(
'nodeCreated fires before the node is added to the graph (graph.nodes does not yet contain the node)'
)
it.todo(
'nodeCreated fires before the VueNode Vue component is mounted (vm.$el is null at call time)'
)
it.todo(
'properties set on node inside nodeCreated are accessible in subsequent lifecycle hooks'
)
})
describe('S2.N8 — beforeRegisterNodeDef hook', () => {
it.todo(
'beforeRegisterNodeDef is called once per node type before the type is registered in the node registry'
)
it.todo(
'beforeRegisterNodeDef receives the node constructor and the raw node definition object'
)
it.todo(
'prototype mutations made in beforeRegisterNodeDef affect all subsequently created instances of that type'
)
it.todo(
'beforeRegisterNodeDef is NOT called again on graph reload if the type is already registered'
)
})
})

View File

@@ -1,41 +0,0 @@
// Category: BC.01 — Node lifecycle: creation
// DB cross-ref: S2.N1, S2.N8
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: defineNodeExtension({ nodeCreated(handle) { ... } })
// Note: v2 nodeCreated receives a NodeHandle, not a raw LGraphNode. VueNode mount
// timing guarantee is unchanged — defer to onNodeMounted for Vue-backed state.
import { describe, it } from 'vitest'
describe('BC.01 v2 contract — node lifecycle: creation', () => {
describe('nodeCreated(handle) — per-instance setup', () => {
it.todo(
'nodeCreated is called once per node instance and receives a NodeHandle wrapping the created node'
)
it.todo(
'NodeHandle.id is stable and matches the underlying LGraphNode id at call time'
)
it.todo(
'NodeHandle.type returns the registered node type string'
)
it.todo(
'state stored via NodeHandle.setState() inside nodeCreated is retrievable in subsequent hooks for the same instance'
)
it.todo(
'nodeCreated fires before VueNode mounts; accessing NodeHandle.vueRef inside nodeCreated returns null'
)
})
describe('type-level registration (replacement for S2.N8)', () => {
it.todo(
'defineNodeExtension({ types: [\"MyNode\"] }) scopes nodeCreated to only instances of the listed types'
)
it.todo(
'omitting types: causes nodeCreated to fire for every node type (global registration)'
)
it.todo(
'type-scoped registration does not receive nodeCreated calls for unregistered node types'
)
})
})

View File

@@ -1,36 +0,0 @@
// Category: BC.02 — Node lifecycle: teardown
// DB cross-ref: S2.N4
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.onRemoved assignment → v2 defineNodeExtension({ onRemoved(handle) })
import { describe, it } from 'vitest'
describe('BC.02 migration — node lifecycle: teardown', () => {
describe('invocation parity (S2.N4)', () => {
it.todo(
'v1 onRemoved and v2 onRemoved are both called the same number of times for the same sequence of node removals'
)
it.todo(
'v2 onRemoved fires at the same point in the removal lifecycle as v1 (after node is detached from graph)'
)
})
describe('resource cleanup equivalence', () => {
it.todo(
'intervals cleared in v1 onRemoved are equally suppressible via NodeHandle.onDispose() in v2 without manual tracking'
)
it.todo(
'DOM elements removed manually in v1 onRemoved are automatically removed by v2 auto-disposal when registered via addDOMWidget()'
)
it.todo(
'observer.disconnect() patterns in v1 can be replaced by NodeHandle.onDispose(() => observer.disconnect()) in v2'
)
})
describe('graph clear coverage', () => {
it.todo(
'both v1 and v2 teardown hooks are invoked for all nodes when graph.clear() is called'
)
})
})

View File

@@ -1,135 +0,0 @@
// Category: BC.02 — Node lifecycle: teardown
// DB cross-ref: S2.N4
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
// Surface: S2.N4 = node.onRemoved
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.onRemoved = function() { /* cleanup DOM, intervals, observers */ }
//
// I-TF.3.C3 — proof-of-concept harness wiring.
// Phase A harness limitation: MiniGraph.remove() deletes the entity from the World
// but does NOT automatically call onRemoved (that requires Phase B eval sandbox +
// LiteGraph prototype wiring). The wired tests below call onRemoved explicitly after
// graph.remove() to prove the harness mechanics and assertion patterns work.
// The TODO stubs below them track what needs Phase B to become real assertions.
import { describe, expect, it, vi } from 'vitest'
import {
countEvidenceExcerpts,
createHarnessWorld,
createMiniComfyApp,
loadEvidenceSnippet
} from '../harness'
// ── Proof-of-concept wired tests (I-TF.3.C3) ────────────────────────────────
// These pass today. They prove: (a) the harness can model the v1 teardown
// pattern, (b) removal is reflected in the World, (c) the cleanup callback
// fires when the extension calls it, (d) evidence excerpts load for S2.N4.
describe('BC.02 v1 contract — node lifecycle: teardown [harness POC]', () => {
describe('S2.N4 — onRemoved harness mechanics', () => {
it('cleanup callback fires when extension calls it after graph.remove()', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
// v1 pattern: extension patches onRemoved on the node during nodeCreated.
// We model this as a plain function stored on a node-shaped object.
const cleanupFn = vi.fn()
const node = {
type: 'LTXVideo',
entityId: app.graph.add({ type: 'LTXVideo' }),
onRemoved: cleanupFn
}
expect(world.findNode(node.entityId)).toBeDefined()
// Simulate the LiteGraph removal sequence (Phase A: explicit call).
app.graph.remove(node.entityId)
node.onRemoved()
expect(world.findNode(node.entityId)).toBeUndefined()
expect(cleanupFn).toHaveBeenCalledOnce()
})
it('cleanup callback does not fire if remove is never called', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
const cleanupFn = vi.fn()
const entityId = app.graph.add({ type: 'KSampler' })
// Node exists; no removal; callback should not have been invoked.
void entityId
expect(cleanupFn).not.toHaveBeenCalled()
expect(world.allNodes()).toHaveLength(1)
})
it('multiple nodes — each removal triggers only its own callback', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
const cbA = vi.fn()
const cbB = vi.fn()
const idA = app.graph.add({ type: 'NodeA' })
const idB = app.graph.add({ type: 'NodeB' })
// Remove only A.
app.graph.remove(idA)
cbA() // simulate LiteGraph calling onRemoved on the removed node only
expect(cbA).toHaveBeenCalledOnce()
expect(cbB).not.toHaveBeenCalled()
expect(world.findNode(idA)).toBeUndefined()
expect(world.findNode(idB)).toBeDefined()
})
it('graph.clear() removes all nodes from the World', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
app.graph.add({ type: 'NodeA' })
app.graph.add({ type: 'NodeB' })
app.graph.add({ type: 'NodeC' })
expect(world.allNodes()).toHaveLength(3)
world.clear()
expect(world.allNodes()).toHaveLength(0)
})
})
describe('S2.N4 — evidence excerpt (loadEvidenceSnippet)', () => {
it('S2.N4 has at least one evidence excerpt in the snapshot', () => {
expect(countEvidenceExcerpts('S2.N4')).toBeGreaterThan(0)
})
it('S2.N4 excerpt contains onRemoved fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N4', 0)
expect(snippet.length).toBeGreaterThan(0)
expect(snippet).toMatch(/onRemoved/i)
})
})
})
// ── Phase B stubs — need eval sandbox + LiteGraph prototype wiring ───────────
describe('BC.02 v1 contract — node lifecycle: teardown [Phase B]', () => {
describe('S2.N4 — node.onRemoved', () => {
it.todo(
'onRemoved is called exactly once when a node is removed from the graph via graph.remove(node)'
)
it.todo(
'onRemoved is called when a node is deleted via the canvas context-menu delete action'
)
it.todo(
'onRemoved is called for every node when the graph is cleared (graph.clear())'
)
it.todo(
'DOM widgets appended by the extension are accessible for cleanup inside onRemoved (not yet garbage-collected)'
)
it.todo(
'setInterval / requestAnimationFrame handles stored on the node instance can be cancelled inside onRemoved'
)
it.todo(
'MutationObserver and ResizeObserver instances stored on the node can be disconnected inside onRemoved'
)
})
})

View File

@@ -1,38 +0,0 @@
// Category: BC.02 — Node lifecycle: teardown
// DB cross-ref: S2.N4
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: defineNodeExtension({ onRemoved(handle) { ... } })
// Note: v2 onRemoved runs inside the NodeHandle scope; extension-owned resources
// registered via handle APIs are auto-disposed before onRemoved fires.
import { describe, it } from 'vitest'
describe('BC.02 v2 contract — node lifecycle: teardown', () => {
describe('onRemoved(handle) — cleanup hook', () => {
it.todo(
'onRemoved is called exactly once per node instance when the node is removed from the graph'
)
it.todo(
'onRemoved receives the same NodeHandle that was passed to nodeCreated for the same instance'
)
it.todo(
'NodeHandle.getState() is still readable inside onRemoved (state not yet cleared)'
)
it.todo(
'onRemoved is called for every node when the graph is cleared, in no guaranteed order'
)
})
describe('auto-disposal of handle-registered resources', () => {
it.todo(
'DOM widgets registered via NodeHandle.addDOMWidget() are removed from the DOM before onRemoved fires'
)
it.todo(
'cleanup functions registered via NodeHandle.onDispose() are invoked before onRemoved fires'
)
it.todo(
'extension can still perform additional teardown in onRemoved after auto-disposal completes'
)
})
})

View File

@@ -1,36 +0,0 @@
// Category: BC.03 — Node lifecycle: hydration from saved workflows
// DB cross-ref: S1.H1, S2.N7
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.onConfigure / beforeRegisterNodeDef → v2 defineNodeExtension({ onConfigure(handle, data) })
import { describe, it } from 'vitest'
describe('BC.03 migration — node lifecycle: hydration from saved workflows', () => {
describe('onConfigure parity (S2.N7)', () => {
it.todo(
'v1 node.onConfigure and v2 onConfigure are both called exactly once per node during workflow load'
)
it.todo(
'the serialized data object received in v2 onConfigure contains the same fields as in v1'
)
it.todo(
'custom property restoration logic written for v1 onConfigure is portable to v2 with only handle substitution'
)
})
describe('beforeRegisterNodeDef hydration guard → type-scoped extension (S1.H1)', () => {
it.todo(
'prototype-level onConfigure injected via v1 beforeRegisterNodeDef produces the same hydration result as a v2 type-scoped onConfigure'
)
it.todo(
'v2 type-scoped onConfigure does not fire for node types not listed in types:, matching v1 guard behavior'
)
})
describe('fresh-creation exclusion invariant', () => {
it.todo(
'neither v1 nor v2 onConfigure fires when a node is created fresh (not from a saved workflow)'
)
})
})

View File

@@ -1,39 +0,0 @@
// Category: BC.03 — Node lifecycle: hydration from saved workflows
// DB cross-ref: S1.H1, S2.N7
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
// Surface: S1.H1 = beforeRegisterNodeDef (used for hydration guards), S2.N7 = node.onConfigure
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
// v1 contract: S1.H1 = beforeRegisterNodeDef guard; S2.N7 = node.onConfigure = function(data) { ... }
// Note: loadedGraphNode hook exists in LiteGraph but is effectively unused in ComfyUI —
// onConfigure is the de-facto hydration surface.
import { describe, it } from 'vitest'
describe('BC.03 v1 contract — node lifecycle: hydration from saved workflows', () => {
describe('S2.N7 — node.onConfigure', () => {
it.todo(
'onConfigure is called when a saved workflow is loaded and the node is rehydrated from serialized data'
)
it.todo(
'onConfigure receives the raw serialized node object (data) as its first argument'
)
it.todo(
'onConfigure is NOT called on freshly created nodes (only on deserialization)'
)
it.todo(
'widget values written to data inside a prior session are accessible via data.widgets_values in onConfigure'
)
it.todo(
'extensions can restore custom properties stored in data.properties inside onConfigure'
)
})
describe('S1.H1 — beforeRegisterNodeDef hydration guard', () => {
it.todo(
'beforeRegisterNodeDef can inject a custom onConfigure override on the node prototype before any instance is created'
)
it.todo(
'prototype-level onConfigure injected in beforeRegisterNodeDef is invoked for all instances during workflow load'
)
})
})

View File

@@ -1,36 +0,0 @@
// Category: BC.03 — Node lifecycle: hydration from saved workflows
// DB cross-ref: S1.H1, S2.N7
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: defineNodeExtension({ onConfigure(handle, data) { ... } })
import { describe, it } from 'vitest'
describe('BC.03 v2 contract — node lifecycle: hydration from saved workflows', () => {
describe('onConfigure(handle, data) — workflow hydration hook', () => {
it.todo(
'onConfigure is called when a node is rehydrated from a saved workflow and NOT on fresh node creation'
)
it.todo(
'onConfigure receives the NodeHandle as first argument and the raw serialized node object as second argument'
)
it.todo(
'data passed to onConfigure contains widgets_values from the saved workflow'
)
it.todo(
'data passed to onConfigure contains properties from the saved workflow'
)
it.todo(
'state written to NodeHandle inside onConfigure is readable in all subsequent hook calls for that instance'
)
})
describe('ordering and idempotency guarantees', () => {
it.todo(
'onConfigure fires after nodeCreated for the same instance during workflow load'
)
it.todo(
'onConfigure is not called a second time if the same node receives a re-configure (idempotent load)'
)
})
})

View File

@@ -1,45 +0,0 @@
// Category: BC.04 — Node interaction: pointer, selection, resize
// DB cross-ref: S2.N10, S2.N17, S2.N19
// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.onMouseDown/onSelected/onResize → v2 handle.on('mousedown'|'selected'|'resize', ...)
import { describe, it } from 'vitest'
describe('BC.04 migration — node interaction: pointer, selection, resize', () => {
describe('mousedown parity (S2.N10)', () => {
it.todo(
'v1 node.onMouseDown and v2 handle.on("mousedown") are both invoked for the same pointer-down events'
)
it.todo(
'propagation-stop by returning true in v1 is equivalent to event.stopPropagation() in v2 handler'
)
it.todo(
'local coordinates passed to v1 onMouseDown match the x/y in the v2 event object for the same input'
)
})
describe('selection parity (S2.N17)', () => {
it.todo(
'v1 node.onSelected and v2 handle.on("selected") are both invoked when the node is selected'
)
it.todo(
'v2 introduces an explicit deselected event absent in v1; migration must add deselected handler for cleanup that relied on onSelected re-fire'
)
})
describe('resize parity (S2.N19)', () => {
it.todo(
'v1 node.onResize([w,h]) and v2 handle.on("resize", { width, height }) convey the same dimensions for the same resize action'
)
it.todo(
'computeSize overrides that triggered onResize in v1 still trigger the resize event in v2'
)
})
describe('listener lifetime', () => {
it.todo(
'v1 listeners on removed nodes remain registered (leak); v2 handle.on() listeners are auto-removed on node removal'
)
})
})

View File

@@ -1,49 +0,0 @@
// Category: BC.04 — Node interaction: pointer, selection, resize
// DB cross-ref: S2.N10, S2.N17, S2.N19
// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
// Surface: S2.N10 = node.onMouseDown, S2.N17 = node.onSelected, S2.N19 = node.onResize
// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.onMouseDown, node.onSelected, node.onResize prototype method assignments
import { describe, it } from 'vitest'
describe('BC.04 v1 contract — node interaction: pointer, selection, resize', () => {
describe('S2.N10 — node.onMouseDown', () => {
it.todo(
'onMouseDown is called when a pointer-down event occurs within the node bounding box on the canvas'
)
it.todo(
'onMouseDown receives the MouseEvent and the local [x, y] position within the node as arguments'
)
it.todo(
'returning true from onMouseDown stops propagation to LiteGraph default mouse handling'
)
it.todo(
'onMouseDown is NOT called when the pointer down is outside the node bounding box'
)
})
describe('S2.N17 — node.onSelected', () => {
it.todo(
'onSelected is called when the node transitions to selected state (single-click or box-select)'
)
it.todo(
'onSelected is called once per selection event even if the node was already selected'
)
it.todo(
'onSelected is not called when a different node is selected and this node is deselected'
)
})
describe('S2.N19 — node.onResize', () => {
it.todo(
'onResize is called after the node dimensions change (user drag-resize or programmatic setSize)'
)
it.todo(
'onResize receives the new [width, height] array as its argument'
)
it.todo(
'onResize is called after the node size is committed, not during the drag'
)
})
})

View File

@@ -1,48 +0,0 @@
// Category: BC.04 — Node interaction: pointer, selection, resize
// DB cross-ref: S2.N10, S2.N17, S2.N19
// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: defineNodeExtension({ on('mousedown', ...), on('selected', ...), on('resize', ...) })
import { describe, it } from 'vitest'
describe('BC.04 v2 contract — node interaction: pointer, selection, resize', () => {
describe('on(\"mousedown\", handler) — pointer events (S2.N10)', () => {
it.todo(
'handle.on("mousedown", handler) registers a listener called when pointer-down occurs within the node bounding box'
)
it.todo(
'handler receives an event object with local x/y coordinates relative to the node origin'
)
it.todo(
'handler returning true stops propagation to LiteGraph default mouse handling'
)
it.todo(
'listener registered via handle.on() is automatically removed when the node is removed from the graph'
)
})
describe('on(\"selected\", handler) — selection focus (S2.N17)', () => {
it.todo(
'handle.on("selected", handler) is called when the node enters selected state'
)
it.todo(
'handle.on("deselected", handler) is called when the node exits selected state'
)
it.todo(
'selected and deselected events do not fire during programmatic selection with { silent: true } option'
)
})
describe('on(\"resize\", handler) — resize feedback (S2.N19)', () => {
it.todo(
'handle.on("resize", handler) is called after the node dimensions change'
)
it.todo(
'handler receives a { width, height } object matching the new node size'
)
it.todo(
'resize event fires for both user drag-resize and programmatic NodeHandle.setSize() calls'
)
})
})

View File

@@ -1,39 +0,0 @@
// Category: BC.05 — Custom DOM widgets and node sizing
// DB cross-ref: S4.W2, S2.N11
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.addDOMWidget + node.computeSize → v2 NodeHandle.addDOMWidget + WidgetHandle.setHeight
import { describe, it } from 'vitest'
describe('BC.05 migration — custom DOM widgets and node sizing', () => {
describe('widget registration parity (S4.W2)', () => {
it.todo(
'v1 node.addDOMWidget and v2 NodeHandle.addDOMWidget both result in the element being visible inside the node widget area'
)
it.todo(
'the widget is accessible by name in both v1 node.widgets and v2 NodeHandle.widgets after registration'
)
it.todo(
'v1 opts.getHeight() returning N produces the same reserved height as v2 addDOMWidget({ height: N })'
)
})
describe('computeSize elimination (S2.N11)', () => {
it.todo(
'v1 manual computeSize override is unnecessary in v2; equivalent height reservation is achieved via WidgetHandle.setHeight()'
)
it.todo(
'node rendered with v2 auto-computeSize integration has the same final dimensions as v1 with an equivalent manual computeSize override'
)
})
describe('cleanup parity', () => {
it.todo(
'v1 requires manual DOM removal in onRemoved; v2 auto-removes the widget element — both result in the element being absent after node removal'
)
it.todo(
'v2 auto-cleanup does not remove DOM elements that were not registered via addDOMWidget, matching v1 scoping'
)
})
})

View File

@@ -1,43 +0,0 @@
// Category: BC.05 — Custom DOM widgets and node sizing
// DB cross-ref: S4.W2, S2.N11
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
// Surface: S4.W2 = node.addDOMWidget, S2.N11 = node.computeSize override
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.addDOMWidget(name, type, element, opts) + node.computeSize = function(out) { ... }
import { describe, it } from 'vitest'
describe('BC.05 v1 contract — custom DOM widgets and node sizing', () => {
describe('S4.W2 — node.addDOMWidget', () => {
it.todo(
'addDOMWidget(name, type, element, opts) appends the provided DOM element inside the node widget area'
)
it.todo(
'widget registered via addDOMWidget is accessible via node.widgets array by the given name'
)
it.todo(
'addDOMWidget opts.getHeight() is called during layout to determine the widget reserved height'
)
it.todo(
'addDOMWidget opts.onDraw(ctx) callback is invoked during each canvas render pass'
)
it.todo(
'the DOM element is removed from the document when the node is removed via graph.remove()'
)
})
describe('S2.N11 — node.computeSize override', () => {
it.todo(
'assigning node.computeSize = function(out) { ... } overrides the default size calculation for the node'
)
it.todo(
'overridden computeSize is called by LiteGraph layout engine before rendering'
)
it.todo(
'computeSize can return a [width, height] pair that accounts for the DOM widget reserved height'
)
it.todo(
'computeSize override persists across graph load/reload if set in nodeCreated or beforeRegisterNodeDef'
)
})
})

View File

@@ -1,39 +0,0 @@
// Category: BC.05 — Custom DOM widgets and node sizing
// DB cross-ref: S4.W2, S2.N11
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.addDOMWidget(opts) — auto-hooks computeSize via WidgetHandle geometry
import { describe, it } from 'vitest'
describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
describe('NodeHandle.addDOMWidget(opts) — widget registration', () => {
it.todo(
'NodeHandle.addDOMWidget({ name, element }) appends the element inside the node widget area'
)
it.todo(
'addDOMWidget returns a WidgetHandle that exposes the registered widget for further configuration'
)
it.todo(
'widget registered via addDOMWidget is included in NodeHandle.widgets list under opts.name'
)
it.todo(
'addDOMWidget({ name, element, height }) reserves the specified height without requiring a manual computeSize override'
)
it.todo(
'the DOM element is removed from the document automatically when the node is removed (no manual cleanup)'
)
})
describe('WidgetHandle geometry — auto-computeSize integration (S2.N11)', () => {
it.todo(
'WidgetHandle.setHeight(px) updates the reserved height and triggers a node relayout without a manual computeSize call'
)
it.todo(
'when multiple DOM widgets are registered, the total node height accounts for all widget heights'
)
it.todo(
'calling WidgetHandle.setHeight() after initial mount correctly re-lays out the node on next render frame'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.06 — Custom canvas drawing (per-node and canvas-level)
// DB cross-ref: S2.N9, S3.C1, S3.C2
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.onDrawForeground → v2 NodeHandle.onDraw (partial).
// S3.C1 / S3.C2 canvas-level overrides: no v2 migration path yet (D9 Phase C).
import { describe, it } from 'vitest'
describe('BC.06 migration — custom canvas drawing (per-node and canvas-level)', () => {
describe('per-node drawing migration (S2.N9)', () => {
it.todo(
'v1 node.onDrawForeground and v2 NodeHandle.onDraw both produce visually equivalent output on the canvas for the same drawing operations'
)
it.todo(
'draw callback in v2 fires the same number of times per second as v1 onDrawForeground for a static scene'
)
it.todo(
'v2 DrawContext.ctx is the same CanvasRenderingContext2D state as v1 receives (same transform, same clip)'
)
})
describe('auto-deregistration vs manual cleanup', () => {
it.todo(
'v1 onDrawForeground continues to fire after node removal if the reference is not cleared (leak); v2 onDraw is auto-removed'
)
it.todo(
'v2 auto-deregistration on node removal does not affect onDraw callbacks registered for other nodes'
)
})
describe('canvas-level override coexistence (S3.C1, S3.C2)', () => {
it.todo(
'extensions that replace LGraphCanvas.prototype methods in v1 continue to function alongside v2 NodeHandle.onDraw registrations without conflict'
)
it.todo(
'processContextMenu replacement in v1 is not disrupted by extensions migrated to v2 per-node APIs'
)
})
})

View File

@@ -1,55 +0,0 @@
// Category: BC.06 — Custom canvas drawing (per-node and canvas-level)
// DB cross-ref: S2.N9, S3.C1, S3.C2
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
// Surface: S2.N9 = node.onDrawForeground, S3.C1 = LGraphCanvas.prototype overrides, S3.C2 = ContextMenu replacement
// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.onDrawForeground(ctx, area), LGraphCanvas.prototype.processContextMenu = ...,
// LGraphCanvas.prototype.drawNodeShape = ... etc.
// v1_scope_note: Simon Tranter (COM-3668) vetoed canvas drawing overrides as "too hacky/specific".
// S3.C* patterns tracked for blast-radius / strangler-fig planning only.
import { describe, it } from 'vitest'
describe('BC.06 v1 contract — custom canvas drawing (per-node and canvas-level)', () => {
describe('S2.N9 — node.onDrawForeground', () => {
it.todo(
'onDrawForeground(ctx, visibleArea) is called once per render frame for each visible node'
)
it.todo(
'ctx passed to onDrawForeground is the same CanvasRenderingContext2D used by LiteGraph for the node layer'
)
it.todo(
'drawing operations performed in onDrawForeground appear above the node body and below the selection highlight'
)
it.todo(
'onDrawForeground is NOT called for nodes outside the visible area (culled by LiteGraph)'
)
it.todo(
'canvas transform (scale, translate) is already applied when onDrawForeground fires — coordinates are in graph space'
)
})
describe('S3.C1 — LGraphCanvas.prototype method overrides', () => {
it.todo(
'assigning LGraphCanvas.prototype.drawNodeShape replaces the built-in node shape renderer for all nodes'
)
it.todo(
'prototype override affects all canvas instances sharing the same prototype (global side-effect)'
)
it.todo(
'two extensions both overriding the same LGraphCanvas.prototype method result in last-writer-wins behavior'
)
})
describe('S3.C2 — ContextMenu global replacement', () => {
it.todo(
'reassigning LGraphCanvas.prototype.processContextMenu replaces the context-menu handler for every right-click on the canvas'
)
it.todo(
'extensions replacing processContextMenu must call the original to preserve built-in menu items'
)
it.todo(
'replacing processContextMenu is the most destructive canvas-level override — absence of original call silently drops all built-in menu entries'
)
})
})

View File

@@ -1,41 +0,0 @@
// Category: BC.06 — Custom canvas drawing (per-node and canvas-level)
// DB cross-ref: S2.N9, S3.C1, S3.C2
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.onDraw(callback) for per-node drawing (S2.N9).
// Canvas-level overrides (S3.C1, S3.C2) are OUT OF v2 SCOPE — deferred to D9 Phase C.
// S3.C* stubs present for blast-radius tracking and strangler-fig planning.
import { describe, it } from 'vitest'
describe('BC.06 v2 contract — custom canvas drawing (per-node and canvas-level)', () => {
describe('NodeHandle.onDraw(callback) — per-node foreground drawing (S2.N9)', () => {
it.todo(
'NodeHandle.onDraw(cb) registers cb to be called once per render frame while the node is visible'
)
it.todo(
'callback receives a DrawContext with ctx (CanvasRenderingContext2D) and area (bounding rect) arguments'
)
it.todo(
'drawing operations in the callback appear in the same layer as v1 onDrawForeground (above node body)'
)
it.todo(
'the canvas transform is pre-applied when the callback fires — coordinates are in graph space, matching v1 behavior'
)
it.todo(
'callback registered via NodeHandle.onDraw() is automatically deregistered when the node is removed'
)
})
describe('canvas-level overrides — deferred (S3.C1, S3.C2)', () => {
it.todo(
'[D9 Phase C] v2 exposes no stable API for replacing LGraphCanvas.prototype.drawNodeShape — extensions using this pattern must remain on v1 shim'
)
it.todo(
'[D9 Phase C] v2 exposes no stable API for replacing processContextMenu — context-menu customization is deferred to the ComfyUI menu extension point'
)
it.todo(
'[D9 Phase C] blast-radius tracking: S3.C1 and S3.C2 overrides coexist with v2 per-node drawing without mutual interference'
)
})
})

View File

@@ -1,44 +0,0 @@
// Category: BC.07 — Connection observation, intercept, and veto
// DB cross-ref: S2.N3, S2.N12, S2.N13
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
// Migration: v1 prototype method assignment → v2 NodeHandle.on('connectInput'/'connectOutput'/'connectionChange')
import { describe, it } from 'vitest'
describe('BC.07 migration — connection observation, intercept, and veto', () => {
describe('onConnectionsChange → on(\'connectionChange\') (S2.N3)', () => {
it.todo(
'v1 onConnectionsChange and v2 on(\'connectionChange\') both fire for the same link connect event with equivalent payload data'
)
it.todo(
'v2 connectionChange event fires at the same point in the link-wiring sequence as v1 onConnectionsChange'
)
})
describe('onConnectInput → on(\'connectInput\') (S2.N12)', () => {
it.todo(
'v1 onConnectInput returning false and v2 on(\'connectInput\') returning false both result in an unwired graph with no link object created'
)
it.todo(
'type coercion performed inside v1 onConnectInput produces the same wired slot type as equivalent mutation inside v2 on(\'connectInput\')'
)
})
describe('onConnectOutput → on(\'connectOutput\') (S2.N13)', () => {
it.todo(
'v1 onConnectOutput veto and v2 on(\'connectOutput\') veto both prevent connectionChange from firing on either endpoint node'
)
it.todo(
'v2 on(\'connectOutput\') listener receives equivalent data to v1 onConnectOutput arguments for the same connection attempt'
)
})
describe('scope and cleanup', () => {
it.todo(
'v1 prototype method persists after extension unregisters (no cleanup); v2 on() listeners are removed on scope dispose'
)
it.todo(
'v2 cleanup does not affect connection listeners registered by other extensions on the same node'
)
})
})

View File

@@ -1,53 +0,0 @@
// Category: BC.07 — Connection observation, intercept, and veto
// DB cross-ref: S2.N3, S2.N12, S2.N13
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
// blast_radius: 5.46 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.onConnectInput(slot, type, link, node, fromSlot)
// node.onConnectOutput(slot, type, link, node, toSlot)
// node.onConnectionsChange(type, slot, connected, link, ioSlot)
import { describe, it } from 'vitest'
describe('BC.07 v1 contract — connection observation, intercept, and veto', () => {
describe('S2.N3 — onConnectionsChange: passive observation', () => {
it.todo(
'onConnectionsChange is called on the node when any input or output link is connected or disconnected'
)
it.todo(
'onConnectionsChange receives type (INPUT=1/OUTPUT=2), slot index, connected boolean, link info, and ioSlot'
)
it.todo(
'onConnectionsChange fires after the link is already wired into the graph (link is present at call time)'
)
it.todo(
'onConnectionsChange fires for both the source node and the target node on a single link operation'
)
})
describe('S2.N12 — onConnectInput: intercept and veto incoming connections', () => {
it.todo(
'onConnectInput returning false vetoes the connection before it is wired'
)
it.todo(
'onConnectInput returning true (or undefined) allows the connection to proceed'
)
it.todo(
'onConnectInput receives slot index, incoming type, link object, source node, and source slot'
)
it.todo(
'onConnectInput can mutate the slot type to coerce an incompatible type before wiring'
)
})
describe('S2.N13 — onConnectOutput: intercept and veto outgoing connections', () => {
it.todo(
'onConnectOutput returning false vetoes the outgoing connection before it is wired'
)
it.todo(
'onConnectOutput receives slot index, outgoing type, link object, target node, and target slot'
)
it.todo(
'onConnectOutput veto does not trigger onConnectionsChange on either node'
)
})
})

View File

@@ -1,51 +0,0 @@
// Category: BC.07 — Connection observation, intercept, and veto
// DB cross-ref: S2.N3, S2.N12, S2.N13
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
// blast_radius: 5.46 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.on('connectInput', ...), on('connectOutput', ...), on('connectionChange', ...)
import { describe, it } from 'vitest'
describe('BC.07 v2 contract — connection observation, intercept, and veto', () => {
describe('on(\'connectionChange\', fn) — passive observation', () => {
it.todo(
'NodeHandle.on(\'connectionChange\', fn) fires fn after any input or output link is connected or disconnected'
)
it.todo(
'connectionChange event payload includes type (\'input\'|\'output\'), slotIndex, connected boolean, and link info'
)
it.todo(
'multiple listeners registered via on(\'connectionChange\') are all invoked in registration order'
)
it.todo(
'listener registered with on() is removed when the extension scope is disposed'
)
})
describe('on(\'connectInput\', fn) — intercept and veto incoming connections', () => {
it.todo(
'fn returning false from on(\'connectInput\') vetoes the connection; graph remains unwired'
)
it.todo(
'fn returning true or undefined from on(\'connectInput\') allows the connection to proceed'
)
it.todo(
'connectInput event payload includes slotIndex, type, link, sourceHandle, and sourceSlot'
)
it.todo(
'fn can mutate event.type to coerce a type mismatch before the connection is wired'
)
})
describe('on(\'connectOutput\', fn) — intercept and veto outgoing connections', () => {
it.todo(
'fn returning false from on(\'connectOutput\') vetoes the outgoing connection; connectionChange does not fire'
)
it.todo(
'connectOutput event payload includes slotIndex, type, link, targetHandle, and targetSlot'
)
it.todo(
'veto from connectOutput does not affect other registered connectOutput listeners on the same node'
)
})
})

View File

@@ -1,38 +0,0 @@
// Category: BC.08 — Programmatic linking
// DB cross-ref: S10.D2
// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
// Migration: v1 node.connect/disconnectInput → v2 NodeHandle.connect/disconnectInput (typed handles)
import { describe, it } from 'vitest'
describe('BC.08 migration — programmatic linking', () => {
describe('connect() equivalence', () => {
it.todo(
'v1 node.connect(srcSlot, targetNode, dstSlot) and v2 NodeHandle.connect(srcSlot, targetHandle, dstSlot) produce identical graph link state'
)
it.todo(
'link id returned by v2 connect() matches the id on the underlying LGraph link created by an equivalent v1 call'
)
it.todo(
'v2 connect() with a type-incompatible pair raises a typed error; v1 returns null — callers must handle both forms during migration'
)
})
describe('disconnectInput() equivalence', () => {
it.todo(
'v1 node.disconnectInput(slot) and v2 NodeHandle.disconnectInput(slotIndex) both leave the graph with no link on that slot'
)
it.todo(
'onConnectionsChange (v1) and on(\'connectionChange\') (v2) both fire for the same disconnect operation with equivalent payload data'
)
})
describe('handle vs. raw node reference', () => {
it.todo(
'v2 NodeHandle.connect() accepts a NodeHandle for targetHandle; passing a raw LGraphNode instance throws a deprecation error'
)
it.todo(
'NodeHandle obtained from v2 nodeCreated correctly wraps the same node that v1 connect() would operate on'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.08 — Programmatic linking
// DB cross-ref: S10.D2
// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
// blast_radius: 5.99 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.connect(srcSlot, targetNode, dstSlot)
// node.disconnectInput(slot)
import { describe, it } from 'vitest'
describe('BC.08 v1 contract — programmatic linking', () => {
describe('S10.D2 — node.connect(srcSlot, targetNode, dstSlot)', () => {
it.todo(
'node.connect(srcSlot, targetNode, dstSlot) creates a link between the source output slot and the target input slot'
)
it.todo(
'connect() returns the newly created link object with a stable numeric id'
)
it.todo(
'connect() on an already-occupied input slot replaces the existing link without leaving a dangling reference'
)
it.todo(
'connect() with a type-incompatible slot pair is rejected and returns null without modifying the graph'
)
it.todo(
'onConnectionsChange fires on both the source and target node after a successful connect() call'
)
})
describe('S10.D2 — node.disconnectInput(slot)', () => {
it.todo(
'node.disconnectInput(slot) removes the link on the specified input slot and updates both endpoint nodes'
)
it.todo(
'disconnectInput() on an empty slot is a no-op and does not throw'
)
it.todo(
'onConnectionsChange fires on both the source and target node after disconnectInput() removes a link'
)
})
})

View File

@@ -1,39 +0,0 @@
// Category: BC.08 — Programmatic linking
// DB cross-ref: S10.D2
// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
// blast_radius: 5.99 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.connect(slotIndex, targetHandle, dstSlot) — same semantics, typed handles
import { describe, it } from 'vitest'
describe('BC.08 v2 contract — programmatic linking', () => {
describe('NodeHandle.connect(slotIndex, targetHandle, dstSlot) — create links', () => {
it.todo(
'NodeHandle.connect(slotIndex, targetHandle, dstSlot) creates a link between the source output slot and the target input slot'
)
it.todo(
'connect() returns a LinkHandle with a stable id that matches the underlying graph link id'
)
it.todo(
'connect() on an already-occupied input slot replaces the existing link and the old LinkHandle becomes invalid'
)
it.todo(
'connect() with a type-incompatible slot pair throws a typed error and leaves the graph unchanged'
)
it.todo(
'on(\'connectionChange\') fires on both NodeHandles after a successful connect() call'
)
})
describe('NodeHandle.disconnectInput(slotIndex) — remove links', () => {
it.todo(
'NodeHandle.disconnectInput(slotIndex) removes the link on the specified input slot and the returned LinkHandle becomes invalid'
)
it.todo(
'disconnectInput() on an empty slot is a no-op and does not throw'
)
it.todo(
'on(\'connectionChange\') fires on both source and target NodeHandles after disconnectInput() removes a link'
)
})
})

View File

@@ -1,42 +0,0 @@
// Category: BC.09 — Dynamic slot and output mutation
// DB cross-ref: S10.D1, S10.D3, S15.OS1
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121
// Migration: v1 positional addInput/removeInput/addOutput/removeOutput + manual setSize
// → v2 name-based NodeHandle.addInput/removeInput/addOutput/removeOutput with auto-reflow
import { describe, it } from 'vitest'
describe('BC.09 migration — dynamic slot and output mutation', () => {
describe('addInput / addOutput equivalence (S10.D1, S10.D3)', () => {
it.todo(
'v1 node.addInput(name, type) and v2 NodeHandle.addInput({ name, type }) both result in an equivalent slot appended to the node'
)
it.todo(
'v1 node.addOutput(name, type) and v2 NodeHandle.addOutput({ name, type }) both result in an equivalent output slot with a matching type'
)
it.todo(
'slot added via v2 addInput() is accessible at the same index position as an equivalent v1 addInput() call (append-only ordering preserved)'
)
})
describe('removeInput / removeOutput equivalence', () => {
it.todo(
'v1 node.removeInput(slotIndex) and v2 NodeHandle.removeInput(name) both remove the slot and detach active links; remaining slots have consistent indices'
)
it.todo(
'v2 removeInput(name) correctly identifies the slot when multiple slots exist, matching by name not by position'
)
})
describe('reflow: manual setSize vs. automatic (S15.OS1)', () => {
it.todo(
'v1 addInput() + setSize([...computeSize()]) and v2 addInput() auto-reflow both produce a node with equal or greater height to display the new slot'
)
it.todo(
'v2 auto-reflow after removeOutput() shrinks the node to the same height as a v1 removeOutput() + manual setSize() sequence'
)
it.todo(
'omitting setSize after a v1 addInput() call causes slot overlap; v2 auto-reflow never produces this condition'
)
})
})

View File

@@ -1,50 +0,0 @@
// Category: BC.09 — Dynamic slot and output mutation
// DB cross-ref: S10.D1, S10.D3, S15.OS1
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121
// blast_radius: 6.03 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.addInput(name, type), node.removeInput(slot)
// node.addOutput(name, type), node.removeOutput(slot)
// node.setSize([w, h])
import { describe, it } from 'vitest'
describe('BC.09 v1 contract — dynamic slot and output mutation', () => {
describe('S10.D1 — addInput / removeInput', () => {
it.todo(
'node.addInput(name, type) appends a new input slot to node.inputs and increments node.inputs.length'
)
it.todo(
'node.removeInput(slot) removes the slot at the given index and shifts subsequent slots down by one'
)
it.todo(
'removing an input slot that has an active link also removes the corresponding link from the graph'
)
it.todo(
'addInput with a duplicate name appends a second slot without error (v1 allows duplicates)'
)
})
describe('S10.D3 — addOutput / removeOutput', () => {
it.todo(
'node.addOutput(name, type) appends a new output slot to node.outputs and increments node.outputs.length'
)
it.todo(
'node.removeOutput(slot) removes the output slot and detaches all outgoing links on that slot'
)
it.todo(
'removing an output slot does not affect links on other output slots of the same node'
)
})
describe('S15.OS1 — computeSize / setSize reflow', () => {
it.todo(
'node.setSize([w, h]) updates node.size to the provided dimensions immediately'
)
it.todo(
'addInput/addOutput followed by node.setSize([...node.computeSize()]) produces a node tall enough to display all slots without overlap'
)
it.todo(
'setSize does not trigger a canvas redraw synchronously; redraw occurs on the next animation frame'
)
})
})

View File

@@ -1,50 +0,0 @@
// Category: BC.09 — Dynamic slot and output mutation
// DB cross-ref: S10.D1, S10.D3, S15.OS1
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121
// blast_radius: 6.03 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.addInput(opts), NodeHandle.removeInput(name)
// NodeHandle.addOutput(opts), NodeHandle.removeOutput(name)
// reflow handled automatically — no manual setSize required
import { describe, it } from 'vitest'
describe('BC.09 v2 contract — dynamic slot and output mutation', () => {
describe('NodeHandle.addInput / removeInput (S10.D1)', () => {
it.todo(
'NodeHandle.addInput({ name, type }) appends a new input slot and returns a SlotHandle with a stable name-based identity'
)
it.todo(
'NodeHandle.removeInput(name) removes the named input slot and detaches any active link on that slot'
)
it.todo(
'removeInput(name) on a non-existent slot name throws a typed SlotNotFoundError'
)
it.todo(
'addInput with a duplicate name throws a DuplicateSlotError (v2 enforces uniqueness unlike v1)'
)
})
describe('NodeHandle.addOutput / removeOutput (S10.D3)', () => {
it.todo(
'NodeHandle.addOutput({ name, type }) appends a new output slot and returns a SlotHandle'
)
it.todo(
'NodeHandle.removeOutput(name) removes the output slot and detaches all outgoing links on that slot'
)
it.todo(
'removeOutput does not affect slots or links on other output slots of the same node'
)
})
describe('automatic reflow (replaces S15.OS1 manual setSize)', () => {
it.todo(
'after addInput() or addOutput() the node size is automatically reflowed to fit all slots without a manual setSize call'
)
it.todo(
'after removeInput() or removeOutput() the node size is automatically shrunk to remove the vacated slot space'
)
it.todo(
'automatic reflow does not trigger a synchronous canvas redraw; redraw occurs on the next animation frame'
)
})
})

View File

@@ -1,39 +0,0 @@
// Category: BC.10 — Widget value subscription
// DB cross-ref: S4.W1, S2.N14
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317
// Migration: v1 widget.callback chain-patching / node.onWidgetChanged
// → v2 WidgetHandle.on('change') / NodeHandle.on('widgetChanged')
import { describe, it } from 'vitest'
describe('BC.10 migration — widget value subscription', () => {
describe('widget.callback → WidgetHandle.on(\'change\') (S4.W1)', () => {
it.todo(
'v1 widget.callback and v2 WidgetHandle.on(\'change\') both fire with the new value for the same user interaction'
)
it.todo(
'v2 on(\'change\') fires at the same point in the event sequence as the last v1 callback in the chain'
)
it.todo(
'v1 chain-patching does not compose with v2 on(\'change\'): each operates independently; both fire for the same change event'
)
})
describe('node.onWidgetChanged → NodeHandle.on(\'widgetChanged\') (S2.N14)', () => {
it.todo(
'v1 node.onWidgetChanged and v2 NodeHandle.on(\'widgetChanged\') both receive equivalent widget name, value, and oldValue for the same change'
)
it.todo(
'v2 widgetChanged payload includes a WidgetHandle reference instead of a raw widget object; WidgetHandle.name matches the widget name'
)
})
describe('ordering and isolation', () => {
it.todo(
'v2 on(\'change\') listeners from different extensions on the same widget all fire without one suppressing another'
)
it.todo(
'disposing one extension scope removes only its own on(\'change\') listeners; other extensions\' listeners continue to fire'
)
})
})

View File

@@ -1,37 +0,0 @@
// Category: BC.10 — Widget value subscription
// DB cross-ref: S4.W1, S2.N14
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317
// blast_radius: 5.09 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: widget.callback = function(value, ...) { ... } (chain-patching)
// node.onWidgetChanged = function(name, value, ...) { ... }
import { describe, it } from 'vitest'
describe('BC.10 v1 contract — widget value subscription', () => {
describe('S4.W1 — widget.callback chain-patching', () => {
it.todo(
'assigning widget.callback invokes the function with the new value whenever the widget is interacted with'
)
it.todo(
'chain-patching preserves the previous callback: saving the old reference and calling it at the end of the new function'
)
it.todo(
'widget.callback receives (value, app, node, pos, event) in that argument order'
)
it.todo(
'if multiple extensions chain-patch widget.callback, all callbacks are invoked in stack order (last-patched first)'
)
})
describe('S2.N14 — node.onWidgetChanged', () => {
it.todo(
'node.onWidgetChanged is called once per widget value change with the widget name, new value, old value, and widget reference'
)
it.todo(
'onWidgetChanged fires for every widget on the node, not only those with an explicit callback'
)
it.todo(
'onWidgetChanged fires after widget.callback has been invoked for the same change event'
)
})
})

View File

@@ -1,36 +0,0 @@
// Category: BC.10 — Widget value subscription
// DB cross-ref: S4.W1, S2.N14
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317
// blast_radius: 5.09 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: WidgetHandle.on('change', fn), NodeHandle.on('widgetChanged', fn)
import { describe, it } from 'vitest'
describe('BC.10 v2 contract — widget value subscription', () => {
describe('WidgetHandle.on(\'change\', fn) — per-widget subscription (S4.W1)', () => {
it.todo(
'WidgetHandle.on(\'change\', fn) fires fn with (newValue, oldValue) whenever the widget value changes'
)
it.todo(
'multiple on(\'change\') listeners on the same WidgetHandle are all invoked in registration order'
)
it.todo(
'on(\'change\') listener is removed when the extension scope is disposed; subsequent changes do not invoke the stale listener'
)
it.todo(
'on(\'change\') listener can call event.preventDefault() to block the value write (unlike v1 callback which cannot veto)'
)
})
describe('NodeHandle.on(\'widgetChanged\', fn) — node-level subscription (S2.N14)', () => {
it.todo(
'NodeHandle.on(\'widgetChanged\', fn) fires fn for any widget value change on the node, with payload { name, value, oldValue, widget }'
)
it.todo(
'widgetChanged fires after all per-widget on(\'change\') listeners have been invoked for the same change event'
)
it.todo(
'widgetChanged fires for every widget on the node regardless of whether the widget has individual on(\'change\') listeners'
)
})
})

View File

@@ -1,45 +0,0 @@
// Category: BC.11 — Widget imperative state writes
// DB cross-ref: S4.W4, S4.W5, S2.N16
// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
// Migration: v1 direct property mutation (widget.value, widget.options.values, node.widgets.push/splice)
// → v2 WidgetHandle.setValue / setOptions / NodeHandle.addWidget / removeWidget
import { describe, it } from 'vitest'
describe('BC.11 migration — widget imperative state writes', () => {
describe('widget.value → WidgetHandle.setValue() (S4.W4)', () => {
it.todo(
'v1 widget.value = v and v2 WidgetHandle.setValue(v) both result in the same displayed value on the canvas'
)
it.todo(
'v1 direct assignment does not fire on(\'change\') listeners; v2 setValue() does — callers must not assume silence'
)
it.todo(
'v2 setValue() raises InvalidValueError for out-of-range COMBO values; v1 assignment silently accepts them'
)
})
describe('widget.options.values → WidgetHandle.setOptions() (S4.W5)', () => {
it.todo(
'v1 widget.options.values = [...] and v2 WidgetHandle.setOptions({ values: [...] }) both replace the COMBO option list'
)
it.todo(
'v1 does not auto-reset stale current value; v2 setOptions() does — migration callers must handle the resulting on(\'change\') event'
)
})
describe('node.widgets.push/splice → NodeHandle.addWidget/removeWidget (S2.N16)', () => {
it.todo(
'v1 node.widgets.push(w) and v2 NodeHandle.addWidget(opts) both result in the widget being present in the node\'s widget list after the call'
)
it.todo(
'v1 splice causes widgets_values positional drift; v2 addWidget uses named-map and produces no drift even when inserted mid-list'
)
it.todo(
'v1 push requires a manual setSize reflow; v2 addWidget performs it automatically — do not double-reflow when migrating'
)
it.todo(
'v2 removeWidget(name) correctly finds the widget by name regardless of its position in the list; v1 splice requires the caller to track the index'
)
})
})

View File

@@ -1,51 +0,0 @@
// Category: BC.11 — Widget imperative state writes
// DB cross-ref: S4.W4, S4.W5, S2.N16
// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
// blast_radius: 5.81 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: widget.value = newVal
// widget.options.values = [...]
// node.widgets.splice(i, 0, w)
// node.widgets.push(w)
import { describe, it } from 'vitest'
describe('BC.11 v1 contract — widget imperative state writes', () => {
describe('S4.W4 — widget.value direct assignment', () => {
it.todo(
'assigning widget.value = newVal updates the displayed value on the next canvas redraw without triggering widget.callback'
)
it.todo(
'widget.value assignment to a value outside the COMBO options list does not throw but may display an invalid state'
)
it.todo(
'reading widget.value immediately after assignment returns the assigned value'
)
})
describe('S4.W5 — widget.options.values mutation (COMBO options)', () => {
it.todo(
'assigning widget.options.values = [...] replaces the COMBO dropdown options on the next canvas redraw'
)
it.todo(
'if the current widget.value is absent from the new options list, the widget continues to display the stale value (no auto-reset in v1)'
)
it.todo(
'widget.options.values mutation does not fire widget.callback'
)
})
describe('S2.N16 — node.widgets array mutation (insert / push)', () => {
it.todo(
'node.widgets.push(widget) appends the widget to the node\'s widget list and it renders on the next canvas redraw'
)
it.todo(
'node.widgets.splice(i, 0, widget) inserts a widget at position i and shifts subsequent widgets\' positional indices'
)
it.todo(
'inserting a widget via splice causes widgets_values positional drift if not followed by a node size reflow'
)
it.todo(
'node.widgets.push does not update node.size; calling setSize([...computeSize()]) is required to avoid slot overlap'
)
})
})

View File

@@ -1,52 +0,0 @@
// Category: BC.11 — Widget imperative state writes
// DB cross-ref: S4.W4, S4.W5, S2.N16
// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
// blast_radius: 5.81 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: WidgetHandle.setValue(v), WidgetHandle.setOptions({ values: [...] })
// NodeHandle.addWidget(opts), NodeHandle.removeWidget(name)
import { describe, it } from 'vitest'
describe('BC.11 v2 contract — widget imperative state writes', () => {
describe('WidgetHandle.setValue(v) — controlled value write (S4.W4)', () => {
it.todo(
'WidgetHandle.setValue(v) updates the widget\'s current value and triggers a reactive update visible on the next canvas frame'
)
it.todo(
'setValue() fires the on(\'change\') listeners with (newValue, oldValue) in the same tick'
)
it.todo(
'setValue() with a value outside the COMBO options list throws a typed InvalidValueError'
)
it.todo(
'reading WidgetHandle.value immediately after setValue() returns the new value'
)
})
describe('WidgetHandle.setOptions({ values }) — COMBO option replacement (S4.W5)', () => {
it.todo(
'WidgetHandle.setOptions({ values: [...] }) replaces the COMBO options and triggers a reactive update'
)
it.todo(
'if the current value is absent from the new options list, setOptions() resets the value to options[0] automatically'
)
it.todo(
'setOptions() fires on(\'change\') only if the current value was reset due to option list change'
)
})
describe('NodeHandle.addWidget / removeWidget — managed widget list mutation (S2.N16)', () => {
it.todo(
'NodeHandle.addWidget(opts) appends a widget, auto-reflowing node size and updating the named widgets_values map'
)
it.todo(
'NodeHandle.removeWidget(name) removes the named widget, auto-reflowing node size and removing the entry from widgets_values'
)
it.todo(
'addWidget does not cause widgets_values positional drift because v2 uses a named map rather than a positional array'
)
it.todo(
'removeWidget(name) on a non-existent widget name throws a typed WidgetNotFoundError'
)
})
})

View File

@@ -1,41 +0,0 @@
// Category: BC.12 — Per-widget serialization transform
// DB cross-ref: S4.W3
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70
// Migration: v1 widget.serializeValue positional index → v2 WidgetHandle.on('serialize') / setSerializeValue name-based
import { describe, it } from 'vitest'
describe('BC.12 migration — per-widget serialization transform', () => {
describe('serializeValue → on(\'serialize\') round-trip equivalence', () => {
it.todo(
'a v1 widget.serializeValue that returns a transformed value and a v2 on(\'serialize\') returning the same transformation produce identical output in the serialized workflow JSON'
)
it.todo(
'v1 serializeValue receives a positional index; v2 on(\'serialize\') does not — callers relying on the index for slot lookup must migrate to name-based lookup'
)
it.todo(
'async transforms: both v1 serializeValue and v2 on(\'serialize\') are awaited by graphToPrompt() before the workflow is finalized'
)
})
describe('serialize===false widget compat', () => {
it.todo(
'v1 positional index for a widget after control_after_generate is offset by 1 relative to the backend prompt; v2 named-map has no such offset'
)
it.todo(
'migrate: v1 code that hard-codes an index offset for serialize===false slots must be rewritten to use WidgetHandle identity by name in v2'
)
it.todo(
'widgets_values_named round-trip: a workflow serialized under v2 with an on(\'serialize\') transform deserializes to the same widget values as the equivalent v1 serializeValue workflow'
)
})
describe('identity stability', () => {
it.todo(
'v2 WidgetHandle identity is stable after node.widgets reordering; v1 serializeValue index changes if widgets are reordered — this is the primary reason to migrate'
)
it.todo(
'setSerializeValue(fn) called twice replaces the first registration; widget.serializeValue overwrites also replace — both v1 and v2 are last-write-wins'
)
})
})

View File

@@ -1,39 +0,0 @@
// Category: BC.12 — Per-widget serialization transform
// DB cross-ref: S4.W3
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70
// blast_radius: 5.58 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: widget.serializeValue = async function(node, index) { return transformedValue }
// Notes: widget.options.serialize===false widgets (e.g. control_after_generate) still occupy a
// widgets_values slot and still fire serializeValue — excluded only from backend prompt by
// graphToPrompt(). See research/architecture/widget-serialization-historical-analysis.md.
import { describe, it } from 'vitest'
describe('BC.12 v1 contract — per-widget serialization transform', () => {
describe('S4.W3 — widget.serializeValue assignment', () => {
it.todo(
'assigning widget.serializeValue = async fn(node, index) causes graphToPrompt() to await fn and use its return value in widgets_values'
)
it.todo(
'serializeValue receives the owning node as first argument and the widget\'s positional index in node.widgets as second argument'
)
it.todo(
'if serializeValue is not assigned, graphToPrompt() uses widget.value directly as the serialized value'
)
it.todo(
'serializeValue may return a value of a different type than widget.value (e.g. string expansion of a seed integer)'
)
})
describe('serialize===false widgets (control_after_generate)', () => {
it.todo(
'a widget with options.serialize===false still occupies a slot in the widgets_values positional array during serialization'
)
it.todo(
'serializeValue fires for a serialize===false widget and its return value appears in widgets_values even though graphToPrompt() excludes it from the backend prompt'
)
it.todo(
'the positional index passed to serializeValue for widgets after a serialize===false widget is offset by one relative to the backend prompt widgets_values array'
)
})
})

View File

@@ -1,47 +0,0 @@
// Category: BC.12 — Per-widget serialization transform
// DB cross-ref: S4.W3
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70
// blast_radius: 5.58 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: WidgetHandle.on('serialize', fn) or WidgetHandle.setSerializeValue(fn)
// Notes: WidgetHandle identity is by name not position (PR #10392 widgets_values_named migration path).
// serialize===false widgets still fire the serialize event and still appear in the named map.
import { describe, it } from 'vitest'
describe('BC.12 v2 contract — per-widget serialization transform', () => {
describe('WidgetHandle.on(\'serialize\', fn) — event-based transform', () => {
it.todo(
'WidgetHandle.on(\'serialize\', fn) fires fn during graphToPrompt(); fn may return a transformed value which replaces the default in the named map'
)
it.todo(
'fn receives a SerializeEvent with { node: NodeHandle, widget: WidgetHandle, value } and can set event.serializedValue to override'
)
it.todo(
'if no on(\'serialize\') listener is registered, graphToPrompt() uses WidgetHandle.value directly'
)
it.todo(
'on(\'serialize\') listener is removed when the extension scope is disposed; subsequent serializations use the raw value'
)
})
describe('WidgetHandle.setSerializeValue(fn) — imperative transform assignment', () => {
it.todo(
'WidgetHandle.setSerializeValue(async fn) registers fn as the sole serialize transform, superseding any prior assignment'
)
it.todo(
'fn passed to setSerializeValue receives (widgetHandle) and its return value is placed in widgets_values_named under the widget name'
)
})
describe('serialize===false widgets (control_after_generate)', () => {
it.todo(
'a widget with serialize===false still appears as a named entry in widgets_values_named during serialization'
)
it.todo(
'on(\'serialize\') fires for a serialize===false WidgetHandle; the returned value is stored in the named map but omitted from the backend prompt'
)
it.todo(
'WidgetHandle identity for serialize===false widgets is stable across slot reordering because it is name-based not position-based'
)
})
})

View File

@@ -1,44 +0,0 @@
// Category: BC.13 — Per-node serialization interception
// DB cross-ref: S2.N6, S2.N15
// Exemplar: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438
// Migration: v1 prototype.serialize patching / node.onSerialize → v2 NodeHandle.on('serialize') named-map
import { describe, it } from 'vitest'
describe('BC.13 migration — per-node serialization interception', () => {
describe('(a) positional v1 compat: prototype.serialize / onSerialize parity', () => {
it.todo(
'custom field injected via v1 prototype.serialize patch and the same field injected via v2 on(\'serialize\') both appear in the serialized workflow JSON under identical keys'
)
it.todo(
'v1 onSerialize and v2 on(\'serialize\') both fire once per graphToPrompt() call with the same node\'s serialization data'
)
it.todo(
'v1 chain of two prototype.serialize patchers produces the same custom-field set as two v2 on(\'serialize\') listeners registered by separate extensions'
)
})
describe('(b) named-map v2 round-trip parity', () => {
it.todo(
'a workflow serialized under v2 with widgets_values_named and deserialized produces the same widget values as the equivalent v1 workflow with a positional widgets_values array'
)
it.todo(
'adding a new widget between two existing widgets does not shift the named-map entries for subsequent widgets (v2); it does shift positional indices in v1 — migration callers must stop relying on hardcoded indices'
)
it.todo(
'serialize===false widget (control_after_generate) occupies a named-map entry in v2 with no positional offset; v1 callers that computed offsets must remove that logic'
)
})
describe('(c) null-in-numeric-widget: warning + default substitution', () => {
it.todo(
'v1 NaN widget value silently becomes null in the workflow JSON; v2 substitutes the declared default and emits a console.warn — the logged message includes the node id and widget name'
)
it.todo(
'a workflow with a null widgets_values entry for a numeric widget loaded under v2 emits a console.warn and restores the declared default rather than loading null'
)
it.todo(
'the NaN guard does not trigger for non-numeric widgets whose value is legitimately null (e.g. unset optional inputs)'
)
})
})

View File

@@ -1,53 +0,0 @@
// Category: BC.13 — Per-node serialization interception
// DB cross-ref: S2.N6, S2.N15
// Exemplar: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438
// blast_radius: 6.36 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v1 contract: node.prototype.serialize = function() { const r = origSerialize.call(this); r.myData = ...; return r }
// node.onSerialize = function(data) { data.myData = ... }
// Notes: widgets_values is positional. Three index-drift sources: control_after_generate slot occupancy,
// extension-injected widgets, V3 IO.MultiType topology-dependent widget count. NaN→null pipeline
// produces silent corruption. Test (a) positional v1 compat, (b) named-map v2 round-trip parity,
// (c) null-in-numeric-widget logs warning + substitutes default.
import { describe, it } from 'vitest'
describe('BC.13 v1 contract — per-node serialization interception', () => {
describe('S2.N6 — prototype.serialize patching', () => {
it.todo(
'patching node.constructor.prototype.serialize and calling origSerialize.call(this) produces the base serialization object which can be extended with custom fields'
)
it.todo(
'custom fields added to the object returned by the patched serialize are present in the workflow JSON written to disk'
)
it.todo(
'multiple extensions each patching prototype.serialize via origSerialize chaining all contribute their custom fields to the final serialized object'
)
it.todo(
'positional widgets_values in the patched serialize output drifts when a serialize===false widget occupies a slot before the target widget'
)
})
describe('S2.N15 — node.onSerialize callback', () => {
it.todo(
'assigning node.onSerialize = fn causes fn to be called with the serialization data object after the base serialize completes'
)
it.todo(
'onSerialize may mutate data.myData in place; the mutation is reflected in the workflow JSON'
)
it.todo(
'NaN values written to widgets_values inside onSerialize are silently coerced to null by JSON.stringify, producing silent corruption'
)
it.todo(
'onSerialize fires once per serialization pass; calling graphToPrompt() twice calls onSerialize twice'
)
})
describe('NaN→null silent corruption', () => {
it.todo(
'a numeric widget whose serializeValue returns NaN causes a null entry in widgets_values after JSON round-trip'
)
it.todo(
'the null entry in widgets_values is loaded back as null on graph restore, not as 0 or the widget default'
)
})
})

View File

@@ -1,50 +0,0 @@
// Category: BC.13 — Per-node serialization interception
// DB cross-ref: S2.N6, S2.N15
// Exemplar: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438
// blast_radius: 6.36 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.on('serialize', (data) => { data.myData = ... }) — named map round-trip
// Notes: v2 uses widgets_values_named keyed by widget name, eliminating positional drift.
// NaN→null pipeline: v2 serializer logs a warning and substitutes the widget's declared default.
import { describe, it } from 'vitest'
describe('BC.13 v2 contract — per-node serialization interception', () => {
describe('NodeHandle.on(\'serialize\', fn) — node-level serialization hook (S2.N6, S2.N15)', () => {
it.todo(
'NodeHandle.on(\'serialize\', fn) fires fn with the serialization data object during graphToPrompt(); fn may add custom fields'
)
it.todo(
'custom fields added to data inside on(\'serialize\') are present in the workflow JSON under the node\'s entry'
)
it.todo(
'multiple on(\'serialize\') listeners from different extensions all fire and their custom fields coexist without overwriting each other (assuming distinct keys)'
)
it.todo(
'on(\'serialize\') listener is removed when the extension scope is disposed; subsequent serializations omit the custom fields'
)
})
describe('named-map round-trip (widgets_values_named)', () => {
it.todo(
'v2 serialization stores widget values in a named map (widgets_values_named) keyed by widget name; the map survives a JSON round-trip with no null drift'
)
it.todo(
'a workflow serialized with three widgets including one serialize===false widget deserializes with correct values for all three regardless of insertion order'
)
it.todo(
'widgets added or removed between two serialization passes do not corrupt the named-map entries for unaffected widgets'
)
})
describe('NaN→null guard (numeric widget safety)', () => {
it.todo(
'when a numeric widget value resolves to NaN at serialization time, v2 logs a console warning and substitutes the widget\'s declared default value'
)
it.todo(
'the substituted default value round-trips through JSON correctly; the deserialized node shows the default, not null'
)
it.todo(
'NaN guard fires per-widget and does not abort the serialization of the remaining widgets on the same node'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.14 — Workflow → API serialization interception (graphToPrompt)
// DB cross-ref: S6.A1
// Exemplar: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781
// blast_radius: 7.02 (HIGHEST in dataset)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 app.graphToPrompt monkey-patch → v2 app.on('beforeGraphToPrompt', handler)
import { describe, it } from 'vitest'
describe('BC.14 migration — graphToPrompt interception', () => {
describe('payload equivalence', () => {
it.todo(
'v1 monkey-patch and v2 beforeGraphToPrompt handler both receive equivalent { output, workflow } structures'
)
it.todo(
'custom metadata injected in v1 via return-value mutation is equally injectable via v2 payload mutation'
)
it.todo(
'v1 virtual-node removal logic produces the same serialized output as v2 automatic isVirtual resolution'
)
})
describe('execution ordering', () => {
it.todo(
'v2 handler fires at the same logical point in the queue pipeline as v1 wrapper (before HTTP dispatch)'
)
it.todo(
'v2 cancellation via payload.cancel() has equivalent effect to v1 throwing an error inside the wrapper'
)
})
describe('coexistence during migration window', () => {
it.todo(
'a v1 monkey-patch and a v2 beforeGraphToPrompt handler active simultaneously do not double-mutate the payload'
)
it.todo(
'removing the v1 monkey-patch while keeping the v2 handler produces identical final API payloads'
)
})
})

View File

@@ -1,32 +0,0 @@
// Category: BC.14 — Workflow → API serialization interception (graphToPrompt)
// DB cross-ref: S6.A1
// Exemplar: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781
// blast_radius: 7.02 (HIGHEST in dataset)
// compat-floor: blast_radius ≥ 2.0
// v1 contract: monkey-patch app.graphToPrompt — const orig = app.graphToPrompt.bind(app); app.graphToPrompt = async function(...args) { const r = await orig(...args); /* mutate r */ return r }
// v2 replacement: app.on('beforeGraphToPrompt', (payload) => { /* mutate payload */ }) event with cancellable/mutable payload
import { describe, it } from 'vitest'
describe('BC.14 v1 contract — graphToPrompt monkey-patch', () => {
describe('S6.A1 — app.graphToPrompt interception', () => {
it.todo(
'extension can replace app.graphToPrompt with a wrapper that calls the original and returns the result'
)
it.todo(
'wrapper receives the same positional arguments that the caller passed to app.graphToPrompt'
)
it.todo(
'mutations to the resolved prompt object (output, workflow) are reflected in the final API payload'
)
it.todo(
'virtual nodes resolved by the extension wrapper are absent from the serialized output sent to the backend'
)
it.todo(
'custom metadata injected into prompt.output is preserved through the full queuePrompt call'
)
it.todo(
'multiple extensions wrapping graphToPrompt in sequence each receive and pass through prior mutations'
)
})
})

View File

@@ -1,43 +0,0 @@
// Category: BC.14 — Workflow → API serialization interception (graphToPrompt)
// DB cross-ref: S6.A1
// Exemplar: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781
// blast_radius: 7.02 (HIGHEST in dataset)
// compat-floor: blast_radius ≥ 2.0
// v2 replacement: app.on('beforeGraphToPrompt', (payload) => { /* mutate payload */ }) event with cancellable/mutable payload
import { describe, it } from 'vitest'
describe('BC.14 v2 contract — beforeGraphToPrompt event', () => {
describe('event registration and dispatch', () => {
it.todo(
'app.on("beforeGraphToPrompt", handler) registers a handler that fires before every prompt serialization'
)
it.todo(
'handler receives a mutable payload object containing { output, workflow } matching the v1 return shape'
)
it.todo(
'mutations to payload.output inside the handler are present in the API body sent to the backend'
)
it.todo(
'handler can cancel serialization by calling payload.cancel(), preventing the queue call from proceeding'
)
})
describe('virtual node resolution', () => {
it.todo(
'virtual nodes declared via defineNodeExtension({ isVirtual: true }) are resolved before beforeGraphToPrompt fires'
)
it.todo(
'handler does not need to manually remove virtual nodes; they are absent from payload.output by default'
)
})
describe('multiple handlers and ordering', () => {
it.todo(
'multiple handlers registered with app.on("beforeGraphToPrompt") are called in registration order'
)
it.todo(
'each handler sees mutations made by prior handlers in the same event cycle'
)
})
})

View File

@@ -1,37 +0,0 @@
// Category: BC.15 — Workflow loading into the editor
// DB cross-ref: S6.A2
// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456
// blast_radius: 5.05 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 app.loadGraphData(json) → v2 app.loadWorkflow(json) with lifecycle hooks
import { describe, it } from 'vitest'
describe('BC.15 migration — workflow loading', () => {
describe('graph state equivalence', () => {
it.todo(
'v1 app.loadGraphData(json) and v2 app.loadWorkflow(json) produce identical node/link graphs for the same input'
)
it.todo(
'node widget values are preserved identically between v1 and v2 load paths'
)
it.todo(
'custom node types registered by extensions are correctly hydrated by both v1 and v2 load paths'
)
})
describe('interception migration', () => {
it.todo(
'v1 monkey-patching app.loadGraphData to mutate json can be replaced by a v2 beforeLoadWorkflow handler with equivalent effect'
)
it.todo(
'v1 post-load logic run synchronously after app.loadGraphData can be moved to a v2 afterLoadWorkflow handler'
)
})
describe('coexistence', () => {
it.todo(
'calling v2 app.loadWorkflow does not break extensions that still listen on the legacy nodeCreated hook'
)
})
})

View File

@@ -1,28 +0,0 @@
// Category: BC.15 — Workflow loading into the editor
// DB cross-ref: S6.A2
// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456
// blast_radius: 5.05 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v1 contract: app.loadGraphData(workflowJson) — direct call, no lifecycle events
import { describe, it } from 'vitest'
describe('BC.15 v1 contract — app.loadGraphData', () => {
describe('S6.A2 — direct workflow load', () => {
it.todo(
'app.loadGraphData(json) replaces the current graph with the nodes and links from json'
)
it.todo(
'calling app.loadGraphData clears all existing nodes before deserializing the new workflow'
)
it.todo(
'node IDs in the loaded workflow are preserved as-is in the editor graph'
)
it.todo(
'app.loadGraphData accepts a plain JSON object (not a string) as its argument'
)
it.todo(
'extensions registered with nodeCreated receive each deserialized node after loadGraphData completes'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.15 — Workflow loading into the editor
// DB cross-ref: S6.A2
// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456
// blast_radius: 5.05 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v2 replacement: app.loadWorkflow(json) — stable public API with beforeLoad/afterLoad hooks for intercepting extensions
import { describe, it } from 'vitest'
describe('BC.15 v2 contract — app.loadWorkflow', () => {
describe('core load API', () => {
it.todo(
'app.loadWorkflow(json) loads workflow nodes and links into the editor, equivalent to v1 loadGraphData'
)
it.todo(
'app.loadWorkflow returns a Promise that resolves once all nodes are deserialized and rendered'
)
it.todo(
'app.loadWorkflow accepts both plain objects and JSON strings'
)
})
describe('beforeLoad hook', () => {
it.todo(
'app.on("beforeLoadWorkflow", handler) fires before the graph is cleared, allowing cancellation via event.cancel()'
)
it.todo(
'handler can mutate event.workflow to transform the incoming JSON before deserialization'
)
})
describe('afterLoad hook', () => {
it.todo(
'app.on("afterLoadWorkflow", handler) fires after all nodes are created, with the fully hydrated graph accessible'
)
it.todo(
'afterLoad handler receives the original workflow JSON alongside the live graph for cross-referencing'
)
})
})

View File

@@ -1,37 +0,0 @@
// Category: BC.16 — Execution output consumption (per-node)
// DB cross-ref: S2.N2
// Exemplar: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9
// blast_radius: 4.67 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 node.onExecuted = fn → v2 NodeHandle.on('executed', fn)
import { describe, it } from 'vitest'
describe('BC.16 migration — per-node execution output', () => {
describe('data equivalence', () => {
it.todo(
'v1 onExecuted data argument and v2 executed event data contain identical fields for the same backend response'
)
it.todo(
'data.text and data.images accessed in v2 handler match the same properties read in v1 onExecuted for the same execution'
)
})
describe('timing equivalence', () => {
it.todo(
'v2 NodeHandle.on("executed") fires at the same point in the WebSocket message processing pipeline as v1 onExecuted'
)
it.todo(
'DOM/widget updates performed in the v2 handler are applied within the same animation frame as equivalent v1 updates'
)
})
describe('cleanup behaviour', () => {
it.todo(
'v1 onExecuted persists after node removal (no automatic cleanup); v2 handler is removed automatically'
)
it.todo(
'explicitly calling the v2 unsubscribe function produces equivalent silence to never assigning v1 onExecuted'
)
})
})

View File

@@ -1,31 +0,0 @@
// Category: BC.16 — Execution output consumption (per-node)
// DB cross-ref: S2.N2
// Exemplar: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9
// blast_radius: 4.67 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v1 contract: node.onExecuted = function(data) { /* data.text, data.images etc */ }
import { describe, it } from 'vitest'
describe('BC.16 v1 contract — node.onExecuted callback', () => {
describe('S2.N2 — per-node execution output', () => {
it.todo(
'node.onExecuted is called by the runtime when the backend reports output for that node\'s ID'
)
it.todo(
'data.text is an array of strings when the node outputs text-type results'
)
it.todo(
'data.images is an array of image descriptor objects when the node outputs image-type results'
)
it.todo(
'data passed to onExecuted matches the raw output object from the backend executed event for that node'
)
it.todo(
'assigning node.onExecuted after graph load is sufficient; the handler receives subsequent execution outputs'
)
it.todo(
'onExecuted is not called for nodes whose IDs are absent from the execution output'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.16 — Execution output consumption (per-node)
// DB cross-ref: S2.N2
// Exemplar: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9
// blast_radius: 4.67 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v2 replacement: NodeHandle.on('executed', (data) => { ... })
import { describe, it } from 'vitest'
describe('BC.16 v2 contract — NodeHandle executed event', () => {
describe('event subscription', () => {
it.todo(
'nodeHandle.on("executed", handler) registers a handler that fires when backend output arrives for that node'
)
it.todo(
'handler receives a typed data object with text, images, and any other output slots defined by the node\'s schema'
)
it.todo(
'nodeHandle.on("executed", ...) returns an unsubscribe function; calling it stops future invocations'
)
})
describe('data shape and typing', () => {
it.todo(
'data.text is typed as string[] for text-output nodes; accessing it does not require a cast'
)
it.todo(
'data.images is typed as ImageOutput[] for image-output nodes, including filename, subfolder, and type fields'
)
})
describe('handler lifecycle', () => {
it.todo(
'handlers registered via nodeHandle.on("executed") are automatically removed when the node is removed from the graph'
)
it.todo(
'multiple handlers on the same node each fire independently and in registration order'
)
})
})

View File

@@ -1,43 +0,0 @@
// Category: BC.17 — Backend execution lifecycle and progress events
// DB cross-ref: S5.A1, S5.A2, S5.A3
// Exemplar: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39
// blast_radius: 5.00 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 app.api.addEventListener → v2 comfyApp.on with typed payloads
import { describe, it } from 'vitest'
describe('BC.17 migration — execution lifecycle events', () => {
describe('event payload equivalence (S5.A1 — executed / execution_error)', () => {
it.todo(
'v1 "executed" CustomEvent.detail and v2 "executed" payload carry the same node ID and output fields'
)
it.todo(
'v1 "execution_error" detail and v2 "executionError" payload both identify the failing node and provide error text'
)
})
describe('progress payload equivalence (S5.A2)', () => {
it.todo(
'v1 progress detail { value, max } and v2 progress payload { step, totalSteps } encode the same completion fraction'
)
})
describe('status and reconnect equivalence (S5.A3)', () => {
it.todo(
'v1 "status" event and v2 "status" event fire at the same points in the WebSocket message lifecycle'
)
it.todo(
'v1 "reconnecting" event and v2 "reconnecting" event both fire before the first reconnect attempt'
)
})
describe('handler removal equivalence', () => {
it.todo(
'v1 app.api.removeEventListener(name, fn) and v2 unsubscribe() both stop the handler from firing on subsequent events'
)
it.todo(
'removing a v1 listener does not affect a concurrently registered v2 listener for the same logical event'
)
})
})

View File

@@ -1,43 +0,0 @@
// Category: BC.17 — Backend execution lifecycle and progress events
// DB cross-ref: S5.A1, S5.A2, S5.A3
// Exemplar: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39
// blast_radius: 5.00 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v1 contract: app.api.addEventListener('executed'|'progress'|'status'|'execution_error'|'reconnecting', fn)
import { describe, it } from 'vitest'
describe('BC.17 v1 contract — app.api.addEventListener', () => {
describe('S5.A1 — execution lifecycle events (executed, execution_error)', () => {
it.todo(
'app.api.addEventListener("executed", fn) fires fn when a node execution completes with output data'
)
it.todo(
'app.api.addEventListener("execution_error", fn) fires fn with error detail when the backend reports a failure'
)
it.todo(
'the executed event detail includes { node, output } matching the backend WebSocket message structure'
)
})
describe('S5.A2 — progress events', () => {
it.todo(
'app.api.addEventListener("progress", fn) fires fn on each step tick during a running execution'
)
it.todo(
'the progress event detail includes { value, max } allowing accurate percentage calculation'
)
})
describe('S5.A3 — status and reconnect events', () => {
it.todo(
'app.api.addEventListener("status", fn) fires fn when the backend queue status changes'
)
it.todo(
'app.api.addEventListener("reconnecting", fn) fires fn when the WebSocket connection is lost and retrying'
)
it.todo(
'app.api.removeEventListener with the same event name and function reference removes the handler'
)
})
})

View File

@@ -1,43 +0,0 @@
// Category: BC.17 — Backend execution lifecycle and progress events
// DB cross-ref: S5.A1, S5.A2, S5.A3
// Exemplar: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39
// blast_radius: 5.00 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v2 replacement: comfyApp.on('executed', fn), comfyApp.on('progress', fn) — typed event payloads
import { describe, it } from 'vitest'
describe('BC.17 v2 contract — comfyApp event subscriptions', () => {
describe('S5.A1 — execution lifecycle events', () => {
it.todo(
'comfyApp.on("executed", fn) fires fn when a node reports completion, with a typed { nodeId, output } payload'
)
it.todo(
'comfyApp.on("executionError", fn) fires fn with a typed error payload including nodeId and exception detail'
)
it.todo(
'comfyApp.on("executionStart", fn) fires fn when the backend begins processing a new prompt'
)
})
describe('S5.A2 — progress events', () => {
it.todo(
'comfyApp.on("progress", fn) fires fn on each step tick with typed { step, totalSteps, nodeId } fields'
)
it.todo(
'progress percentage derived from v2 payload (step / totalSteps) equals percentage from v1 (value / max)'
)
})
describe('S5.A3 — status and connectivity events', () => {
it.todo(
'comfyApp.on("status", fn) fires fn when queue depth or running state changes, with a typed status payload'
)
it.todo(
'comfyApp.on("reconnecting", fn) fires fn when the WebSocket drops and a reconnect attempt begins'
)
it.todo(
'calling the unsubscribe handle returned by comfyApp.on() removes the handler without affecting other subscribers'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.18 — Backend HTTP calls
// DB cross-ref: S6.A3
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61
// blast_radius: 5.77 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 app.api.fetchApi → v2 comfyAPI.fetchApi (same signature, stable import)
import { describe, it } from 'vitest'
describe('BC.18 migration — backend HTTP calls', () => {
describe('request equivalence', () => {
it.todo(
'v1 app.api.fetchApi(path, init) and v2 comfyAPI.fetchApi(path, init) send identical HTTP requests to the backend'
)
it.todo(
'authentication headers attached by v1 and v2 are equivalent; the backend accepts both without reconfiguration'
)
it.todo(
'FormData uploads via v1 and v2 produce the same multipart body on the wire'
)
})
describe('response handling equivalence', () => {
it.todo(
'v1 and v2 both return a native Response object; callers can use .json(), .text(), and .ok identically'
)
it.todo(
'4xx/5xx responses resolve (not reject) in both v1 and v2, so existing error-check patterns remain valid'
)
})
describe('import path migration', () => {
it.todo(
'replacing "app.api.fetchApi" with an import of comfyAPI.fetchApi requires no call-site argument changes'
)
it.todo(
'comfyAPI.fetchApi is available at extension init time without waiting for app.setup() to complete'
)
})
})

View File

@@ -1,31 +0,0 @@
// Category: BC.18 — Backend HTTP calls
// DB cross-ref: S6.A3
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61
// blast_radius: 5.77 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v1 contract: app.api.fetchApi('/endpoint', { method: 'POST', body: ... })
import { describe, it } from 'vitest'
describe('BC.18 v1 contract — app.api.fetchApi', () => {
describe('S6.A3 — authenticated HTTP calls via fetchApi', () => {
it.todo(
'app.api.fetchApi(path, init) returns a Promise<Response> from the ComfyUI backend origin'
)
it.todo(
'fetchApi prepends the configured base URL so callers use relative paths like "/upload/image"'
)
it.todo(
'fetchApi includes authentication headers (e.g. session cookie or Authorization) automatically'
)
it.todo(
'a POST call with a FormData body is forwarded without Content-Type override, allowing multipart to work'
)
it.todo(
'a non-2xx response from the backend is returned as a resolved Promise (not rejected); callers must check response.ok'
)
it.todo(
'concurrent fetchApi calls from different extensions do not share or corrupt each other\'s request state'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.18 — Backend HTTP calls
// DB cross-ref: S6.A3
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61
// blast_radius: 5.77 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v2 replacement: comfyAPI.fetchApi(path, opts) — same signature, same authentication, stable import path
import { describe, it } from 'vitest'
describe('BC.18 v2 contract — comfyAPI.fetchApi', () => {
describe('API surface stability', () => {
it.todo(
'comfyAPI.fetchApi(path, init) is importable from the stable extension-api-v2 package without accessing app.api'
)
it.todo(
'comfyAPI.fetchApi signature is identical to v1 app.api.fetchApi: (path: string, init?: RequestInit) => Promise<Response>'
)
it.todo(
'comfyAPI.fetchApi uses the same base URL and authentication mechanism as v1 fetchApi'
)
})
describe('request handling', () => {
it.todo(
'POST with FormData body is forwarded correctly, preserving multipart boundary'
)
it.todo(
'JSON body with explicit Content-Type: application/json is sent without modification'
)
it.todo(
'non-2xx responses resolve (not reject) the returned Promise, consistent with v1 behaviour'
)
})
describe('extension isolation', () => {
it.todo(
'comfyAPI.fetchApi does not expose session credentials in a way that allows cross-extension credential theft'
)
})
})

View File

@@ -1,40 +0,0 @@
// Category: BC.19 — Workflow execution trigger
// DB cross-ref: S6.A4
// Exemplar: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317
// blast_radius: 6.09 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 app.queuePrompt monkey-patch → v2 comfyApp.on('beforeQueuePrompt') + comfyApp.queuePrompt(opts)
import { describe, it } from 'vitest'
describe('BC.19 migration — workflow execution trigger', () => {
describe('payload mutation equivalence', () => {
it.todo(
'v1 wrapper mutation of the serialized prompt body and v2 event.payload mutation produce identical HTTP request bodies'
)
it.todo(
'auth tokens injected via v1 wrapper extra_data and v2 event.payload.extra_data reach the backend identically'
)
})
describe('cancellation equivalence', () => {
it.todo(
'v1 wrapper that does not call orig() and v2 handler that calls event.cancel() both result in zero HTTP calls to /prompt'
)
})
describe('programmatic trigger equivalence', () => {
it.todo(
'v1 app.queuePrompt(0, 1) and v2 comfyApp.queuePrompt({ batchCount: 1 }) both enqueue the same graph payload'
)
it.todo(
'v2 comfyApp.queuePrompt() fires beforeQueuePrompt handlers; v1 programmatic call also triggers any active v1 wrappers'
)
})
describe('coexistence', () => {
it.todo(
'a v1 monkey-patch and a v2 beforeQueuePrompt handler active simultaneously do not double-submit the prompt'
)
})
})

View File

@@ -1,31 +0,0 @@
// Category: BC.19 — Workflow execution trigger
// DB cross-ref: S6.A4
// Exemplar: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317
// blast_radius: 6.09 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v1 contract: monkey-patch app.queuePrompt — const orig = app.queuePrompt.bind(app); app.queuePrompt = async function(num, batchCount) { /* mutate */ return orig(num, batchCount) }
import { describe, it } from 'vitest'
describe('BC.19 v1 contract — app.queuePrompt monkey-patch', () => {
describe('S6.A4 — queuePrompt interception', () => {
it.todo(
'extension can replace app.queuePrompt with a wrapper that calls the original and returns its result'
)
it.todo(
'wrapper receives (number, batchCount) arguments matching the internal call signature'
)
it.todo(
'extension can inject an auth token or extra field into the prompt payload before delegating to orig()'
)
it.todo(
'extension can prevent execution by not calling orig() inside the wrapper'
)
it.todo(
'multiple extensions wrapping queuePrompt in sequence each execute in wrapping order'
)
it.todo(
'programmatic call to app.queuePrompt(0, 1) from an extension correctly enqueues the current graph'
)
})
})

View File

@@ -1,43 +0,0 @@
// Category: BC.19 — Workflow execution trigger
// DB cross-ref: S6.A4
// Exemplar: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317
// blast_radius: 6.09 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// v2 replacement: comfyApp.on('beforeQueuePrompt', handler) with event.payload mutation; comfyApp.queuePrompt(opts) for programmatic trigger
import { describe, it } from 'vitest'
describe('BC.19 v2 contract — beforeQueuePrompt event and comfyApp.queuePrompt', () => {
describe('beforeQueuePrompt event', () => {
it.todo(
'comfyApp.on("beforeQueuePrompt", handler) fires before every prompt is enqueued, including UI-triggered runs'
)
it.todo(
'handler receives a mutable event.payload containing the prompt body and extra_data fields'
)
it.todo(
'mutating event.payload.extra_data.extra_pnginfo in the handler persists into the queued request'
)
it.todo(
'calling event.cancel() inside the handler prevents the prompt from being submitted to the backend'
)
})
describe('programmatic trigger', () => {
it.todo(
'comfyApp.queuePrompt(opts) programmatically enqueues the current workflow, firing beforeQueuePrompt first'
)
it.todo(
'opts.batchCount defaults to 1 when omitted; the backend receives a single prompt'
)
})
describe('multiple handlers', () => {
it.todo(
'multiple beforeQueuePrompt handlers are called in registration order; each sees prior mutations'
)
it.todo(
'cancellation by any handler short-circuits remaining handlers and suppresses the HTTP call'
)
})
})

View File

@@ -1,46 +0,0 @@
// Category: BC.20 — Custom node-type registration (frontend-only / virtual)
// DB cross-ref: S1.H5, S1.H6, S8.P1
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts
// blast_radius: 5.49 (compat-floor)
// compat-floor: blast_radius ≥ 2.0
// Migration: v1 LiteGraph.registerNodeType + isVirtualNode → v2 defineNodeExtension({ isVirtual: true, setup })
import { describe, it } from 'vitest'
describe('BC.20 migration — custom and virtual node registration', () => {
describe('registration equivalence (S1.H5)', () => {
it.todo(
'v1 LiteGraph.registerNodeType("MyType", MyClass) and v2 defineNodeExtension({ nodeType: "MyType" }) both make the type droppable from the node picker'
)
it.todo(
'v1 MyClass.prototype.isVirtualNode = true and v2 isVirtual: true both exclude the node from the graphToPrompt output'
)
it.todo(
'canvas rendering behaviour of a virtual node is identical between v1 and v2 registration paths'
)
})
describe('augmentation equivalence (S1.H6)', () => {
it.todo(
'v1 beforeRegisterNodeDef prototype mutation and v2 defineNodeExtension setup() widget addition produce equivalent UI on existing backend node types'
)
it.todo(
'widget values set via v2 setup(handle) are serialized identically to those set via v1 prototype augmentation'
)
})
describe('serialization equivalence (S8.P1)', () => {
it.todo(
'a graph with virtual nodes serialized via v1 graphToPrompt and the same graph using v2 produce bit-equivalent backend payloads'
)
it.todo(
'link re-routing through virtual nodes produces the same source→target pairs in both v1 and v2 serialized outputs'
)
})
describe('cleanup on unregister', () => {
it.todo(
'v1 registered types persist in LiteGraph after extension unregisters; v2 types registered via defineNodeExtension are removed'
)
})
})

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