// Every DOM-widget panel must keep its contents inside the node. // // Three guards make that true, and the LoRA loader shipped without all // three - its strength box and info button hung past the node's right edge: // 1. box-sizing: border-box on the panel, so padding cannot render // outside the width the frontend allocates (content-box padding adds // to it); // 2. an overflow clip on the panel, so anything that still ends up wider // paints inside the node instead of escaping it; // 3. a minimum width wired through computeLayoutSize, so the node cannot // be resized below the narrowest width the panel's fixed-size controls // need (the legacy computeSize clamp holds the PANEL wide, but without // the layout minimum the NODE could shrink out from under it). // // This test is policy: every js//index.js that calls addDOMWidget // either imports the shared video root (which carries the guards) or // carries them itself. A new panel that forgets shows up here, not in a // screenshot. 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"; const JS_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "js"); function domWidgetEntries() { const entries = []; for (const name of readdirSync(JS_ROOT)) { const path = join(JS_ROOT, name, "index.js"); try { statSync(path); } catch { continue; } const source = readFileSync(path, "utf-8"); if (source.includes("addDOMWidget")) entries.push({ name, source }); } return entries; } // The shared video root (js/shared/video_ui.mjs) carries border-box and // overflow:hidden for everyone who mounts it. const SHARED_ROOT_IMPORT = "ensureVideoCss"; test("the audit sees the panels it exists to guard", () => { const names = domWidgetEntries().map((entry) => entry.name); for (const expected of ["lora_loader", "load_video", "save_video"]) { assert.ok(names.includes(expected), `${expected} not found by the audit`); } }); test("every DOM panel keeps padding inside its box and clips overflow", () => { for (const { name, source } of domWidgetEntries()) { if (source.includes(SHARED_ROOT_IMPORT)) continue; assert.match( source, /box-sizing:\s*border-box/, `${name}: panel CSS has no box-sizing: border-box - its padding widens it past the node`, ); assert.match( source, /overflow(-x)?:\s*(hidden|clip)/, `${name}: panel CSS never clips overflow - oversized content escapes the node`, ); } }); test("every resizable DOM panel declares its minimum width to BOTH layout paths", () => { // No exemptions. input_preview was one while it was a pointer-transparent // thumbnail with nothing in it that could hang out; it now carries the // AUTO/MORE buttons and follows the node's height, so it needs a floor like // the rest. for (const { name, source } of domWidgetEntries()) { // Modern frontends read computeLayoutSize; older ones read the // minNodeSize option. Missing either leaves one frontend family able to // resize the node under the panel, which then holds its own minimum and // pokes past the border - the overflow clip cuts at the panel's edge, // not the node's. assert.match( source, /(computeLayoutSize\s*=\s*\(\)\s*=>\s*\(\{\s*\n?\s*minWidth|fillNodeHeight\([\s\S]{0,120}?minWidth)/, `${name}: DOM widget has no computeLayoutSize minWidth - the node can shrink out from under the panel`, ); assert.match( source, /(options\.minNodeSize\s*=\s*\[|minNodeSize:\s*\[)/, `${name}: DOM widget has no minNodeSize - older frontends can shrink the node under the panel`, ); } }); test("a panel that should follow the node's height never declares computeSize", () => { // The layout walks widgets once (LGraphNode._arrangeWidgets): // if (computeSize) -> fixed height, OUT of the free-space split // else if (computeLayoutSize) -> joins distributeSpace // It is an else-if, so any computeSize wins and the panel is pinned. Every // stage/player/filmstrip in this pack derived that fixed height from the // node's WIDTH, which is why dragging a node taller only added dead space // underneath it. These panels go through fillNodeHeight instead, which // deletes computeSize and declares a floor with no ceiling. // // Listed here rather than derived, so adding a panel is a deliberate call: // is this a stage that should grow, or a fixed row? const mustGrow = new Set([ "resolution", "compare", "image_crop_rotate_pad", "input_preview", "load_image_pad", "load_video", "lora_loader", "save_video", "show_text", "workflow_note", ]); // Fixed by design: a toolbar - content whose height is a constant, not a // viewport onto something bigger. // // input_preview used to sit here, and that was right while it showed a small // thumbnail of the node's INPUT. It now shows the node's own result, which // makes it a viewport like every other stage - and the reclassification was // missed when the behaviour changed, so Mask Refine, LaMa Inpaint and Select // Frame all shipped pinned at 140px with dead space under them. Moving a // panel between these two sets is the decision to review whenever what a // panel DISPLAYS changes, not just when a new one is added. // // lora_loader also sat here, and its pinned computeSize was how the stack's // rounded bottom border got clipped flat: the frontend insets a DOM widget's // element by a frame the exact-pixel sum never carried, and a pinned panel // is excluded from the free-space split so it could never absorb the // difference. It now grows: the stack flexes and the add button rides the // node's bottom edge. // // seed and run_timer are single rows of readouts and buttons under plain // widgets - a constant height by nature, nothing bigger behind them. const fixedByDesign = new Set(["run_timer", "save_image", "seed", "widget_cards"]); const seen = new Set(); for (const { name, source } of domWidgetEntries()) { seen.add(name); if (fixedByDesign.has(name)) continue; assert.ok( mustGrow.has(name), `${name}: new DOM panel - add it to mustGrow (a stage that follows the ` + "node) or fixedByDesign (a constant-height row) in this test", ); assert.match( source, /fillNodeHeight\(/, `${name}: panel does not use fillNodeHeight - it will not follow the node's height`, ); assert.doesNotMatch( source, /\.computeSize\s*=/, `${name}: panel assigns computeSize - that pins its height and undoes fillNodeHeight`, ); } for (const name of mustGrow) { assert.ok(seen.has(name), `${name} is listed as a growing panel but the audit never saw it`); } }); test("the preview picture never sets the panel's height", () => { // Nodes 2.0 lets a node's content decide its height (the saved size is only // a minimum). With the picture in normal flow, a portrait result drawn at // full width grew Save Image from 730 to about 1270 px, over the Run Timer // under it. Out of flow, the stage takes its size from the node alone and // the picture scales to fit, in both renderers. const source = readFileSync(join(JS_ROOT, "input_preview", "index.js"), "utf-8"); const rule = source.match(/\.ausboss-input-preview-stage img,\s*\.ausboss-input-preview-stage video\s*\{([^}]*)\}/); assert.ok(rule, "the rule for the stage's picture is gone"); assert.match(rule[1], /position:\s*absolute/, "the stage's picture is back in normal flow - it will size the node in Nodes 2.0"); assert.match(rule[1], /max-width:\s*100%/, "the stage's picture can overflow the stage's width"); assert.match(rule[1], /max-height:\s*100%/, "the stage's picture can overflow the stage's height"); }); test("the shared transform panel mounted on the video node also follows height", () => { // Video Crop + Rotate + Pad has no js//index.js of its own - it // registers through shared/transform_editor.mjs, so the audit above cannot // see it. Its fallback DOM widget is the one that used to pin 230px. const shared = readFileSync(join(JS_ROOT, "shared", "transform_editor.mjs"), "utf-8"); assert.match(shared, /fillNodeHeight\(domWidget/); assert.doesNotMatch( shared, /domWidget\.computeSize\s*=/, "the shared transform panel pins its height again", ); }); test("every DOM panel discards planted layout widths", () => { // The frontend sizes the wrapper as `widget.width ?? node.width`, and // LiteGraph's layout PLANTS widget.width during draws. Once planted it // outranks the node width forever: resize the node narrower and the // wrapper keeps the old width, parking the row's controls outside the // border - which no overflow clip can fix, because the wrapper itself is // the thing that is too wide. keepDomWidgetWidthAuto discards the writes // so the lookup stays on its node-width fallback. for (const { name, source } of domWidgetEntries()) { assert.match( source, /keepDomWidgetWidthAuto\(/, `${name}: DOM widget never calls keepDomWidgetWidthAuto - a planted widget.width will outrank the node width`, ); } const shared = readFileSync(join(JS_ROOT, "shared", "index.mjs"), "utf-8"); assert.match(shared, /export function keepDomWidgetWidthAuto/); assert.match(shared, /get\(\)\s*\{\s*\n?\s*return undefined/); }); test("every panel root class carries border-box and an overflow clip", () => { // The entries that style their own root, mapped to that root's class. // Entries mounting the shared video root are covered by its CSS, checked // in the shared-root test below. const roots = { // The lora panel's guards live on its BODY: the body is the panel's // layout box, and it carries both the border-box blanket and the // overflow clip by name. lora_loader: ".ausboss-lora-body {", compare: ".ausboss-compare-root{", input_preview: ".ausboss-input-preview{", resolution: ".ausboss-res-panel {", load_image_pad: ".ausboss-loadpad-root{", image_crop_rotate_pad: ".ausboss-transform-panel{", save_image: ".ausboss-save{", show_text: ".ausboss-show-text{", workflow_note: ".ausboss-note{", seed: ".ausboss-seed{", run_timer: ".ausboss-timer{", }; for (const { name, source } of domWidgetEntries()) { const marker = roots[name]; if (!marker) { assert.ok( source.includes(SHARED_ROOT_IMPORT), `${name}: unknown DOM panel - add its root class to this test's map, ` + "or mount the shared video root", ); continue; } assert.ok(source.includes(marker), `${name}: root rule ${marker} not found`); // A guard may come from the root's own rule or from a group selector // naming the root class (the lora panel takes border-box from a // blanket). Any selector occurrence followed closely by the declaration // counts; template interpolations close braces mid-rule, so windows // beat brace-matching here. const rootClass = marker.replace(/\s*\{$/, ""); const guarded = (pattern, window) => { let from = 0; while (true) { const at = source.indexOf(rootClass, from); if (at < 0) return false; if (pattern.test(source.slice(at, at + window))) return true; from = at + rootClass.length; } }; assert.ok( guarded(/box-sizing:\s*border-box/, 300), `${name}: no box-sizing: border-box reaches the root class`, ); assert.ok( guarded(/overflow(-x)?:\s*(hidden|clip)/, 600), `${name}: no overflow clip reaches the root class`, ); } }); test("the shared video root really carries the guards it is trusted for", () => { const shared = readFileSync(join(JS_ROOT, "shared", "video_ui.mjs"), "utf-8"); assert.match(shared, /ausboss-video-root\{[^}]*box-sizing:border-box/); assert.match(shared, /ausboss-video-root\{[^}]*overflow:hidden/); }); test("the lora panel carries all three guards by name", () => { const source = readFileSync(join(JS_ROOT, "lora_loader", "index.js"), "utf-8"); // Regexes cannot use [^}] here: the CSS lives in a template literal whose // ${...} interpolations close braces mid-rule. Slice the body rule out // by its neighbors instead. assert.match( source, /\.ausboss-lora-panel,\s*\.ausboss-lora-panel \*[\s\S]{0,120}box-sizing:\s*border-box/, "the border-box blanket over the panel and its children is gone", ); const start = source.indexOf(".ausboss-lora-body {"); const end = source.indexOf(".ausboss-lora-row", start); assert.ok(start > 0 && end > start, "the body rule is missing"); const rule = source.slice(start, end); assert.ok(rule.includes("overflow: hidden"), "the body no longer clips overflow"); assert.ok(rule.includes("width: 100%"), "the body no longer pins to the widget width"); // height: 100% is the anti-clip guard: the body tracks whatever box the // frontend actually allocates and the flexible stack absorbs a shortfall, // so the stack's rounded bottom border cannot be cut flat again. assert.ok(rule.includes("height: 100%"), "the body no longer tracks the allocated height"); // The declared floor must carry WIDGET_FRAME: the frontend insets the // element by a margin per side, and a floor without the allowance hands // the panel fewer CSS pixels than its rows need. assert.match( source, /minHeight:\s*\(\)\s*=>\s*panelHeight\(state\)\s*\+\s*WIDGET_FRAME/, "the height floor dropped the DOM-widget frame allowance", ); // The floor follows the strength mode (a second box per row when model // and CLIP are separate), and it is built on PANEL_MIN_WIDTH. assert.match(source, /minWidth:\s*\(\)\s*=>\s*panelMinWidth\(state\)/); assert.match(source, /return PANEL_MIN_WIDTH \+ \(linked\(state\) \? 0 : SEPARATE_EXTRA_WIDTH\)/); assert.match(source, /minNodeSize:\s*\[PANEL_MIN_WIDTH/); }); // A canvas panel is stretched by CSS to whatever box it is given, but its // backing store is only resized when something draws. So a panel that paints // into a canvas MUST redraw on element resize, or a node dragged to a new // shape displays the last frame at the wrong aspect ratio. // // This bit Video Crop + Rotate + Pad: the observer lived inside a // `kind === "image"` branch next to the interactive drag handlers, so the // image node redrew and the video node did not. It only became visible once // panels started taking the node's leftover height, because until then the // box could not change shape without a width change to trigger a relayout. // // Panels that present media through /