Files
ausboss-ComfyUI-AusBoss/tests/saved_widget_values.test.mjs
ausboss 21ca6e9f47 Stitch Inpaint: Paste onto puts a hand crop back on the whole picture
Image Crop + Rotate + Pad's stitcher now remembers the picture and the
crop's position. With Paste onto on original, Stitch Inpaint lays the
result on the whole picture, straight, resized or turned. The default
stays crop, so saved workflows run as before.
2026-11-01 01:24:49 -06:00

275 lines
13 KiB
JavaScript

// Cards and panels are not saved values, and older saves still carry one.
//
// The frontend saves every widget whose own `serialize` is not false into
// widgets_values, and hands saved values back by position when a workflow
// opens. `options.serialize = false` only keeps a widget out of the queued
// prompt, so until this pack set `widget.serialize = false` on its cards and
// panels, each of them saved an empty value of its own after the node's real
// values. An input appended to the node since then sits where that empty
// value was: Stitch Inpaint's seam and Crop For Inpaint's keep_inside both
// opened empty from older saves.
//
// This test is policy:
// 1. every DOM widget in the pack is left out of saved workflows;
// 2. the shipped examples, saved with the old layout, still hand every real
// widget its own value when the cards and panels are skipped;
// 3. an input added to a node whose older saves end in such a value is
// declared below, and a widget input gets a fallback that fires.
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resetUnknownValues } from "../js/shared/widget_card_math.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const JS_ROOT = join(ROOT, "js");
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, files);
else if (/\.m?js$/.test(name)) files.push(path);
}
return files;
}
// ---------- 1. DOM widgets stay out of saved workflows ----------
test("every DOM widget is left out of saved workflows", () => {
let calls = 0;
for (const path of walk(JS_ROOT)) {
const source = readFileSync(path, "utf-8");
const file = path.slice(JS_ROOT.length + 1);
for (const match of source.matchAll(/(?:const|let)\s+(\w+)\s*=\s*\w+\.addDOMWidget\(/g)) {
calls += 1;
const name = match[1];
// The flag must follow before the file creates its next DOM widget.
const after = source.slice(match.index + match[0].length);
const next = after.indexOf("addDOMWidget(");
const scope = next < 0 ? after : after.slice(0, next);
assert.match(
scope,
new RegExp(`\\b${name}\\.serialize = false;`),
`${file}: ${name} = addDOMWidget(...) is saved with the workflow - set ${name}.serialize = false `
+ "right after it (options.serialize only keeps it out of the prompt)",
);
}
// A DOM widget created without a name to flag cannot be checked.
const unnamed = [...source.matchAll(/\.addDOMWidget\(/g)].length
- [...source.matchAll(/(?:const|let)\s+\w+\s*=\s*\w+\.addDOMWidget\(/g)].length;
assert.equal(unnamed, 0, `${file}: addDOMWidget called without keeping the widget in a variable`);
}
assert.ok(calls >= 15, `expected the pack's DOM widgets, found ${calls}`);
});
// ---------- 2. the shipped examples still restore every value ----------
// A card or panel in a named copy: the pack's own name, saved as "".
const isPlaceholder = (key, value) => key.startsWith("ausboss_") && (value === "" || value === null);
function exampleNodes() {
const dir = join(ROOT, "example_workflows");
const nodes = [];
for (const name of readdirSync(dir).filter((item) => item.endsWith(".json")).sort()) {
const graph = JSON.parse(readFileSync(join(dir, name), "utf-8"));
const all = [...(graph.nodes ?? []), ...(graph.definitions?.subgraphs ?? []).flatMap((sub) => sub.nodes ?? [])];
for (const node of all) nodes.push({ file: name, node });
}
return nodes;
}
test("the examples hand every widget its own value with the cards and panels skipped", () => {
let checked = 0;
for (const { file, node } of exampleNodes()) {
const values = node.widgets_values;
const named = node.widgets_values_named;
// A dict save restores by name, and a save with no named copy says
// nothing about which slot was a panel.
if (!Array.isArray(values) || !named || typeof named !== "object") continue;
const keys = Object.keys(named);
const real = keys.filter((key) => !isPlaceholder(key, named[key]));
if (real.length === keys.length) continue;
checked += 1;
// Skipping the cards and panels moves nothing only while they all come
// after the real values.
const firstPlaceholder = keys.findIndex((key) => isPlaceholder(key, named[key]));
assert.ok(
keys.slice(firstPlaceholder).every((key) => isPlaceholder(key, named[key])),
`${file}: ${node.type} ${node.id} saved a panel before a real value: ${keys.join(", ")}`,
);
real.forEach((key, index) => {
assert.deepEqual(values[index], named[key], `${file}: ${node.type} ${node.id} ${key} would load another value`);
});
}
assert.ok(checked >= 10, `expected examples saved with cards and panels, found ${checked}`);
});
// ---------- 3. inputs added since a node's saves began ending in a placeholder ----------
// Workflows saved before the cards and panels stopped saving end each of
// these nodes' widget values with an empty value per card or panel: since
// 1.1.0 for the preview, LoRA, pad and Compare panels (1.2.0 for Select
// Frame and Show Text), since 2.0.0 for the cards and the other panels, and
// since 2.3.0 for Realign to Source. Replaying every release from 1.0.0
// against today's inputs found the inputs added since; APPENDED_SINCE
// declares them. The input listed here is the node's last one when this
// guard began, so any input after it is new and must be declared as well.
// Frozen history: a node added later never saved such a value. The
// widget_kv nodes (Load Video, Save Video, Save Image and the three Crop +
// Rotate + Pad nodes) save by name and are not listed.
const LAST_GUARDED_INPUT = {
AUSBOSS_NODES_AlignImage: "pad_color",
AUSBOSS_NODES_ColorMatch: "reference_mode",
AUSBOSS_NODES_Compare: "image_b",
AUSBOSS_NODES_CropForInpaint: "keep_inside",
AUSBOSS_NODES_Float: "value",
AUSBOSS_NODES_FrameInterpolate: "batch_size",
AUSBOSS_NODES_ImageResize: "mask",
AUSBOSS_NODES_Integer: "value",
AUSBOSS_NODES_Krea2Encode: "vlm_reference",
AUSBOSS_NODES_Krea2OutpaintModelPatch: "placement",
AUSBOSS_NODES_LaMaInpaint: "preview",
AUSBOSS_NODES_LoadImagePad: "source_image",
AUSBOSS_NODES_LoraLoader: "on_missing",
AUSBOSS_NODES_MathExpression: "c",
AUSBOSS_NODES_MergeBatches: "on_mismatch",
AUSBOSS_NODES_RealignToSource: "stitcher",
AUSBOSS_NODES_RefineMask: "guide_image",
AUSBOSS_NODES_Resolution: "batch_size",
AUSBOSS_NODES_RunTimer: null,
AUSBOSS_NODES_Seed: "seed",
AUSBOSS_NODES_SelectEveryNth: "fps",
AUSBOSS_NODES_SelectFrame: "preview",
AUSBOSS_NODES_ShowText: "text",
AUSBOSS_NODES_SplitBatch: "index",
AUSBOSS_NODES_StitchInpaint: "seam",
AUSBOSS_NODES_Text: "text",
AUSBOSS_NODES_WorkflowNote: "note",
};
// Every input added to those nodes since their saves began ending in an
// empty value, and what it is: a socket (a link only, never a saved value)
// or its widget type. Adding an input to one of these nodes means listing it
// here, and a widget input also needs a fallback equal to the node's default
// - what an API prompt without the input runs with: resetUnknown in its card
// (js/widget_cards/index.js), or RESET_UNKNOWN beside NODE_CLASS in its own
// js/<name>/index.js for a node without a card.
const APPENDED_SINCE = {
// Added in 2.4.0; 2.0.0 - 2.3.0 saves hold the card's value there.
AUSBOSS_NODES_CropForInpaint: { keep_inside: "toggle" },
AUSBOSS_NODES_StitchInpaint: { seam: "combo", paste_onto: "combo" },
// Added in 2.0.0; 1.x saves hold the preview panel's value there (Select
// Frame's from 1.2.0), or the LoRA panel's. Mask Refine's max_hole_size
// came after 2.4.0; 2.x saves hold the card's value there.
AUSBOSS_NODES_LaMaInpaint: { preview: "toggle" },
AUSBOSS_NODES_RefineMask: { preview: "toggle", max_hole_size: "number" },
AUSBOSS_NODES_SelectFrame: { preview: "toggle" },
AUSBOSS_NODES_LoraLoader: { on_missing: "combo" },
// Sockets, which hold no saved value.
AUSBOSS_NODES_Compare: { filename: "socket" },
AUSBOSS_NODES_Krea2Encode: { mask: "socket" },
AUSBOSS_NODES_LoadImagePad: { source_image: "socket" },
AUSBOSS_NODES_SelectEveryNth: { fps: "socket" },
};
// What such an input receives from an older save: the card's or panel's "",
// or on a node with an upload button, the button's saved "image".
const OLD_SLOT_VALUES = ["", "image"];
const NODE_API = JSON.parse(readFileSync(join(ROOT, "tests", "fixtures", "node_api.json"), "utf-8"));
// A resetUnknown or RESET_UNKNOWN object literal, keys unquoted.
const parseFallbacks = (literal) => JSON.parse(literal.replace(/(\w+):/g, '"$1":'));
function cardEntry(nodeId) {
const source = readFileSync(join(JS_ROOT, "widget_cards", "index.js"), "utf-8");
const start = source.indexOf(`\n ${nodeId}: {`);
if (start < 0) return null;
const next = source.slice(start + 1).search(/\n {2}AUSBOSS_NODES_\w+: \{/);
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
}
// A node's fallbacks: its card's resetUnknown, or RESET_UNKNOWN in the
// js/<name>/index.js whose NODE_CLASS is the node.
function nodeFallbacks(nodeId) {
const card = cardEntry(nodeId)?.match(/resetUnknown: (\{[^}]*\})/)?.[1];
if (card) return parseFallbacks(card);
for (const name of readdirSync(JS_ROOT)) {
let source;
try {
source = readFileSync(join(JS_ROOT, name, "index.js"), "utf-8");
} catch {
continue;
}
if (!source.includes(`const NODE_CLASS = "${nodeId}";`)) continue;
const own = source.match(/^const RESET_UNKNOWN = (\{[^}]*\});$/m)?.[1];
if (own) return parseFallbacks(own);
}
return {};
}
test("every input added since a node's saves began ending in a placeholder is declared", () => {
for (const [nodeId, last] of Object.entries(LAST_GUARDED_INPUT)) {
const api = NODE_API[nodeId];
assert.ok(api, `${nodeId} is missing from tests/fixtures/node_api.json`);
const inputs = [...api.required, ...api.optional];
const at = last === null ? -1 : inputs.indexOf(last);
assert.ok(last === null || at >= 0, `${nodeId} no longer has ${last}; inputs only grow`);
const declared = APPENDED_SINCE[nodeId] ?? {};
for (const name of inputs.slice(at + 1)) {
assert.ok(
name in declared,
`${nodeId}: ${name} is new, and older saves hand it a card's or panel's empty value. `
+ "List it in APPENDED_SINCE in this test as a socket or its widget type; a widget input also "
+ "needs a fallback equal to the node's default (see APPENDED_SINCE)",
);
}
for (const name of Object.keys(declared)) {
assert.ok(inputs.includes(name), `${nodeId}: APPENDED_SINCE lists ${name}, which is not one of its inputs`);
}
}
for (const nodeId of Object.keys(APPENDED_SINCE)) {
assert.ok(nodeId in LAST_GUARDED_INPUT, `${nodeId} is in APPENDED_SINCE but not in LAST_GUARDED_INPUT`);
}
});
test("an added widget input opens an older save with its fallback, not the old slot's value", () => {
for (const [nodeId, inputs] of Object.entries(APPENDED_SINCE)) {
for (const [name, type] of Object.entries(inputs)) {
if (type === "socket") continue;
const fallbacks = nodeFallbacks(nodeId);
assert.ok(
name in fallbacks,
`${nodeId}: no fallback for ${name} - add it to the card's resetUnknown, or to RESET_UNKNOWN `
+ "beside NODE_CLASS in the node's own js/<name>/index.js",
);
const fallback = fallbacks[name];
for (const old of OLD_SLOT_VALUES) {
const widget = { name, type, value: old, options: { values: [fallback] } };
assert.deepEqual(
resetUnknownValues([widget], { [name]: fallback }),
[name],
`${nodeId}: a ${type} widget holding ${JSON.stringify(old)} is not reset - resetUnknownValues cannot judge it`,
);
assert.deepEqual(widget.value, fallback);
}
// A value the user saved is left alone.
const saved = { name, type, value: fallback, options: { values: [fallback] } };
assert.deepEqual(resetUnknownValues([saved], { [name]: fallback }), []);
}
}
});
test("a node without a card applies its own RESET_UNKNOWN when a workflow opens", () => {
// The LoRA Loader has no card; its fallback only helps if the node runs it.
const source = readFileSync(join(JS_ROOT, "lora_loader", "index.js"), "utf-8");
assert.deepEqual(nodeFallbacks("AUSBOSS_NODES_LoraLoader"), { on_missing: "error" });
assert.match(
source,
/chainCallback\(nodeType\.prototype, "onConfigure", function \(\) \{\s*resetUnknownValues\(this\.widgets, RESET_UNKNOWN\);/,
);
});