467 lines
21 KiB
JavaScript
467 lines
21 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.
|
|
//
|
|
// workflow_switches is a list sized to its rows, like the widget cards: one
|
|
// row per switch, and extra height would only be dead space under the last.
|
|
const fixedByDesign = new Set(["run_timer", "save_image", "seed", "widget_cards", "workflow_switches"]);
|
|
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`);
|
|
}
|
|
});
|
|
|
|
// Every .js / .mjs the frontend serves from js/, with its path relative to js/.
|
|
function allScripts(dir = JS_ROOT, prefix = "") {
|
|
const found = [];
|
|
for (const name of readdirSync(dir)) {
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) found.push(...allScripts(path, `${prefix}${name}/`));
|
|
else if (/\.m?js$/.test(name)) found.push({ file: `${prefix}${name}`, source: readFileSync(path, "utf-8") });
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// The text of each call to `name(` in a source: from the name to its matching
|
|
// close parenthesis.
|
|
function callsTo(name, source) {
|
|
const calls = [];
|
|
let from = 0;
|
|
while (true) {
|
|
const at = source.indexOf(`${name}(`, from);
|
|
if (at < 0) return calls;
|
|
let depth = 0;
|
|
let end = at + name.length;
|
|
for (; end < source.length; end += 1) {
|
|
if (source[end] === "(") depth += 1;
|
|
else if (source[end] === ")" && --depth === 0) break;
|
|
}
|
|
calls.push(source.slice(at, end + 1));
|
|
from = end;
|
|
}
|
|
}
|
|
|
|
test("a growing panel's declared width is the node's real floor", () => {
|
|
// The frontend adds 104px (room for a number widget's value box) to every
|
|
// laid-out panel's minWidth, so a panel declaring 320 floored its node at
|
|
// 424 and a node created at 340 jumped to 424 on the first touch of its
|
|
// corner. exactMinWidth takes the padding back off (panel_layout.mjs).
|
|
let calls = 0;
|
|
for (const { file, source } of allScripts()) {
|
|
if (file === "shared/panel_layout.mjs") continue;
|
|
for (const call of callsTo("fillNodeHeight", source)) {
|
|
calls += 1;
|
|
assert.match(
|
|
call,
|
|
/exactMinWidth:\s*true/,
|
|
`${file}: fillNodeHeight without exactMinWidth - the node's floor ends up 104px wider than the number declared`,
|
|
);
|
|
}
|
|
}
|
|
assert.ok(calls >= 12, `expected the pack's twelve fillNodeHeight panels, the audit saw ${calls}`);
|
|
});
|
|
|
|
test("a panel that pins its own height still makes its declared width the floor", () => {
|
|
// The frontend sizes a node's width from a widget's computeLayoutSize only
|
|
// while the widget has no computeSize of its own. A pinned card (Seed, Save
|
|
// Image, the widget cards, Run Timer's Nodes 2.0 readout) declares both, so
|
|
// its width was never read and the node fell to the 210px default, where
|
|
// buttons and pills were cut off. holdNodeMinWidth makes the number real.
|
|
const pinned = [];
|
|
for (const { file, source } of allScripts()) {
|
|
if (file === "shared/panel_layout.mjs") continue;
|
|
// Only real panels: core_preview.mjs pins the widgets it hides.
|
|
if (!/\.addDOMWidget\(/.test(source)) continue;
|
|
if (!/\b(widget|domWidget)\.computeSize\s*=/.test(source)) continue;
|
|
pinned.push(file);
|
|
assert.match(
|
|
source,
|
|
/holdNodeMinWidth\(/,
|
|
`${file}: pins a widget's computeSize but never calls holdNodeMinWidth - its declared width is not the node's floor`,
|
|
);
|
|
}
|
|
for (const expected of ["seed/index.js", "save_image/index.js", "shared/widget_card.mjs", "run_timer/index.js"]) {
|
|
assert.ok(pinned.includes(expected), `${expected} should be one of the pinned panels the audit checks`);
|
|
}
|
|
});
|
|
|
|
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{",
|
|
workflow_switches: ".ausboss-ws{",
|
|
};
|
|
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}`);
|
|
});
|