// 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//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//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//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\);/, ); });