mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
5 Commits
v1.45.1
...
ecs-vue-ho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6fe052cd | ||
|
|
83263fd2ee | ||
|
|
581c5bb38e | ||
|
|
4d37194be6 | ||
|
|
5bb54de0d7 |
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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.
|
||||
128
docs/architecture/extension-api-v2/README.md
Normal file
128
docs/architecture/extension-api-v2/README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 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
|
||||
265
docs/architecture/extension-api-v2/scripts/add-evidence-pass2.py
Normal file
265
docs/architecture/extension-api-v2/scripts/add-evidence-pass2.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/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()
|
||||
213
docs/architecture/extension-api-v2/scripts/add-evidence.py
Normal file
213
docs/architecture/extension-api-v2/scripts/add-evidence.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/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()
|
||||
67
docs/architecture/extension-api-v2/scripts/fetch-stars.sh
Executable file
67
docs/architecture/extension-api-v2/scripts/fetch-stars.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/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())
|
||||
15118
docs/architecture/extension-api-v2/touch-points-database.yaml
Normal file
15118
docs/architecture/extension-api-v2/touch-points-database.yaml
Normal file
File diff suppressed because one or more lines are too long
313
docs/architecture/extension-api-v2/touch-points-plan.md
Normal file
313
docs/architecture/extension-api-v2/touch-points-plan.md
Normal file
@@ -0,0 +1,313 @@
|
||||
---
|
||||
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 G1–G13, 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 S1–S8 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 S1–S8 (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)
|
||||
1184
docs/architecture/extension-api-v2/touch-points-rollup.yaml
Normal file
1184
docs/architecture/extension-api-v2/touch-points-rollup.yaml
Normal file
File diff suppressed because it is too large
Load Diff
724
docs/architecture/extension-api-v2/touch-points-star-cache.yaml
Normal file
724
docs/architecture/extension-api-v2/touch-points-star-cache.yaml
Normal file
@@ -0,0 +1,724 @@
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -47,6 +47,9 @@
|
||||
"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",
|
||||
|
||||
3
packages/extension-api/.gitignore
vendored
Normal file
3
packages/extension-api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
docs-build/
|
||||
build/
|
||||
node_modules/
|
||||
50
packages/extension-api/README.md
Normal file
50
packages/extension-api/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @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
|
||||
28
packages/extension-api/package.json
Normal file
28
packages/extension-api/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
470
packages/extension-api/scripts/build-docs.ts
Normal file
470
packages/extension-api/scripts/build-docs.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
#!/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()
|
||||
}
|
||||
21
packages/extension-api/tsconfig.docs.json
Normal file
21
packages/extension-api/tsconfig.docs.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
37
packages/extension-api/typedoc.json
Normal file
37
packages/extension-api/typedoc.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
39
src/extension-api-v2/__tests__/bc-01.migration.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-01.migration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
45
src/extension-api-v2/__tests__/bc-01.v1.test.ts
Normal file
45
src/extension-api-v2/__tests__/bc-01.v1.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-01.v2.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-01.v2.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-02.migration.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-02.migration.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
135
src/extension-api-v2/__tests__/bc-02.v1.test.ts
Normal file
135
src/extension-api-v2/__tests__/bc-02.v1.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-02.v2.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-02.v2.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-03.migration.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-03.migration.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-03.v1.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-03.v1.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-03.v2.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-03.v2.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
45
src/extension-api-v2/__tests__/bc-04.migration.test.ts
Normal file
45
src/extension-api-v2/__tests__/bc-04.migration.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
49
src/extension-api-v2/__tests__/bc-04.v1.test.ts
Normal file
49
src/extension-api-v2/__tests__/bc-04.v1.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
48
src/extension-api-v2/__tests__/bc-04.v2.test.ts
Normal file
48
src/extension-api-v2/__tests__/bc-04.v2.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-05.migration.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-05.migration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-05.v1.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-05.v1.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-05.v2.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-05.v2.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-06.migration.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-06.migration.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
55
src/extension-api-v2/__tests__/bc-06.v1.test.ts
Normal file
55
src/extension-api-v2/__tests__/bc-06.v1.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-06.v2.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-06.v2.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-07.migration.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-07.migration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
53
src/extension-api-v2/__tests__/bc-07.v1.test.ts
Normal file
53
src/extension-api-v2/__tests__/bc-07.v1.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
51
src/extension-api-v2/__tests__/bc-07.v2.test.ts
Normal file
51
src/extension-api-v2/__tests__/bc-07.v2.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-08.migration.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-08.migration.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-08.v1.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-08.v1.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-08.v2.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-08.v2.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
42
src/extension-api-v2/__tests__/bc-09.migration.test.ts
Normal file
42
src/extension-api-v2/__tests__/bc-09.migration.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
50
src/extension-api-v2/__tests__/bc-09.v1.test.ts
Normal file
50
src/extension-api-v2/__tests__/bc-09.v1.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
50
src/extension-api-v2/__tests__/bc-09.v2.test.ts
Normal file
50
src/extension-api-v2/__tests__/bc-09.v2.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-10.migration.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-10.migration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
37
src/extension-api-v2/__tests__/bc-10.v1.test.ts
Normal file
37
src/extension-api-v2/__tests__/bc-10.v1.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-10.v2.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-10.v2.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
45
src/extension-api-v2/__tests__/bc-11.migration.test.ts
Normal file
45
src/extension-api-v2/__tests__/bc-11.migration.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
51
src/extension-api-v2/__tests__/bc-11.v1.test.ts
Normal file
51
src/extension-api-v2/__tests__/bc-11.v1.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
52
src/extension-api-v2/__tests__/bc-11.v2.test.ts
Normal file
52
src/extension-api-v2/__tests__/bc-11.v2.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-12.migration.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-12.migration.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-12.v1.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-12.v1.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
47
src/extension-api-v2/__tests__/bc-12.v2.test.ts
Normal file
47
src/extension-api-v2/__tests__/bc-12.v2.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-13.migration.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-13.migration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
53
src/extension-api-v2/__tests__/bc-13.v1.test.ts
Normal file
53
src/extension-api-v2/__tests__/bc-13.v1.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
50
src/extension-api-v2/__tests__/bc-13.v2.test.ts
Normal file
50
src/extension-api-v2/__tests__/bc-13.v2.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-14.migration.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-14.migration.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
32
src/extension-api-v2/__tests__/bc-14.v1.test.ts
Normal file
32
src/extension-api-v2/__tests__/bc-14.v1.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-14.v2.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-14.v2.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
37
src/extension-api-v2/__tests__/bc-15.migration.test.ts
Normal file
37
src/extension-api-v2/__tests__/bc-15.migration.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
28
src/extension-api-v2/__tests__/bc-15.v1.test.ts
Normal file
28
src/extension-api-v2/__tests__/bc-15.v1.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-15.v2.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-15.v2.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
37
src/extension-api-v2/__tests__/bc-16.migration.test.ts
Normal file
37
src/extension-api-v2/__tests__/bc-16.migration.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
31
src/extension-api-v2/__tests__/bc-16.v1.test.ts
Normal file
31
src/extension-api-v2/__tests__/bc-16.v1.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-16.v2.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-16.v2.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-17.migration.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-17.migration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-17.v1.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-17.v1.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-17.v2.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-17.v2.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-18.migration.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-18.migration.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
31
src/extension-api-v2/__tests__/bc-18.v1.test.ts
Normal file
31
src/extension-api-v2/__tests__/bc-18.v1.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-18.v2.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-18.v2.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-19.migration.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-19.migration.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
31
src/extension-api-v2/__tests__/bc-19.v1.test.ts
Normal file
31
src/extension-api-v2/__tests__/bc-19.v1.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-19.v2.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-19.v2.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
46
src/extension-api-v2/__tests__/bc-20.migration.test.ts
Normal file
46
src/extension-api-v2/__tests__/bc-20.migration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
47
src/extension-api-v2/__tests__/bc-20.v1.test.ts
Normal file
47
src/extension-api-v2/__tests__/bc-20.v1.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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
|
||||
// v1 contract: app.registerExtension({ registerCustomNodes(app) { LiteGraph.registerNodeType('MyType', MyClass); MyClass.prototype.isVirtualNode = true } })
|
||||
// app.registerExtension({ beforeRegisterNodeDef(nodeType, nodeData) { ... } })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.20 v1 contract — LiteGraph.registerNodeType and isVirtualNode', () => {
|
||||
describe('S1.H5 — registerCustomNodes hook', () => {
|
||||
it.todo(
|
||||
'registerExtension({ registerCustomNodes(app) }) is called during setup before any graph is loaded'
|
||||
)
|
||||
it.todo(
|
||||
'LiteGraph.registerNodeType("MyType", MyClass) inside registerCustomNodes makes the type instantiable in the graph'
|
||||
)
|
||||
it.todo(
|
||||
'setting MyClass.prototype.isVirtualNode = true causes the serializer to omit the node from the backend API payload'
|
||||
)
|
||||
it.todo(
|
||||
'virtual node is still visible and interactive in the LiteGraph canvas'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S1.H6 — beforeRegisterNodeDef hook', () => {
|
||||
it.todo(
|
||||
'registerExtension({ beforeRegisterNodeDef(nodeType, nodeData) }) fires for every backend-defined node type before it is registered'
|
||||
)
|
||||
it.todo(
|
||||
'extension can augment nodeType prototype inside beforeRegisterNodeDef and the change affects all future instances'
|
||||
)
|
||||
it.todo(
|
||||
'mutations to nodeData inside beforeRegisterNodeDef alter the node\'s widget/input schema visible to the graph'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S8.P1 — virtual node payload suppression', () => {
|
||||
it.todo(
|
||||
'graphToPrompt excludes nodes with isVirtualNode === true from the output object sent to the backend'
|
||||
)
|
||||
it.todo(
|
||||
'links connected to a virtual node are re-routed in the serialized output to preserve logical connectivity'
|
||||
)
|
||||
})
|
||||
})
|
||||
46
src/extension-api-v2/__tests__/bc-20.v2.test.ts
Normal file
46
src/extension-api-v2/__tests__/bc-20.v2.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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
|
||||
// v2 replacement: defineNodeExtension({ nodeType: 'MyType', isVirtual: true, setup(handle) { ... } })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.20 v2 contract — defineNodeExtension', () => {
|
||||
describe('S1.H5 — virtual node registration', () => {
|
||||
it.todo(
|
||||
'defineNodeExtension({ nodeType: "MyType", isVirtual: true, setup }) registers a pure-frontend node type'
|
||||
)
|
||||
it.todo(
|
||||
'nodes registered with isVirtual: true do not appear in the serialized API payload from graphToPrompt'
|
||||
)
|
||||
it.todo(
|
||||
'the virtual node is rendered on the canvas and accepts user interaction normally'
|
||||
)
|
||||
it.todo(
|
||||
'setup(handle) receives a NodeHandle bound to every instance created at graph-load or user-drop time'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S1.H6 — backend node-def augmentation', () => {
|
||||
it.todo(
|
||||
'defineNodeExtension({ nodeType: "ExistingBackendType", setup }) fires setup for every instance of a backend-defined type'
|
||||
)
|
||||
it.todo(
|
||||
'extension can add widgets to the handle inside setup() and they appear on all matching nodes'
|
||||
)
|
||||
it.todo(
|
||||
'schema-level augmentation (adding an input slot) declared via defineNodeExtension takes effect before the node is first rendered'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S8.P1 — serialization of virtual links', () => {
|
||||
it.todo(
|
||||
'links through a virtual node are transparently resolved in the serialized output so backend sees direct source→target connections'
|
||||
)
|
||||
it.todo(
|
||||
'removing the virtual node from the canvas also removes any dangling link stubs from the serialized payload'
|
||||
)
|
||||
})
|
||||
})
|
||||
34
src/extension-api-v2/__tests__/bc-21.migration.test.ts
Normal file
34
src/extension-api-v2/__tests__/bc-21.migration.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Category: BC.21 — Custom widget-type registration
|
||||
// DB cross-ref: S1.H2
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// blast_radius: 4.32
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// Migration: v1 getCustomWidgets factory → v2 defineWidgetExtension
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.21 migration — Custom widget-type registration', () => {
|
||||
describe('factory invocation parity (S1.H2)', () => {
|
||||
it.todo(
|
||||
'v1 factory (node, inputData, app) and v2 create(handle, inputData) both receive equivalent inputData for the same node def'
|
||||
)
|
||||
it.todo(
|
||||
'widget produced by v1 factory and v2 create have identical serialized value in node.widgets after creation'
|
||||
)
|
||||
})
|
||||
|
||||
describe('registration timing', () => {
|
||||
it.todo(
|
||||
'v1 getCustomWidgets fires during extension setup; v2 defineWidgetExtension registers before setup completes — both resolve before nodeCreated'
|
||||
)
|
||||
})
|
||||
|
||||
describe('scope cleanup on dispose', () => {
|
||||
it.todo(
|
||||
'v1 custom widget type persists after extension unregisters; v2 type is unregistered and nodes fall back to default rendering'
|
||||
)
|
||||
it.todo(
|
||||
'v2 cleanup on dispose does not affect widget types registered by other extensions'
|
||||
)
|
||||
})
|
||||
})
|
||||
29
src/extension-api-v2/__tests__/bc-21.v1.test.ts
Normal file
29
src/extension-api-v2/__tests__/bc-21.v1.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Category: BC.21 — Custom widget-type registration
|
||||
// DB cross-ref: S1.H2
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// blast_radius: 4.32
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1 contract: app.registerExtension({ getCustomWidgets(app) { return { MYWIDGET: (node, inputData, app) => { ... } } } })
|
||||
// Notes: small family — 2 evidence rows + 1 minor variant (acceptance carve-out)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.21 v1 contract — Custom widget-type registration', () => {
|
||||
describe('S1.H2 — getCustomWidgets hook', () => {
|
||||
it.todo(
|
||||
'extension returning a widget factory from getCustomWidgets registers the type globally'
|
||||
)
|
||||
it.todo(
|
||||
'registered widget factory is invoked with (node, inputData, app) when a node with that input type is created'
|
||||
)
|
||||
it.todo(
|
||||
'widget returned by factory is attached to node.widgets array'
|
||||
)
|
||||
it.todo(
|
||||
'two extensions registering distinct widget types do not collide'
|
||||
)
|
||||
it.todo(
|
||||
'registering the same widget type key twice: second registration wins (last-write semantics)'
|
||||
)
|
||||
})
|
||||
})
|
||||
28
src/extension-api-v2/__tests__/bc-21.v2.test.ts
Normal file
28
src/extension-api-v2/__tests__/bc-21.v2.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Category: BC.21 — Custom widget-type registration
|
||||
// DB cross-ref: S1.H2
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// blast_radius: 4.32
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 replacement: defineWidgetExtension({ widgetType: 'MYWIDGET', create(handle, inputData) { ... } })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.21 v2 contract — Custom widget-type registration', () => {
|
||||
describe('defineWidgetExtension() — declarative widget registration', () => {
|
||||
it.todo(
|
||||
'defineWidgetExtension({ widgetType, create }) registers the type before any nodeCreated fires'
|
||||
)
|
||||
it.todo(
|
||||
'create(handle, inputData) is called with a typed WidgetHandle and the input spec tuple'
|
||||
)
|
||||
it.todo(
|
||||
'widget registered via defineWidgetExtension appears in NodeHandle.widgets after node creation'
|
||||
)
|
||||
it.todo(
|
||||
'widget is removed from all nodes when the extension scope is disposed'
|
||||
)
|
||||
it.todo(
|
||||
'defineWidgetExtension throws if widgetType is an empty string or conflicts with a built-in type'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-22.migration.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-22.migration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Category: BC.22 — Context menu contributions (node and canvas)
|
||||
// DB cross-ref: S2.N5, S1.H3, S1.H4
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// blast_radius: 5.10
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// Migration: v1 getNodeMenuItems / prototype.getExtraMenuOptions / getCanvasMenuItems
|
||||
// → v2 NodeHandle.addContextMenuItem / app.addCanvasMenuItem
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.22 migration — Context menu contributions (node and canvas)', () => {
|
||||
describe('node menu item parity (S1.H3 → NodeHandle.addContextMenuItem)', () => {
|
||||
it.todo(
|
||||
'v1 getNodeMenuItems item and v2 addContextMenuItem item both appear in the node context menu with equal label text'
|
||||
)
|
||||
it.todo(
|
||||
'action/callback invoked by clicking the item receives equivalent node context in both v1 and v2'
|
||||
)
|
||||
})
|
||||
|
||||
describe('prototype patch migration (S2.N5 → NodeHandle.addContextMenuItem)', () => {
|
||||
it.todo(
|
||||
'v1 prototype.getExtraMenuOptions items and v2 addContextMenuItem items both render in the same menu section'
|
||||
)
|
||||
it.todo(
|
||||
'migrating from prototype patch removes the need to manually chain prior implementations'
|
||||
)
|
||||
})
|
||||
|
||||
describe('canvas menu parity (S1.H4 → app.addCanvasMenuItem)', () => {
|
||||
it.todo(
|
||||
'v1 getCanvasMenuItems item and v2 addCanvasMenuItem item both appear when right-clicking empty canvas'
|
||||
)
|
||||
})
|
||||
|
||||
describe('scope cleanup on dispose', () => {
|
||||
it.todo(
|
||||
'v1 menu items persist after extension unregisters; v2 items are removed on dispose'
|
||||
)
|
||||
it.todo(
|
||||
'v2 item removal on dispose does not affect items contributed by other extensions'
|
||||
)
|
||||
})
|
||||
})
|
||||
48
src/extension-api-v2/__tests__/bc-22.v1.test.ts
Normal file
48
src/extension-api-v2/__tests__/bc-22.v1.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Category: BC.22 — Context menu contributions (node and canvas)
|
||||
// DB cross-ref: S2.N5, S1.H3, S1.H4
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// blast_radius: 5.10
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1 node: app.registerExtension({ getNodeMenuItems(value, options) { return [{ content: 'My Item', callback: fn }] } })
|
||||
// or node.prototype.getExtraMenuOptions = function(...) { return [...] }
|
||||
// v1 canvas: app.registerExtension({ getCanvasMenuItems() { return [{ content: 'Canvas Option', callback: fn }] } })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.22 v1 contract — Context menu contributions (node and canvas)', () => {
|
||||
describe('S1.H3 — getNodeMenuItems hook', () => {
|
||||
it.todo(
|
||||
'extension returning items from getNodeMenuItems appends them to the node right-click menu'
|
||||
)
|
||||
it.todo(
|
||||
'getNodeMenuItems receives (value, options) where options.node is the right-clicked LGraph node'
|
||||
)
|
||||
it.todo(
|
||||
'returning null or undefined from getNodeMenuItems does not break the menu'
|
||||
)
|
||||
it.todo(
|
||||
'multiple extensions contributing node menu items all appear in the same context menu'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N5 — prototype patch getExtraMenuOptions', () => {
|
||||
it.todo(
|
||||
'assigning node.prototype.getExtraMenuOptions appends extra items to the node context menu'
|
||||
)
|
||||
it.todo(
|
||||
'prototype-patched getExtraMenuOptions receives (app, options) and its items are merged after built-ins'
|
||||
)
|
||||
it.todo(
|
||||
'multiple prototype patches chain correctly without overwriting each other'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S1.H4 — getCanvasMenuItems hook', () => {
|
||||
it.todo(
|
||||
'extension returning items from getCanvasMenuItems appends them to the canvas right-click menu'
|
||||
)
|
||||
it.todo(
|
||||
'getCanvasMenuItems items appear only when no node is right-clicked'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-22.v2.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-22.v2.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Category: BC.22 — Context menu contributions (node and canvas)
|
||||
// DB cross-ref: S2.N5, S1.H3, S1.H4
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// blast_radius: 5.10
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 replacement: NodeHandle.addContextMenuItem(opts), app.addCanvasMenuItem(opts)
|
||||
// registered items removed on extension dispose
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.22 v2 contract — Context menu contributions (node and canvas)', () => {
|
||||
describe('NodeHandle.addContextMenuItem() — node-scoped menu items', () => {
|
||||
it.todo(
|
||||
'NodeHandle.addContextMenuItem({ label, action }) appends the item to that node\'s right-click menu'
|
||||
)
|
||||
it.todo(
|
||||
'action callback receives a MenuItemContext with the target NodeHandle'
|
||||
)
|
||||
it.todo(
|
||||
'addContextMenuItem returns a disposable; calling it removes only that item'
|
||||
)
|
||||
it.todo(
|
||||
'item added via addContextMenuItem is removed automatically when the extension scope is disposed'
|
||||
)
|
||||
})
|
||||
|
||||
describe('app.addCanvasMenuItem() — canvas-scoped menu items', () => {
|
||||
it.todo(
|
||||
'app.addCanvasMenuItem({ label, action }) appends the item to the canvas right-click menu'
|
||||
)
|
||||
it.todo(
|
||||
'canvas menu item is visible only when right-clicking empty canvas (no node hit)'
|
||||
)
|
||||
it.todo(
|
||||
'canvas menu item is removed when the extension scope is disposed'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-23.migration.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-23.migration.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Category: BC.23 — Node property bag mutations
|
||||
// DB cross-ref: S2.N18
|
||||
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78
|
||||
// blast_radius: 5.82
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// Migration: v1 onPropertyChanged prototype patch / node.properties direct write
|
||||
// → v2 NodeHandle.on('propertyChanged') / NodeHandle.setProperty
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.23 migration — Node property bag mutations', () => {
|
||||
describe('observer parity (S2.N18)', () => {
|
||||
it.todo(
|
||||
'v1 onPropertyChanged and v2 propertyChanged listener both receive identical (name, value, prevValue) for the same mutation'
|
||||
)
|
||||
it.todo(
|
||||
'v2 listener fires for writes made via NodeHandle.setProperty; v1 hook fires for the same via native property set path'
|
||||
)
|
||||
})
|
||||
|
||||
describe('persistence parity', () => {
|
||||
it.todo(
|
||||
'property written via v1 node.properties.myKey and v2 NodeHandle.setProperty both round-trip through JSON serialization identically'
|
||||
)
|
||||
it.todo(
|
||||
'property survives node.clone() in both v1 and v2 paths'
|
||||
)
|
||||
})
|
||||
|
||||
describe('scope cleanup on dispose', () => {
|
||||
it.todo(
|
||||
'v1 prototype.onPropertyChanged persists after extension unregisters; v2 listener is removed on dispose'
|
||||
)
|
||||
it.todo(
|
||||
'v2 listener removal on dispose does not silence listeners registered by other extensions on the same node'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-23.v1.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-23.v1.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Category: BC.23 — Node property bag mutations
|
||||
// DB cross-ref: S2.N18
|
||||
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78
|
||||
// blast_radius: 5.82
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1: node.prototype.onPropertyChanged = function(name, value, prevValue) { ... }
|
||||
// or node.properties.myKey = value
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.23 v1 contract — Node property bag mutations', () => {
|
||||
describe('S2.N18 — onPropertyChanged lifecycle hook', () => {
|
||||
it.todo(
|
||||
'assigning node.prototype.onPropertyChanged wires a callback invoked when any property value changes'
|
||||
)
|
||||
it.todo(
|
||||
'onPropertyChanged receives (name, value, prevValue) with correct types for each argument'
|
||||
)
|
||||
it.todo(
|
||||
'onPropertyChanged is NOT called for properties set before the node is created'
|
||||
)
|
||||
it.todo(
|
||||
'multiple prototype patches to onPropertyChanged: later patch overwrites earlier unless manually chained'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N18 — direct node.properties mutation', () => {
|
||||
it.todo(
|
||||
'setting node.properties.myKey = value persists the value through graph serialization and deserialization'
|
||||
)
|
||||
it.todo(
|
||||
'direct property mutation does not automatically trigger onPropertyChanged'
|
||||
)
|
||||
it.todo(
|
||||
'properties bag survives node clone (node.clone() copies node.properties by value)'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-23.v2.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-23.v2.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Category: BC.23 — Node property bag mutations
|
||||
// DB cross-ref: S2.N18
|
||||
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78
|
||||
// blast_radius: 5.82
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 replacement: NodeHandle.on('propertyChanged', (name, value, prevValue) => { ... })
|
||||
// NodeHandle.setProperty(name, value)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.23 v2 contract — Node property bag mutations', () => {
|
||||
describe('NodeHandle.on(\'propertyChanged\') — reactive property observation', () => {
|
||||
it.todo(
|
||||
'NodeHandle.on(\'propertyChanged\', cb) fires cb with (name, value, prevValue) on every property write'
|
||||
)
|
||||
it.todo(
|
||||
'propertyChanged event fires for mutations made via both NodeHandle.setProperty and direct node.properties writes'
|
||||
)
|
||||
it.todo(
|
||||
'multiple listeners on the same node all receive the event independently'
|
||||
)
|
||||
it.todo(
|
||||
'listener registered via NodeHandle.on is removed when the extension scope is disposed'
|
||||
)
|
||||
})
|
||||
|
||||
describe('NodeHandle.setProperty() — managed property mutation', () => {
|
||||
it.todo(
|
||||
'NodeHandle.setProperty(name, value) updates node.properties[name] and triggers propertyChanged listeners'
|
||||
)
|
||||
it.todo(
|
||||
'value set via setProperty survives graph serialization and deserialization'
|
||||
)
|
||||
it.todo(
|
||||
'setProperty with the same value as current does not fire propertyChanged (no-op guard)'
|
||||
)
|
||||
})
|
||||
})
|
||||
37
src/extension-api-v2/__tests__/bc-24.migration.test.ts
Normal file
37
src/extension-api-v2/__tests__/bc-24.migration.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Category: BC.24 — Node-def schema inspection
|
||||
// DB cross-ref: S13.SC1
|
||||
// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1
|
||||
// blast_radius: 5.00
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// Migration: v1 raw nodeData property access → v2 NodeHandle.def / NodeHandle.inputDefs / NodeHandle.outputDefs
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.24 migration — Node-def schema inspection', () => {
|
||||
describe('input schema parity (S13.SC1)', () => {
|
||||
it.todo(
|
||||
'v1 nodeData.input.required and v2 NodeHandle.def.input.required contain identical keys for the same node type'
|
||||
)
|
||||
it.todo(
|
||||
'v1 InputSpec tuple first element and v2 InputDef.type are equal strings for every slot'
|
||||
)
|
||||
it.todo(
|
||||
'v1 nodeData.input.optional and v2 NodeHandle.def.input.optional both reflect server-provided optional inputs'
|
||||
)
|
||||
})
|
||||
|
||||
describe('output schema parity', () => {
|
||||
it.todo(
|
||||
'v1 nodeData.output array and v2 NodeHandle.def.output have the same length and type strings in slot order'
|
||||
)
|
||||
it.todo(
|
||||
'v1 nodeData.output_node and v2 NodeHandle.def.output_node are the same boolean value'
|
||||
)
|
||||
})
|
||||
|
||||
describe('category parity', () => {
|
||||
it.todo(
|
||||
'v1 nodeData.category and v2 NodeHandle.def.category are identical strings for the same node type'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-24.v1.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-24.v1.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Category: BC.24 — Node-def schema inspection
|
||||
// DB cross-ref: S13.SC1
|
||||
// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1
|
||||
// blast_radius: 5.00
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1: direct inspection of nodeData.input.required, nodeData.input.optional, nodeData.output,
|
||||
// nodeData.output_node, nodeData.category, InputSpec sentinel tuples
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.24 v1 contract — Node-def schema inspection', () => {
|
||||
describe('S13.SC1 — input slot inspection', () => {
|
||||
it.todo(
|
||||
'nodeData.input.required is an object mapping slot names to InputSpec tuples [type, opts?]'
|
||||
)
|
||||
it.todo(
|
||||
'nodeData.input.optional is an object mapping slot names to InputSpec tuples and may be undefined'
|
||||
)
|
||||
it.todo(
|
||||
'nodeData.input.hidden is an object or undefined; hidden inputs do not appear in the node UI'
|
||||
)
|
||||
it.todo(
|
||||
'InputSpec tuple first element is a string type name or array of enum values'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S13.SC1 — output slot inspection', () => {
|
||||
it.todo(
|
||||
'nodeData.output is an array of output type name strings in slot order'
|
||||
)
|
||||
it.todo(
|
||||
'nodeData.output_node is a boolean indicating whether this node routes data to the server output'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S13.SC1 — category inspection', () => {
|
||||
it.todo(
|
||||
'nodeData.category is a slash-delimited string used to place the node in the Add Node menu hierarchy'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-24.v2.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-24.v2.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Category: BC.24 — Node-def schema inspection
|
||||
// DB cross-ref: S13.SC1
|
||||
// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1
|
||||
// blast_radius: 5.00
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 replacement: NodeHandle.def — typed ComfyNodeDef shape with same fields but typed accessors
|
||||
// NodeHandle.inputDefs, NodeHandle.outputDefs
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.24 v2 contract — Node-def schema inspection', () => {
|
||||
describe('NodeHandle.def — typed ComfyNodeDef accessor', () => {
|
||||
it.todo(
|
||||
'NodeHandle.def.input.required is a typed Record<string, InputDef> mirroring the v1 shape'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.def.input.optional is a typed Record<string, InputDef> or undefined'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.def.output is a typed readonly array of OutputDef in slot order'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.def.output_node is a boolean identical to the server-provided value'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.def.category is the slash-delimited category string'
|
||||
)
|
||||
})
|
||||
|
||||
describe('NodeHandle.inputDefs — convenience accessor', () => {
|
||||
it.todo(
|
||||
'NodeHandle.inputDefs returns a flat array merging required and optional inputs with a slot-order index'
|
||||
)
|
||||
it.todo(
|
||||
'each InputDef entry exposes .name, .type, .required, and .options fields'
|
||||
)
|
||||
})
|
||||
|
||||
describe('NodeHandle.outputDefs — convenience accessor', () => {
|
||||
it.todo(
|
||||
'NodeHandle.outputDefs returns an array of OutputDef with .name, .type, and .index fields'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-25.migration.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-25.migration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Category: BC.25 — Shell UI registration (commands, sidebars, toasts)
|
||||
// DB cross-ref: S12.UI1
|
||||
// Exemplar: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269
|
||||
// blast_radius: 4.02
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// Migration: v1 extensionManager / commandManager / toastManager imports
|
||||
// → v2 comfyApp.registerSidebarTab / registerCommand / showToast (stable import path)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.25 migration — Shell UI registration (commands, sidebars, toasts)', () => {
|
||||
describe('sidebar tab parity (S12.UI1)', () => {
|
||||
it.todo(
|
||||
'v1 extensionManager.registerSidebarTab and v2 comfyApp.registerSidebarTab both result in a visible tab with equivalent id and title'
|
||||
)
|
||||
it.todo(
|
||||
'v2 tab render context provides the same root element accessible in v1 raw render callback'
|
||||
)
|
||||
})
|
||||
|
||||
describe('command parity', () => {
|
||||
it.todo(
|
||||
'command registered via v1 commandManager.registerCommand and v2 comfyApp.registerCommand are both invocable by the same id'
|
||||
)
|
||||
it.todo(
|
||||
'execute/function callback receives equivalent context objects in v1 and v2'
|
||||
)
|
||||
})
|
||||
|
||||
describe('toast parity', () => {
|
||||
it.todo(
|
||||
'v1 toastManager.add and v2 comfyApp.showToast both display a notification with the same severity and summary text'
|
||||
)
|
||||
it.todo(
|
||||
'auto-dismiss timing is equivalent between v1 life and v2 life options'
|
||||
)
|
||||
})
|
||||
|
||||
describe('scope cleanup on dispose', () => {
|
||||
it.todo(
|
||||
'v1 sidebar tabs and commands persist after extension unregisters; v2 contributions are removed on dispose'
|
||||
)
|
||||
})
|
||||
})
|
||||
52
src/extension-api-v2/__tests__/bc-25.v1.test.ts
Normal file
52
src/extension-api-v2/__tests__/bc-25.v1.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Category: BC.25 — Shell UI registration (commands, sidebars, toasts)
|
||||
// DB cross-ref: S12.UI1
|
||||
// Exemplar: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269
|
||||
// blast_radius: 4.02
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1: app.registerExtension({ settings: [...] })
|
||||
// extensionManager.registerSidebarTab(opts)
|
||||
// commandManager.registerCommand(opts)
|
||||
// toastManager.add(opts)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.25 v1 contract — Shell UI registration (commands, sidebars, toasts)', () => {
|
||||
describe('S12.UI1 — settings registration', () => {
|
||||
it.todo(
|
||||
'extension passing a settings array to registerExtension adds each setting to the ComfyUI settings panel'
|
||||
)
|
||||
it.todo(
|
||||
'registered setting value is readable via app.ui.settings.getSettingValue(id) after registration'
|
||||
)
|
||||
it.todo(
|
||||
'setting onChange callback fires when the user changes the value in the settings panel'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S12.UI1 — sidebar tab registration', () => {
|
||||
it.todo(
|
||||
'extensionManager.registerSidebarTab({ id, icon, title, render }) adds a tab to the sidebar'
|
||||
)
|
||||
it.todo(
|
||||
'render function is called with the tab container element when the tab is first activated'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S12.UI1 — command registration', () => {
|
||||
it.todo(
|
||||
'commandManager.registerCommand({ id, label, function }) makes the command invocable by id'
|
||||
)
|
||||
it.todo(
|
||||
'registered command appears in the command palette UI'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S12.UI1 — toast notifications', () => {
|
||||
it.todo(
|
||||
'toastManager.add({ severity, summary, detail }) displays a toast notification in the UI'
|
||||
)
|
||||
it.todo(
|
||||
'toast with a specified life value auto-dismisses after the given number of milliseconds'
|
||||
)
|
||||
})
|
||||
})
|
||||
48
src/extension-api-v2/__tests__/bc-25.v2.test.ts
Normal file
48
src/extension-api-v2/__tests__/bc-25.v2.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Category: BC.25 — Shell UI registration (commands, sidebars, toasts)
|
||||
// DB cross-ref: S12.UI1
|
||||
// Exemplar: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269
|
||||
// blast_radius: 4.02
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 replacement: same APIs stabilized — comfyApp.registerSidebarTab(opts),
|
||||
// comfyApp.registerCommand(opts), comfyApp.showToast(opts)
|
||||
// consistent import path from @comfyorg/extension-api
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.25 v2 contract — Shell UI registration (commands, sidebars, toasts)', () => {
|
||||
describe('comfyApp.registerSidebarTab() — stabilized sidebar API', () => {
|
||||
it.todo(
|
||||
'comfyApp.registerSidebarTab({ id, icon, title, render }) adds a tab accessible in the sidebar'
|
||||
)
|
||||
it.todo(
|
||||
'sidebar tab registered via comfyApp is removed when the extension scope is disposed'
|
||||
)
|
||||
it.todo(
|
||||
'render receives a typed SidebarTabContext instead of a raw DOM element'
|
||||
)
|
||||
})
|
||||
|
||||
describe('comfyApp.registerCommand() — stabilized command API', () => {
|
||||
it.todo(
|
||||
'comfyApp.registerCommand({ id, label, execute }) makes the command invocable by id'
|
||||
)
|
||||
it.todo(
|
||||
'command appears in the command palette with the provided label'
|
||||
)
|
||||
it.todo(
|
||||
'command is unregistered when the extension scope is disposed'
|
||||
)
|
||||
})
|
||||
|
||||
describe('comfyApp.showToast() — stabilized toast API', () => {
|
||||
it.todo(
|
||||
'comfyApp.showToast({ severity, summary, detail }) displays a toast notification'
|
||||
)
|
||||
it.todo(
|
||||
'showToast with life option auto-dismisses after the specified duration'
|
||||
)
|
||||
it.todo(
|
||||
'showToast returns a handle with a dismiss() method for programmatic removal'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-26.migration.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-26.migration.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Category: BC.26 — Globals as ABI (window.LiteGraph, window.comfyAPI)
|
||||
// DB cross-ref: S7.G1
|
||||
// Exemplar: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1
|
||||
// blast_radius: 4.55
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// Migration: v1 window.LiteGraph / window.comfyAPI / window.app access
|
||||
// → v2 explicit named imports from @comfyorg/extension-api
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.26 migration — Globals as ABI (window.LiteGraph, window.comfyAPI)', () => {
|
||||
describe('LiteGraph reference parity (S7.G1)', () => {
|
||||
it.todo(
|
||||
'window.LiteGraph.LGraphNode and the named import LGraphNode from @comfyorg/extension-api are the same constructor reference'
|
||||
)
|
||||
it.todo(
|
||||
'a node registered via window.LiteGraph.registerNodeType is identical to one registered via the v2 import path'
|
||||
)
|
||||
it.todo(
|
||||
'LiteGraph enum values accessed via window and via import are strictly equal (===)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('comfyAPI / comfyApp reference parity', () => {
|
||||
it.todo(
|
||||
'window.app and the imported comfyApp share the same graph state — mutations via one are visible on the other'
|
||||
)
|
||||
it.todo(
|
||||
'window.comfyAPI.modules.extensionService and imported extensionManager refer to the same instance'
|
||||
)
|
||||
})
|
||||
|
||||
describe('deprecation signal migration', () => {
|
||||
it.todo(
|
||||
'replacing window.LiteGraph access with named imports removes all deprecation console warnings'
|
||||
)
|
||||
it.todo(
|
||||
'replacing window.comfyAPI access with named imports removes all deprecation console warnings'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-26.v1.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-26.v1.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Category: BC.26 — Globals as ABI (window.LiteGraph, window.comfyAPI)
|
||||
// DB cross-ref: S7.G1
|
||||
// Exemplar: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1
|
||||
// blast_radius: 4.55
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1: window.LiteGraph.registerNodeType(...), window.comfyAPI.modules.extensionService, window.app
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.26 v1 contract — Globals as ABI (window.LiteGraph, window.comfyAPI)', () => {
|
||||
describe('S7.G1 — window.LiteGraph global usage', () => {
|
||||
it.todo(
|
||||
'window.LiteGraph is defined and exposes registerNodeType, LGraph, LGraphNode, and LLink constructors'
|
||||
)
|
||||
it.todo(
|
||||
'window.LiteGraph.registerNodeType(type, ctor) registers a custom node type visible in the Add Node menu'
|
||||
)
|
||||
it.todo(
|
||||
'LiteGraph enum constants (e.g. LiteGraph.INPUT, LiteGraph.OUTPUT) are accessible via window.LiteGraph'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S7.G1 — window.comfyAPI global registry', () => {
|
||||
it.todo(
|
||||
'window.comfyAPI is defined after the app boots and exposes a modules sub-object'
|
||||
)
|
||||
it.todo(
|
||||
'window.comfyAPI.modules.extensionService references the active extensionManager instance'
|
||||
)
|
||||
it.todo(
|
||||
'services accessed via window.comfyAPI.modules are the same objects as those available via ES module import'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S7.G1 — window.app global', () => {
|
||||
it.todo(
|
||||
'window.app is defined and is the same object as the app instance passed to extension hooks'
|
||||
)
|
||||
it.todo(
|
||||
'mutations made to the graph via window.app are reflected in the live canvas immediately'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-26.v2.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-26.v2.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Category: BC.26 — Globals as ABI (window.LiteGraph, window.comfyAPI)
|
||||
// DB cross-ref: S7.G1
|
||||
// Exemplar: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1
|
||||
// blast_radius: 4.55
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 replacement: explicit imports from @comfyorg/extension-api
|
||||
// globals still exported for compat shim but deprecated
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.26 v2 contract — Globals as ABI (window.LiteGraph, window.comfyAPI)', () => {
|
||||
describe('explicit LiteGraph imports from @comfyorg/extension-api', () => {
|
||||
it.todo(
|
||||
'LGraph, LGraphNode, LLink are importable by name from @comfyorg/extension-api'
|
||||
)
|
||||
it.todo(
|
||||
'LiteGraph enum constants (INPUT, OUTPUT, etc.) are importable as named exports'
|
||||
)
|
||||
it.todo(
|
||||
'imported constructors are the same references as window.LiteGraph equivalents during the compat shim window'
|
||||
)
|
||||
})
|
||||
|
||||
describe('explicit comfyApp / service imports', () => {
|
||||
it.todo(
|
||||
'comfyApp is importable from @comfyorg/extension-api and is the same instance as window.app'
|
||||
)
|
||||
it.todo(
|
||||
'extensionManager is importable from @comfyorg/extension-api and is the same instance as window.comfyAPI.modules.extensionService'
|
||||
)
|
||||
})
|
||||
|
||||
describe('compat shim deprecation', () => {
|
||||
it.todo(
|
||||
'accessing window.LiteGraph in v2 mode emits a deprecation warning to the console'
|
||||
)
|
||||
it.todo(
|
||||
'accessing window.comfyAPI in v2 mode emits a deprecation warning to the console'
|
||||
)
|
||||
it.todo(
|
||||
'compat shim globals are still functional (not removed) so v1 extensions continue working during migration window'
|
||||
)
|
||||
})
|
||||
})
|
||||
46
src/extension-api-v2/__tests__/bc-27.migration.test.ts
Normal file
46
src/extension-api-v2/__tests__/bc-27.migration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Category: BC.27 — LiteGraph entity direct manipulation (reroute, group, link, slot)
|
||||
// DB cross-ref: S9.R1, S9.G1, S9.L1, S9.S1
|
||||
// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
|
||||
// blast_radius: 5.62
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// migration: direct raw object mutations → read-only v2 accessors (mutations deferred to D9 Phase C)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.27 migration — LiteGraph entity direct manipulation', () => {
|
||||
describe('reroute migration', () => {
|
||||
it.todo(
|
||||
'v1 graph.reroutes raw access is replaced by comfyApp.graph.reroutes iterable'
|
||||
)
|
||||
it.todo(
|
||||
'v1 direct position mutation (graph.reroutes[id].pos = [...]) has no v2 equivalent until D9 Phase C'
|
||||
)
|
||||
})
|
||||
|
||||
describe('group migration', () => {
|
||||
it.todo(
|
||||
'v1 graph.groups[i].title mutation is replaced by a future GroupHandle.setTitle() (D9 Phase C)'
|
||||
)
|
||||
it.todo(
|
||||
'v1 graph.groups iteration is replaced by comfyApp.graph.groups read-only iterable'
|
||||
)
|
||||
})
|
||||
|
||||
describe('link migration', () => {
|
||||
it.todo(
|
||||
'v1 link.color direct assignment is replaced by a future LinkHandle.setColor() (D9 Phase C)'
|
||||
)
|
||||
it.todo(
|
||||
'v2 compat shim logs a deprecation warning when graph.links is accessed directly'
|
||||
)
|
||||
})
|
||||
|
||||
describe('slot migration', () => {
|
||||
it.todo(
|
||||
'v1 node.inputs[i].shape mutation has no v2 equivalent until D9 Phase C'
|
||||
)
|
||||
it.todo(
|
||||
'v2 compat shim throws a TypeError when slot mutation is attempted via legacy path'
|
||||
)
|
||||
})
|
||||
})
|
||||
52
src/extension-api-v2/__tests__/bc-27.v1.test.ts
Normal file
52
src/extension-api-v2/__tests__/bc-27.v1.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Category: BC.27 — LiteGraph entity direct manipulation (reroute, group, link, slot)
|
||||
// DB cross-ref: S9.R1, S9.G1, S9.L1, S9.S1
|
||||
// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
|
||||
// blast_radius: 5.62
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v1 contract: direct graph.reroutes, graph.groups, link.color, slot.shape mutations — no API, raw object access
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.27 v1 contract — LiteGraph entity direct manipulation', () => {
|
||||
describe('S9.R1 — reroute direct access', () => {
|
||||
it.todo(
|
||||
'extension can read graph.reroutes and iterate all reroute nodes in the graph'
|
||||
)
|
||||
it.todo(
|
||||
'extension can mutate reroute position directly via graph.reroutes[id].pos'
|
||||
)
|
||||
it.todo(
|
||||
'reroute additions via graph.reroutes[id] = { ... } are reflected in the rendered canvas'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.G1 — group direct access', () => {
|
||||
it.todo(
|
||||
'extension can read graph.groups and iterate all groups'
|
||||
)
|
||||
it.todo(
|
||||
'extension can mutate group title via graph.groups[i].title = string'
|
||||
)
|
||||
it.todo(
|
||||
'extension can mutate group bounding box via graph.groups[i].bounding'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.L1 — link direct access', () => {
|
||||
it.todo(
|
||||
'extension can read link.color and link.type directly from graph.links[id]'
|
||||
)
|
||||
it.todo(
|
||||
'setting link.color mutates the rendered link color without requiring graph refresh'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.S1 — slot direct access', () => {
|
||||
it.todo(
|
||||
'extension can read node.inputs[i].shape and node.outputs[i].shape directly'
|
||||
)
|
||||
it.todo(
|
||||
'extension can mutate slot.shape to change rendered connector shape'
|
||||
)
|
||||
})
|
||||
})
|
||||
50
src/extension-api-v2/__tests__/bc-27.v2.test.ts
Normal file
50
src/extension-api-v2/__tests__/bc-27.v2.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Category: BC.27 — LiteGraph entity direct manipulation (reroute, group, link, slot)
|
||||
// DB cross-ref: S9.R1, S9.G1, S9.L1, S9.S1
|
||||
// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
|
||||
// blast_radius: 5.62
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// v2 contract: partial — reroute/group/link read APIs planned; mutations deferred to D9 Phase C.
|
||||
// For now: read-only accessors
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.27 v2 contract — LiteGraph entity direct manipulation', () => {
|
||||
describe('S9.R1 — reroute read-only accessors', () => {
|
||||
it.todo(
|
||||
'comfyApp.graph.reroutes returns an iterable of read-only RerouteHandle objects'
|
||||
)
|
||||
it.todo(
|
||||
'RerouteHandle exposes id, pos, and linked link IDs as read-only properties'
|
||||
)
|
||||
it.todo(
|
||||
'attempting to mutate RerouteHandle.pos in v2 throws or is silently ignored (write-protect)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.G1 — group read-only accessors', () => {
|
||||
it.todo(
|
||||
'comfyApp.graph.groups returns an iterable of read-only GroupHandle objects'
|
||||
)
|
||||
it.todo(
|
||||
'GroupHandle exposes title and bounding as read-only (mutations deferred to D9 Phase C)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.L1 — link read-only accessors', () => {
|
||||
it.todo(
|
||||
'comfyApp.graph.links returns a Map<id, LinkHandle> with read-only color and type'
|
||||
)
|
||||
it.todo(
|
||||
'link mutation API is not available in v2 Phase A (deferred to D9 Phase C)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.S1 — slot read-only accessors', () => {
|
||||
it.todo(
|
||||
'NodeHandle.inputs and NodeHandle.outputs expose read-only SlotHandle with shape'
|
||||
)
|
||||
it.todo(
|
||||
'slot shape mutation is not available in v2 Phase A (deferred to D9 Phase C)'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-28.migration.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-28.migration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Category: BC.28 — Subgraph fan-out via set/get virtual nodes
|
||||
// DB cross-ref: S9.SG1
|
||||
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
|
||||
// blast_radius: 4.97
|
||||
// compat-floor: blast_radius ≥ 2.0
|
||||
// migration: isVirtualNode=true + graphToPrompt monkey-patch → defineNodeExtension({ virtual: true, resolveConnections })
|
||||
// Decision: I-UWF.5 (2026-05-08) — S8.P1 → virtual: true (mechanical rename); S9.SG1 → add resolveConnections.
|
||||
// Classified uwf-resolved per I-PG.B2 — UWF Phase 3 is the migration path.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.28 migration — subgraph fan-out via set/get virtual nodes', () => {
|
||||
describe('S8.P1 — isVirtualNode flag migration', () => {
|
||||
it.todo(
|
||||
'v1 class-level isVirtualNode=true is replaced by defineNodeExtension({ virtual: true, resolveConnections })'
|
||||
)
|
||||
it.todo(
|
||||
'v2 compat shim recognizes isVirtualNode=true on a registered class and emits a migration warning'
|
||||
)
|
||||
it.todo(
|
||||
'migration is mechanical: rename isVirtualNode=true to virtual: true and add resolveConnections stub'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S9.SG1 — graphToPrompt monkey-patch migration', () => {
|
||||
it.todo(
|
||||
'v1 graphToPrompt patch that rewrites link.target_id is replaced by resolveConnections returning ResolvedEdges'
|
||||
)
|
||||
it.todo(
|
||||
'v2 resolveConnections receives the same graph state that v1 graphToPrompt received, as a read-only view'
|
||||
)
|
||||
it.todo(
|
||||
'v2 compat shim logs a deprecation warning when graphToPrompt is monkey-patched for virtual node resolution'
|
||||
)
|
||||
it.todo(
|
||||
'for cg-use-everywhere topology inference (graph-wide, not per-type): ctx.on("beforePrompt") is the bridge until UWF Phase 3'
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user