docs: 新しいノード2.0互換性に関するドキュメントを追加し、テストケースを更新

This commit is contained in:
るみなー先生
2026-11-12 18:55:44 +09:00
parent ffb8f1e32f
commit e479498c59
2 changed files with 155 additions and 6 deletions
+77
View File
@@ -0,0 +1,77 @@
# Nodes 2.0 compatibility
## Scope
This repository supports both the Legacy Nodes renderer and Nodes 2.0 without
migrating the Python nodes to the V3 API.
The compatibility boundary is intentionally limited to the existing node
contracts and frontend extension behavior. Node identifiers, input and output
names, widget serialization, and Python execution behavior must remain stable.
## Official implementation checked
The current ComfyUI frontend main branch (package version 1.61.0) was checked
on 2026-11-12:
- Nodes 2.0 uses a Vue-based renderer while the Legacy renderer remains
selectable.
- Execution results are resolved to the target LiteGraph node and delivered by
calling `node.onExecuted(output)`.
- Widgets created with `node.addDOMWidget(...)` are registered in the widget
store used by Nodes 2.0.
- `node.widgets` may be undefined, so reads must remain guarded.
- Legacy `hidden` and `type = "hidden"` widget visibility writes are bridged to
the current visibility state.
References:
- https://docs.comfy.org/interface/nodes-2
- https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/scripts/app.ts
- https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/extensions/widgets-migration.md
- https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/scripts/domWidget.ts
- https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/widgets/BaseWidget.ts
## Compatibility decisions
| Concern | Existing implementation | Decision |
| --- | --- | --- |
| Execution title | Wraps each node type's `onExecuted` and assigns `node.title` | Keep unchanged; this is the shared Legacy/Nodes 2.0 path |
| Runtime editor | Uses a uniquely named `addDOMWidget` with `getValue` and `setValue` | Keep unchanged; it participates in the Nodes 2.0 widget store |
| Saved Runtime inputs | Keeps `options_text` and `state_key` serialized while hiding their built-in widgets | Keep unchanged to preserve workflow data and backend inputs |
| Widget lookup | Uses `node.widgets?.find(...)` | Keep unchanged; the undefined case is guarded |
| Runtime background tint | Uses `onDrawBackground` | Retain as a Legacy-only cosmetic decoration; do not write Vue internals or persistent node colors |
| Python node API | Uses `NODE_CLASS_MAPPINGS` and the existing class API | Keep V1 API; no V3 migration |
The Runtime editor, synchronization, selection behavior, inputs, and outputs do
not depend on the Legacy-only background tint.
## Stable workflow contracts
The following node identifiers remain unchanged:
- `PromptRandomChoice`
- `PromptRandomChoiceEx`
- `RuntimePromptRandomChoice`
- `RuntimePromptRandomChoiceEx`
- `SafeRandomSeed`
The existing Python regression tests lock the Runtime input names and all node
mapping identifiers. The frontend regression tests lock the shared
`onExecuted` title path and the supported DOM widget registration path.
## Verification boundary
Automated checks cover Python behavior, mapping and input contracts, JavaScript
syntax, Runtime DOM widget layout options, and the frontend compatibility paths
listed above.
The following require manual verification in a real ComfyUI installation:
- Existing workflow load and execution with Legacy Nodes
- Existing workflow load and execution with Nodes 2.0
- Title updates after execution in both renderers
- Runtime editor display, editing, resize, save/reload, and server sync in both
renderers
- Switching the same workflow between Legacy Nodes and Nodes 2.0
- Runtime state behavior during queued execution
+78 -6
View File
@@ -2,15 +2,87 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(
const runtimeSource = readFileSync(
new URL("../web/runtime_prompt_random_choice.js", import.meta.url),
"utf8",
);
const titleSource = readFileSync(
new URL("../web/prompt_random_choice.js", import.meta.url),
"utf8",
);
function loadTitleExtension() {
let extension;
const app = {
graph: { setDirtyCanvas() {} },
registerExtension(value) {
extension = value;
},
};
const executableSource = titleSource.replace(/^import .*;\r?\n/m, "");
Function("app", executableSource)(app);
return extension;
}
test("Runtime editor uses resizable DOM widget layout options", () => {
assert.doesNotMatch(source, /domWidget\.computeSize\s*=/);
assert.match(source, /getMinHeight:\s*\(\)\s*=>\s*96/);
assert.match(source, /getHeight:\s*\(\)\s*=>\s*"100%"/);
assert.match(source, /afterResize:/);
assert.match(source, /flex:\s*"1 1 auto"/);
assert.doesNotMatch(runtimeSource, /domWidget\.computeSize\s*=/);
assert.match(runtimeSource, /getMinHeight:\s*\(\)\s*=>\s*96/);
assert.match(runtimeSource, /getHeight:\s*\(\)\s*=>\s*"100%"/);
assert.match(runtimeSource, /afterResize:/);
assert.match(runtimeSource, /flex:\s*"1 1 auto"/);
});
test("Runtime editor uses the supported DOM widget compatibility path", () => {
assert.match(runtimeSource, /node\.widgets\?\.find/);
assert.match(runtimeSource, /node\.addDOMWidget\(/);
assert.match(runtimeSource, /"runtime_candidate_editor"/);
assert.match(runtimeSource, /serialize:\s*false/);
assert.match(runtimeSource, /widget\.type\s*=\s*"hidden"/);
assert.match(runtimeSource, /widget\.hidden\s*=\s*true/);
assert.doesNotMatch(runtimeSource, /useWidgetValueStore/);
});
test("Execution titles are updated through the node onExecuted callback", () => {
assert.match(
titleSource,
/const originalOnExecuted = nodeType\.prototype\.onExecuted/,
);
assert.match(
titleSource,
/nodeType\.prototype\.onExecuted = function \(message\)/,
);
assert.match(titleSource, /originalOnExecuted\?\.apply\(this, arguments\)/);
assert.match(titleSource, /this\.title\s*=/);
assert.doesNotMatch(titleSource, /api\.addEventListener\(\s*["']executed/);
assert.doesNotMatch(titleSource, /Number\(\s*nodeId\s*\)/);
});
test("Execution title callback preserves existing handlers and node names", () => {
const cases = [
["PromptRandomChoice", "Ch: blonde hair (2/3)"],
["PromptRandomChoiceEx", "Ex: blonde hair (2/3)"],
["RuntimePromptRandomChoice", "Rt: blonde hair (2/3)"],
["RuntimePromptRandomChoiceEx", "RtEx: blonde hair (2/3)"],
["SafeRandomSeed", "Seed: 123"],
];
for (const [nodeName, expectedTitle] of cases) {
const extension = loadTitleExtension();
let originalCalls = 0;
class NodeType {}
NodeType.prototype.onExecuted = function () {
originalCalls += 1;
};
extension.beforeRegisterNodeDef(NodeType, { name: nodeName });
const node = new NodeType();
node.onExecuted({
selected_text_title: ["blonde hair"],
repeat_index: [2],
change_every: [3],
seed_title: ["Seed: 123"],
});
assert.equal(originalCalls, 1);
assert.equal(node.title, expectedTitle);
}
});