Files
ausboss-ComfyUI-AusBoss/tests/panel_guards.test.mjs
T
ausboss 7b8aa698e6 Preview nodes keep their size in Nodes 2.0
In Nodes 2.0 a node's saved height is only a minimum and its content can
make it taller. The preview panel's picture was in normal flow, so a
portrait result drawn at full width grew Save Image from 730 to 1276 px in
the Krea 2 Outpaint example, over the Run Timer under it. Nodes 2.0 also
drew its own copy of the result under the panel, because the panel never
set hideOutputImages.

- The stage's picture is out of flow and centred, so the stage gets its
  size from the node and the picture scales to fit, in both renderers.
- suppressCoreImagePreview sets hideOutputImages; classic never reads it.
- In Nodes 2.0 that copy was the only thing filling node.imgs, so the
  panel falls back to the node's stored output (app.nodeOutputs) and
  re-reads it when a workflow tab restores its outputs. node.imgs still
  wins where it exists, so classic keeps its old path.

Select Frame, Mask Refine and LaMa Inpaint share the panel and grew the
same way. Core Save Image and Preview Image keep their size in Nodes 2.0.
Classic is unchanged.

Tests: the stage-picture guard in panel_guards, hideOutputImages in
core_preview and the stored-output lookup in input_preview fail on main
and pass here.
2026-09-29 05:11:19 -05:00

389 lines
17 KiB
JavaScript

// 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/<name>/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/<name>/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 <img>/<video> with object-fit: contain
// are exempt — the browser rescales those on any box, with no redraw.
test("every canvas-painting panel redraws when its element resizes", () => {
const painters = ["shared/pad_panel.mjs", "shared/transform_editor.mjs"];
for (const rel of painters) {
const source = readFileSync(join(JS_ROOT, ...rel.split("/")), "utf-8");
assert.ok(
source.includes('getContext("2d")'),
`${rel} no longer paints a canvas - drop it from this list`,
);
assert.match(
source,
/new ResizeObserver\(/,
`${rel} paints a canvas but never observes its size`,
);
}
});
test("the transform panel observes resize for video as well as image", () => {
const source = readFileSync(join(JS_ROOT, "shared", "transform_editor.mjs"), "utf-8");
const observer = source.indexOf("state.panelResizeObserver = new ResizeObserver(");
assert.ok(observer > 0, "the panel resize observer is gone");
// Walk back to the guard that encloses it. Anything narrowing the observer
// to one kind leaves the other painting stale frames into a resized box.
const guard = source.lastIndexOf("if (", observer);
const condition = source.slice(guard, source.indexOf(") {", guard));
assert.ok(
!condition.includes('kind === "image"') && !condition.includes('kind === "video"'),
`the resize observer is gated on node kind: ${condition.trim()}`,
);
});
// The frontend hides a DOM widget below a zoom threshold unless it opts out,
// and every AusBoss panel is the node's face: hidden, the node is an empty
// box. The video transform nodes shipped that way because their mount was
// the one addDOMWidget call without the flag.
test("every DOM widget stays visible when the graph is zoomed out", () => {
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (/\.m?js$/.test(name)) files.push(path);
}
};
walk(JS_ROOT);
let calls = 0;
for (const path of files) {
const source = readFileSync(path, "utf-8");
let from = 0;
for (;;) {
const start = source.indexOf("addDOMWidget(", from);
if (start < 0) break;
// Walk to the call's closing parenthesis.
let depth = 0;
let end = start + "addDOMWidget".length;
for (; end < source.length; end += 1) {
if (source[end] === "(") depth += 1;
else if (source[end] === ")" && --depth === 0) break;
}
const call = source.slice(start, end + 1);
from = end + 1;
if (/typeof\s+node\.addDOMWidget|addDOMWidget\s*===|\.addDOMWidget\s*\?/.test(source.slice(start - 30, start + 13))) continue;
calls += 1;
assert.match(call, /hideOnZoom:\s*false/, `${path.slice(JS_ROOT.length + 1)}: addDOMWidget without hideOnZoom: false`);
}
}
assert.ok(calls >= 14, `expected the pack's DOM widgets, found ${calls}`);
});