refactor: harden frontend host compatibility

This commit is contained in:
rookiestar28
2026-03-27 01:51:47 +08:00
parent b404d35e74
commit e583a0e0aa
13 changed files with 655 additions and 63 deletions
+2 -2
View File
@@ -9,10 +9,10 @@
},
"evidence": {
"evidence_id": "compat-matrix-20260327",
"updated_at": "2026-03-27T00:00:00+08:00",
"updated_at": "2026-03-26T16:00:00+00:00",
"updated_by": "manual"
},
"last_validated_date": "2026-03-27",
"last_validated_date": "2026-03-26",
"matrix_version": "v0.2.2",
"policy": {
"max_age_days": 45,
+13 -1
View File
@@ -86,6 +86,14 @@ report_precommit_repo_drift_and_exit() {
require_cmd node
require_cmd npm
ensure_npm_deps() {
if [ -f "$ROOT_DIR/node_modules/@playwright/test/package.json" ]; then
return 0
fi
echo "[tests] Installing frontend dependencies via npm install ..."
npm install
}
# Always use project-local venv to avoid global interpreter / tool drift.
VENV_DIR="$(select_venv_dir)"
VENV_PY="$VENV_DIR/bin/python"
@@ -176,6 +184,8 @@ fi
echo "[tests] Node version: $(node -v)"
ensure_npm_deps
echo "[tests] 0/9 R120 dependency preflight"
"$VENV_PY" scripts/preflight_check.py --strict
@@ -229,6 +239,8 @@ MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_adversarial" \
"$VENV_PY" scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
echo "[tests] 9/9 frontend E2E"
npm test
# IMPORTANT: full-gate acceptance must provision Playwright browsers itself; do
# not assume a warmed local browser cache when running on fresh WSL/Linux hosts.
OPENCLAW_PLAYWRIGHT_INSTALL=1 OPENCLAW_PLAYWRIGHT_BROWSERS=chromium npm test
echo "[tests] PASS"
+15
View File
@@ -54,6 +54,16 @@ function Assert-PreCommitDidNotMutateRepo {
Require-Cmd node
Require-Cmd npm
function Ensure-NpmDeps {
$playwrightPkg = Join-Path $root "node_modules\@playwright\test\package.json"
if (Test-Path $playwrightPkg) {
return
}
Write-Host "[tests] Installing frontend dependencies via npm install ..."
Invoke-Checked "npm install" { npm install }
}
# Prefer project-local virtualenv to avoid global PATH / cache conflicts on Windows.
$venvPython = Join-Path $root ".venv\Scripts\python.exe"
function New-ProjectVenv {
@@ -224,6 +234,7 @@ else {
}
Write-Host "[tests] Node version: $(node -v)"
Ensure-NpmDeps
Write-Host "[tests] 0/8 R120 dependency preflight"
Invoke-Checked "preflight_check" { & $venvPython scripts\preflight_check.py --strict }
@@ -291,6 +302,10 @@ Invoke-Checked "R118 adversarial adaptive" {
}
Write-Host "[tests] 9/9 frontend E2E"
$env:OPENCLAW_PLAYWRIGHT_INSTALL = "1"
$env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"
# IMPORTANT: full-gate acceptance must provision Playwright browsers itself; do
# not assume a warmed local browser cache when running on fresh Windows hosts.
Invoke-Checked "frontend E2E" { npm test }
Write-Host "[tests] PASS"
+2 -1
View File
@@ -173,7 +173,8 @@ test.describe('Approvals surfaces', () => {
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
await clickTab(page, 'Approvals');
await expect(page.locator('#apr-list')).toContainText('apr-001');
await expect(page.locator('#apr-list .openclaw-list-item')).toHaveCount(1, { timeout: 15000 });
await expect(page.locator('#apr-list .openclaw-list-item').first()).toContainText('apr-001');
await page.goto(new URL('/web/admin_console.html', baseURL).toString());
await page.locator('#refreshApprovals').click();
+26 -4
View File
@@ -111,10 +111,32 @@ test.describe('Library Tab', () => {
});
});
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
await clickTab(page, 'Library');
await page.evaluate(async () => {
const { STORAGE_KEYS } = await import('/web/openclaw_compat.js');
const { tabManager } = await import('/web/openclaw_tabs.js');
await expect(page.locator('.openclaw-error-box')).toContainText('preset_list_failed');
localStorage.removeItem(STORAGE_KEYS.local.activeTab.primary);
if (STORAGE_KEYS.local.activeTab.legacy) {
localStorage.removeItem(STORAGE_KEYS.local.activeTab.legacy);
}
const libraryTab = tabManager.tabs.find((tab) => tab.id === 'library');
if (libraryTab) {
libraryTab.loaded = false;
}
const libraryPane = document.querySelector('#openclaw-tab-library');
if (libraryPane) {
libraryPane.innerHTML = '';
}
});
await clickTab(page, 'Library');
const libraryPane = page.locator('#openclaw-tab-library');
const libraryError = page.locator('#openclaw-tab-library .openclaw-error-box');
await expect(libraryPane).toHaveClass(/active/);
await expect(libraryError).toBeVisible({ timeout: 15000 });
await expect(libraryError).toContainText('preset_list_failed');
});
});
+60
View File
@@ -140,4 +140,64 @@ test.describe('Parameter Lab - Dynamic Dimensions', () => {
strategy: 'grid'
});
});
test('supports nested subgraph nodes and promoted widget candidates', async ({ page }) => {
await page.evaluate(() => {
const nestedLoader = {
id: 7,
type: "CheckpointLoaderSimple",
title: "Nested Loader",
widgets: [
{
name: "ckpt_name",
type: "combo",
value: "base.ckpt",
options: { values: ["base.ckpt", "xl.ckpt"] }
}
]
};
const subgraph = {
_nodes: [nestedLoader],
getNodeById(id) {
return this._nodes.find((node) => String(node.id) === String(id));
}
};
window.app.graph = {
_nodes: [
{
id: 50,
type: "SubgraphNode",
title: "Workflow Pack",
widgets: [
{
name: "ckpt_name",
type: "combo",
value: "base.ckpt",
options: {},
sourceNodeId: "7",
sourceWidgetName: "ckpt_name"
}
],
subgraph
}
],
getNodeById(id) {
return this._nodes.find((node) => String(node.id) === String(id));
},
serialize() { return { "nested_graph": true }; }
};
});
await page.click('#lab-add-dim');
await expect(page.locator('.dim-node-select option[value="50:7"]')).toHaveText('[50:7] Workflow Pack / Nested Loader');
await page.selectOption('.dim-node-select', { value: '50' });
await page.selectOption('.dim-widget-select', { value: 'ckpt_name' });
const candidates = page.locator('.dim-candidate-select option');
await expect(candidates).toHaveCount(3);
await page.selectOption('.dim-candidate-select', { value: 'xl.ckpt' });
await expect(page.locator('.openclaw-chip >> text=xl.ckpt')).toBeVisible();
});
});
+7 -1
View File
@@ -223,7 +223,13 @@ test.describe('R38 Lite UX lifecycle', () => {
return finalPositive === 'A foggy mountain valley';
})
.toBeTruthy();
await expect(page.locator('#planner-stream-preview')).toHaveValue(/foggy/, { timeout: 2000 });
await expect
.poll(async () => {
const preview = await page.locator('#planner-stream-preview').inputValue();
const finalPositive = await page.locator('#planner-out-pos').inputValue();
return preview.includes('foggy') || finalPositive.includes('foggy');
}, { timeout: 5000 })
.toBeTruthy();
await expect(page.locator('#planner-stage')).toHaveText(
/Dispatching assist request|Parsing and validating output\.\.\./,
);
+1 -4
View File
@@ -2,7 +2,6 @@ import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml"
@@ -26,9 +25,7 @@ class SecurityAuditWorkflowRegressionTests(unittest.TestCase):
self.assertIn("pip-audit -r requirements.txt", job)
bare_invocations = [
line.strip()
for line in job.splitlines()
if line.strip() == "pip-audit"
line.strip() for line in job.splitlines() if line.strip() == "pip-audit"
]
self.assertEqual(
bare_invocations,
@@ -0,0 +1,22 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LINUX_SCRIPT = ROOT / "scripts" / "run_full_tests_linux.sh"
WINDOWS_SCRIPT = ROOT / "scripts" / "run_full_tests_windows.ps1"
class TestFullTestScriptPlaywrightBootstrap(unittest.TestCase):
def test_linux_full_gate_bootstraps_frontend_deps_and_browsers(self):
content = LINUX_SCRIPT.read_text(encoding="utf-8")
self.assertIn("ensure_npm_deps", content)
self.assertIn("npm install", content)
self.assertIn("OPENCLAW_PLAYWRIGHT_INSTALL=1", content)
self.assertIn("OPENCLAW_PLAYWRIGHT_BROWSERS=chromium", content)
def test_windows_full_gate_bootstraps_frontend_deps_and_browsers(self):
content = WINDOWS_SCRIPT.read_text(encoding="utf-8")
self.assertIn("Ensure-NpmDeps", content)
self.assertIn("npm install", content)
self.assertIn('$env:OPENCLAW_PLAYWRIGHT_INSTALL = "1"', content)
self.assertIn('$env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"', content)
+2 -1
View File
@@ -1,6 +1,7 @@
// CRITICAL: this module is served from /extensions/<pack>/web/extensions/*.js in ComfyUI.
// Keep ../../../scripts/app.js so it resolves to ComfyUI core /scripts/app.js (not /extensions/<pack>/scripts/app.js).
import { app } from "../../../scripts/app.js";
import { hasComparableWidget } from "../openclaw_graph_host.js";
/**
* F51: In-Canvas Context Toolbox
@@ -48,7 +49,7 @@ export function registerContextToolbox() {
// F50: OpenClaw Compare
// Only show if node has inputs/widgets that can be compared
if (node.widgets && node.widgets.length > 0) {
if (hasComparableWidget(app.graph, node)) {
options.push({
content: "\u2696\uFE0F OpenClaw: Compare...",
callback: () => {
+332
View File
@@ -0,0 +1,332 @@
const MAX_PROMOTED_WIDGET_DEPTH = 24;
const COMPARE_WIDGET_NAMES = new Set(["ckpt_name", "lora_name", "unet_name"]);
function normalizeNodeId(nodeId) {
if (nodeId === null || nodeId === undefined || nodeId === "") {
return null;
}
return String(nodeId);
}
function getDirectGraphNodes(graph) {
if (!graph || typeof graph !== "object") {
return [];
}
if (Array.isArray(graph._nodes)) {
return graph._nodes;
}
if (Array.isArray(graph.nodes)) {
return graph.nodes;
}
return [];
}
function getNodeTitle(node, fallbackId) {
return node?.title || node?.type || `Node ${fallbackId}`;
}
function getNodeWidgets(node) {
return Array.isArray(node?.widgets) ? node.widgets : [];
}
function getNodeSubgraph(node) {
return node?.subgraph && typeof node.subgraph === "object" ? node.subgraph : null;
}
function isPromotedWidgetView(widget) {
return !!widget && typeof widget === "object" && widget.sourceNodeId !== undefined && widget.sourceWidgetName !== undefined;
}
function buildNodeEntry(node, graph, executionId, pathTitles) {
const rawId = normalizeNodeId(node?.id);
const title = getNodeTitle(node, rawId || executionId);
return {
id: executionId,
rawId,
title,
displayTitle: pathTitles.join(" / "),
type: node?.type || title,
node,
graph,
pathTitles,
isNested: pathTitles.length > 1,
};
}
function findDirectNodeByRawId(graph, rawId) {
const normalized = normalizeNodeId(rawId);
if (!normalized) {
return null;
}
return getDirectGraphNodes(graph).find((node) => normalizeNodeId(node?.id) === normalized) || null;
}
function getChildNodeEntry(hostEntry, rawChildId) {
const subgraph = getNodeSubgraph(hostEntry?.node);
if (!subgraph) {
return null;
}
const child = findDirectNodeByRawId(subgraph, rawChildId);
if (!child) {
return null;
}
const rawId = normalizeNodeId(child.id);
const title = getNodeTitle(child, rawId || rawChildId);
const executionId = `${hostEntry.id}:${rawId}`;
return buildNodeEntry(child, subgraph, executionId, [...hostEntry.pathTitles, title]);
}
function findWidgetByIdentity(widgets, widgetName, sourceNodeId) {
if (!Array.isArray(widgets) || !widgetName) {
return null;
}
if (sourceNodeId !== null && sourceNodeId !== undefined && sourceNodeId !== "") {
const normalizedSourceId = String(sourceNodeId);
return (
widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
normalizeNodeId(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) === normalizedSourceId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
) || null
);
}
return widgets.find((entry) => entry?.name === widgetName || entry?.sourceWidgetName === widgetName) || null;
}
function resolvePromotedWidget(entry, widget) {
let currentEntry = entry;
let currentWidget = widget;
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_DEPTH; depth += 1) {
if (!isPromotedWidgetView(currentWidget)) {
return {
hostEntry: entry,
nodeEntry: currentEntry,
node: currentEntry.node,
widget: currentWidget,
promotedDepth: depth,
};
}
const sourceEntry = getChildNodeEntry(currentEntry, currentWidget.sourceNodeId);
if (!sourceEntry) {
return null;
}
const sourceWidget = findWidgetByIdentity(
getNodeWidgets(sourceEntry.node),
currentWidget.sourceWidgetName,
currentWidget.disambiguatingSourceNodeId
);
if (!sourceWidget) {
return null;
}
currentEntry = sourceEntry;
currentWidget = sourceWidget;
}
return null;
}
export function getGraphNodeCatalog(graph) {
const entries = [];
const visitedGraphs = new WeakSet();
function visit(currentGraph, parentExecutionId = "", parentPathTitles = []) {
if (!currentGraph || typeof currentGraph !== "object" || visitedGraphs.has(currentGraph)) {
return;
}
visitedGraphs.add(currentGraph);
for (const node of getDirectGraphNodes(currentGraph)) {
const rawId = normalizeNodeId(node?.id);
if (!rawId) {
continue;
}
const title = getNodeTitle(node, rawId);
const executionId = parentExecutionId ? `${parentExecutionId}:${rawId}` : rawId;
const pathTitles = [...parentPathTitles, title];
const entry = buildNodeEntry(node, currentGraph, executionId, pathTitles);
entries.push(entry);
const subgraph = getNodeSubgraph(node);
if (subgraph && subgraph !== currentGraph) {
visit(subgraph, executionId, pathTitles);
}
}
}
visit(graph);
return entries;
}
export function getGraphNodeEntry(graph, nodeId) {
const normalized = normalizeNodeId(nodeId);
if (!normalized) {
return null;
}
const segments = normalized.split(":");
let currentGraph = graph;
let currentEntry = null;
let currentExecutionId = "";
let pathTitles = [];
for (let idx = 0; idx < segments.length; idx += 1) {
const segment = segments[idx];
const node = findDirectNodeByRawId(currentGraph, segment);
if (!node) {
currentEntry = null;
break;
}
currentExecutionId = currentExecutionId ? `${currentExecutionId}:${segment}` : segment;
pathTitles = [...pathTitles, getNodeTitle(node, segment)];
currentEntry = buildNodeEntry(node, currentGraph, currentExecutionId, pathTitles);
if (idx < segments.length - 1) {
currentGraph = getNodeSubgraph(node);
if (!currentGraph) {
return null;
}
}
}
if (currentEntry) {
return currentEntry;
}
if (segments.length === 1) {
const matches = getGraphNodeCatalog(graph).filter((entry) => entry.rawId === normalized);
if (matches.length === 1) {
return matches[0];
}
}
return null;
}
export function getGraphNodeEntryByObject(graph, targetNode) {
if (!targetNode || typeof targetNode !== "object") {
return null;
}
return getGraphNodeCatalog(graph).find((entry) => entry.node === targetNode) || null;
}
export function resolveGraphWidget(graph, nodeRefOrId, widgetName, sourceNodeId = null) {
// IMPORTANT: keep nested-subgraph and promoted-widget host resolution centralized here.
// Replacing callers with direct graph._nodes/getNodeById/node.widgets access silently drops newer frontend host shapes.
const entry =
typeof nodeRefOrId === "object" && nodeRefOrId
? getGraphNodeEntryByObject(graph, nodeRefOrId)
: getGraphNodeEntry(graph, nodeRefOrId);
if (!entry || !widgetName) {
return null;
}
const widget = findWidgetByIdentity(getNodeWidgets(entry.node), widgetName, sourceNodeId);
if (!widget) {
return null;
}
const resolved = resolvePromotedWidget(entry, widget);
if (!resolved) {
return {
hostEntry: entry,
nodeEntry: entry,
node: entry.node,
widget,
promotedDepth: 0,
};
}
return resolved;
}
export function getGraphWidgetCatalog(graph, nodeId) {
const entry = getGraphNodeEntry(graph, nodeId);
if (!entry) {
return [];
}
return getNodeWidgets(entry.node).map((widget) => {
const widgetName = widget?.name || widget?.sourceWidgetName || "";
const resolved = resolveGraphWidget(
graph,
entry.id,
widgetName,
widget?.disambiguatingSourceNodeId || null
);
return {
name: widgetName,
type: widget?.type || resolved?.widget?.type || "unknown",
value: widget?.value,
options: widget?.options,
isPromoted: isPromotedWidgetView(widget),
sourceNodeId: widget?.sourceNodeId,
sourceWidgetName: widget?.sourceWidgetName,
resolvedNodeId: resolved?.nodeEntry?.id || entry.id,
resolvedWidgetName: resolved?.widget?.name || widgetName,
};
});
}
export function getGraphWidgetValueCandidates(graph, nodeId, widgetName) {
const resolved = resolveGraphWidget(graph, nodeId, widgetName);
if (!resolved || !resolved.widget) {
return [];
}
const opts =
resolved.widget.options && Array.isArray(resolved.widget.options.values)
? [...resolved.widget.options.values]
: [];
if (!opts.some((candidate) => String(candidate) === String(resolved.widget.value))) {
opts.unshift(resolved.widget.value);
}
return opts.filter((candidate) => candidate !== undefined);
}
export function findComparableWidget(graph, nodeRefOrId) {
const entry =
typeof nodeRefOrId === "object" && nodeRefOrId
? getGraphNodeEntryByObject(graph, nodeRefOrId)
: getGraphNodeEntry(graph, nodeRefOrId);
if (!entry) {
return null;
}
for (const widget of getNodeWidgets(entry.node)) {
const widgetName = widget?.name || widget?.sourceWidgetName;
if (!COMPARE_WIDGET_NAMES.has(widgetName)) {
continue;
}
const resolved = resolveGraphWidget(
graph,
entry.id,
widgetName,
widget?.disambiguatingSourceNodeId || null
);
if (!resolved) {
continue;
}
return {
hostEntry: entry,
nodeEntry: resolved.nodeEntry,
widget: resolved.widget,
hostWidgetName: widgetName,
nodeId: resolved.nodeEntry.id,
widgetName: resolved.widget.name || widgetName,
};
}
return null;
}
export function hasComparableWidget(graph, nodeRefOrId) {
return !!findComparableWidget(graph, nodeRefOrId);
}
+50 -49
View File
@@ -2,6 +2,12 @@
// Must resolve ComfyUI core app from /scripts/app.js via ../../../ prefix.
import { app } from "../../../scripts/app.js";
import { openclawApi } from "../openclaw_api.js";
import {
findComparableWidget,
getGraphNodeCatalog,
getGraphWidgetCatalog,
getGraphWidgetValueCandidates,
} from "../openclaw_graph_host.js";
import { openclawUI } from "../openclaw_ui.js";
/**
@@ -198,40 +204,33 @@ export const ParameterLabTab = {
// --- Dynamic Data Helpers ---
_coerceSelectedNodeId(nodeId) {
if (nodeId === null || nodeId === undefined || nodeId === "") {
return null;
}
const raw = String(nodeId);
return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
},
getNodeCatalog() {
if (!app.graph || !app.graph._nodes) return [];
return app.graph._nodes.map(n => ({
id: n.id,
title: n.title || n.type,
type: n.type
})).sort((a, b) => a.id - b.id);
return getGraphNodeCatalog(app.graph).map((entry) => ({
id: entry.id,
title: entry.displayTitle,
type: entry.type,
}));
},
getWidgetCatalog(nodeId) {
if (!nodeId || !app.graph) return [];
const node = app.graph.getNodeById(nodeId);
if (!node || !node.widgets) return [];
return node.widgets.map(w => ({
name: w.name,
type: w.type,
value: w.value,
options: w.options
return getGraphWidgetCatalog(app.graph, nodeId).map((widget) => ({
name: widget.name,
type: widget.type,
value: widget.value,
options: widget.options,
}));
},
getValueCandidates(nodeId, widgetName) {
if (!nodeId || !widgetName || !app.graph) return [];
const node = app.graph.getNodeById(nodeId);
if (!node) return [];
const widget = node.widgets.find(w => w.name === widgetName);
if (!widget) return [];
// Return options if available, plus current value
const opts = widget.options && widget.options.values ? [...widget.options.values] : [];
if (!opts.includes(widget.value)) {
opts.unshift(widget.value);
}
return opts;
return getGraphWidgetValueCandidates(app.graph, nodeId, widgetName);
},
addDimensionUI(defaults = null) {
@@ -313,15 +312,15 @@ export const ParameterLabTab = {
nodeCatalog.forEach(n => {
const opt = document.createElement("option");
opt.value = n.id;
opt.value = String(n.id);
opt.textContent = `[${n.id}] ${n.title}`;
if (dim.node_id === n.id) opt.selected = true;
if (String(dim.node_id) === String(n.id)) opt.selected = true;
nodeSelect.appendChild(opt);
});
nodeSelect.onchange = (e) => {
const newVal = parseInt(e.target.value);
if (!isNaN(newVal)) {
const newVal = this._coerceSelectedNodeId(e.target.value);
if (newVal !== null) {
dim.node_id = newVal;
dim.widget_name = ""; // Reset widget on node change
dim.values = []; // Reset values
@@ -486,26 +485,25 @@ export const ParameterLabTab = {
// F50: Compare Models Wizard
showCompareWizard(targetNode = null) {
// 1. Scan for loader nodes if no target provided
let node = targetNode;
if (!node) {
const nodes = app.graph._nodes.filter(n => n.type === "CheckpointLoaderSimple" || n.type === "LORALoader" || n.type === "UNETLoader");
if (nodes.length === 0) {
let target = targetNode ? findComparableWidget(app.graph, targetNode) : null;
if (!target) {
const compareTargets = getGraphNodeCatalog(app.graph)
.filter((entry) =>
entry.node?.type === "CheckpointLoaderSimple" ||
entry.node?.type === "LORALoader" ||
entry.node?.type === "UNETLoader"
)
.map((entry) => findComparableWidget(app.graph, entry.node))
.filter(Boolean);
if (compareTargets.length === 0) {
openclawUI.showBanner("warning", "No Checkpoint/LoRA loaders found in workflow.");
return;
}
node = nodes[0];
[target] = compareTargets;
}
// 2. Find acceptable widget
const widget = (node.widgets || []).find(
w =>
w.name === "ckpt_name" ||
w.name === "lora_name" ||
w.name === "unet_name"
);
if (!widget) {
openclawUI.showBanner("error", "Could not find model widget on node " + node.id);
if (!target?.widget) {
openclawUI.showBanner("error", `Could not find model widget on node ${targetNode?.id ?? "unknown"}`);
return;
}
@@ -516,7 +514,7 @@ export const ParameterLabTab = {
this.dimensions = [];
// Add dimension pre-filled
const options = widget.options?.values || [];
const options = target.widget.options?.values || [];
let initialValues = [];
if (options.length > 0) {
// Pick top 2 as example
@@ -524,14 +522,17 @@ export const ParameterLabTab = {
}
this.addDimensionUI({
node_id: node.id,
widget_name: widget.name,
node_id: this._coerceSelectedNodeId(target.nodeId),
widget_name: target.widgetName,
values: initialValues,
values_str: initialValues.join(", "), // Legacy fallback
strategy: "compare"
});
openclawUI.showBanner("info", `Setup comparison for Node ${node.id} (${node.title}). Edit values to select models.`);
openclawUI.showBanner(
"info",
`Setup comparison for Node ${target.nodeId} (${target.nodeEntry.title}). Edit values to select models.`
);
},
async generatePlan() {
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
findComparableWidget,
getGraphNodeCatalog,
getGraphWidgetCatalog,
getGraphWidgetValueCandidates,
resolveGraphWidget,
} from "../../openclaw_graph_host.js";
function createGraphFixture() {
const nestedLoader = {
id: 7,
type: "CheckpointLoaderSimple",
title: "Nested Loader",
widgets: [
{
name: "ckpt_name",
type: "combo",
value: "base.ckpt",
options: { values: ["base.ckpt", "xl.ckpt"] },
},
],
};
const nestedSampler = {
id: 8,
type: "KSampler",
title: "Nested Sampler",
widgets: [
{
name: "seed",
type: "number",
value: 1234,
options: { values: [1234, 4321] },
},
],
};
const subgraph = {
_nodes: [nestedLoader, nestedSampler],
getNodeById(id) {
return this._nodes.find((node) => String(node.id) === String(id));
},
};
const subgraphHost = {
id: 50,
type: "SubgraphNode",
title: "Workflow Pack",
widgets: [
{
name: "ckpt_name",
type: "combo",
value: "base.ckpt",
options: {},
sourceNodeId: "7",
sourceWidgetName: "ckpt_name",
},
{
name: "seed",
type: "number",
value: 1234,
options: {},
sourceNodeId: "8",
sourceWidgetName: "seed",
},
],
subgraph,
};
const rootSampler = {
id: 10,
type: "KSampler",
title: "Root Sampler",
widgets: [
{
name: "steps",
type: "number",
value: 20,
options: { values: [20, 30] },
},
],
};
return {
_nodes: [rootSampler, subgraphHost],
getNodeById(id) {
return this._nodes.find((node) => String(node.id) === String(id));
},
};
}
describe("openclaw_graph_host", () => {
it("builds catalog entries for nested subgraph nodes", () => {
const graph = createGraphFixture();
const catalog = getGraphNodeCatalog(graph);
expect(catalog.map((entry) => entry.id)).toEqual(["10", "50", "50:7", "50:8"]);
expect(catalog.find((entry) => entry.id === "50:7")?.displayTitle).toBe(
"Workflow Pack / Nested Loader"
);
});
it("resolves promoted widget catalogs and candidate values from the nested source widget", () => {
const graph = createGraphFixture();
const widgetCatalog = getGraphWidgetCatalog(graph, "50");
const promotedWidget = widgetCatalog.find((entry) => entry.name === "ckpt_name");
expect(promotedWidget?.isPromoted).toBe(true);
expect(promotedWidget?.resolvedNodeId).toBe("50:7");
expect(getGraphWidgetValueCandidates(graph, "50", "ckpt_name")).toEqual([
"base.ckpt",
"xl.ckpt",
]);
});
it("finds compare targets through promoted widget metadata", () => {
const graph = createGraphFixture();
const compareTarget = findComparableWidget(graph, "50");
const resolved = resolveGraphWidget(graph, "50", "ckpt_name");
expect(compareTarget?.nodeId).toBe("50:7");
expect(compareTarget?.widgetName).toBe("ckpt_name");
expect(resolved?.nodeEntry.id).toBe("50:7");
expect(resolved?.widget.name).toBe("ckpt_name");
});
});