Files
ausboss-ComfyUI-AusBoss/tests/panel_guards.test.mjs

744 lines
36 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");
// A panel whose DOM is built by a shared module, so a second view can reuse
// it, is audited where it is built.
const SHARED_PANELS = { mask_by_touch: join("shared", "touch_stage.mjs") };
function domWidgetEntries() {
const entries = [];
for (const name of readdirSync(JS_ROOT)) {
const path = join(JS_ROOT, SHARED_PANELS[name] ?? join(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. A card that pins its height has no computeLayoutSize
// (see the Nodes 2.0 test below): holdNodeMinWidth is its floor.
assert.match(
source,
/(computeLayoutSize\s*=\s*\(\)\s*=>\s*\(\{\s*\n?\s*minWidth|fillNodeHeight\([\s\S]{0,120}?minWidth|holdNodeMinWidth\()/,
`${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([
"callout",
"resolution",
"compare",
"image_crop_rotate_pad",
"image_folder",
"input_preview",
"layout_boxes",
"load_audio",
"load_image_pad",
"load_video",
"lora_loader",
"mask_by_touch",
"save_video",
"show_text",
"step_preview",
"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`,
);
// fillNodeHeight is also what carries the floor into Nodes 2.0, as a CSS
// min-height on the panel's element (panel_layout.mjs). A floor assigned
// by hand reaches the classic layout alone, and in Nodes 2.0 the node
// can then be dragged shorter than the panel.
assert.doesNotMatch(
source,
/\.computeLayoutSize\s*=/,
`${name}: panel assigns computeLayoutSize - its floor no longer reaches Nodes 2.0, where fillNodeHeight keeps it as CSS`,
);
}
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("a pinned card keeps its own height in Nodes 2.0 too", () => {
// Each renderer has its own sign for a panel that keeps its height. The
// classic one skips a widget that has a computeSize. Nodes 2.0 gives a
// widget's row a share of the node's spare height whenever the widget has a
// computeLayoutSize, and every DOM widget inherits one. So a card that only
// pinned its computeSize was stretched there: on a node with a picture under
// the card (Mask Refine, Save Image, Mask by Name, Load Video) the card took
// half the spare height as empty space under its last row, and the picture
// was squeezed into the rest. pinVuePanelHeight clears the inherited method;
// assigning a computeLayoutSize afterwards would put the stretch back.
//
// Listed, not derived, so leaving a panel out is a deliberate call. Run
// Timer's readout is its node's whole face and fills the node on purpose;
// it is also the only panel there, with nothing under it to squeeze.
const fillsItsNode = new Set(["run_timer/index.js"]);
const preview = readFileSync(join(JS_ROOT, "input_preview", "index.js"), "utf-8");
const pinnedInBoth = [];
for (const { file, source } of allScripts()) {
if (file === "shared/panel_layout.mjs") continue;
if (!/\.addDOMWidget\(/.test(source)) continue;
if (!/\b(widget|domWidget)\.computeSize\s*=/.test(source)) continue;
if (fillsItsNode.has(file)) {
const nodeClass = source.match(/NODE_CLASS = "(AUSBOSS_NODES_\w+)"/)?.[1];
assert.ok(nodeClass, `${file}: no NODE_CLASS to check against the preview panel's nodes`);
assert.ok(
!preview.includes(`${nodeClass}:`) && !/fillNodeHeight\(/.test(source),
`${file}: listed as filling its node, but the node has a growing panel too - call pinVuePanelHeight`,
);
continue;
}
pinnedInBoth.push(file);
assert.match(
source,
/pinVuePanelHeight\(/,
`${file}: pins a widget's computeSize but never calls pinVuePanelHeight - Nodes 2.0 stretches the card and squeezes the panel under it`,
);
assert.doesNotMatch(
source,
/\.computeLayoutSize\s*=/,
`${file}: assigns a computeLayoutSize to a pinned panel - Nodes 2.0 reads that as a row that takes spare height`,
);
}
for (const expected of ["save_image/index.js", "seed/index.js", "shared/widget_card.mjs", "workflow_switches/index.js"]) {
assert.ok(pinnedInBoth.includes(expected), `${expected} should be one of the pinned cards 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 preview stage keeps its floor inside a Nodes 2.0 node", () => {
// The picture is out of flow (the test above), so the stage has no height
// of its own, and a Nodes 2.0 node shrinks as far as its content lets it:
// Mask Refine went from 356 to 218 px on a corner drag, its stage a 2 px
// line. Inside a Nodes 2.0 node the stage carries its floor itself, so the
// drag stops where it does in the classic renderer. The classic layout
// keeps the panel that tall by itself: the plain rule stays at 0 there.
const source = readFileSync(join(JS_ROOT, "input_preview", "index.js"), "utf-8");
// No [^}] here: the CSS is a template literal, and ${...} closes a brace
// in the middle of a rule.
assert.match(
source,
/\n\.ausboss-input-preview-stage\{[^\n]*?min-height:\s*0;/,
"the stage's own rule no longer starts from min-height:0 - the classic renderer's stage has changed",
);
assert.match(
source,
/\n\[data-node-id\] \.ausboss-input-preview-stage\{[^\n]*?min-height:\s*\$\{STAGE_HEIGHT\}px/,
"the stage has no floor inside a Nodes 2.0 node - a corner drag can flatten it again",
);
// Off, the stage is gone and its floor with it: the node shrinks to the bar.
assert.match(source, /\.ausboss-input-preview\.preview-off \.ausboss-input-preview-stage\{display:\s*none;?\}/);
});
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{",
image_folder: ".ausboss-if{",
layout_boxes: ".ausboss-lb{",
mask_by_touch: ".ausboss-touch{",
save_image: ".ausboss-save{",
show_text: ".ausboss-show-text{",
step_preview: ".ausboss-steppv{",
callout: ".ausboss-callout{",
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 picture never sets Mask by Touch's height", () => {
// The preview picture's fault once more. The picture and the layers drawn
// over it (the picked area, the piece under the pointer, the brush) sit
// out of flow in a frame that is out of flow itself, so the stage takes
// its size from the node alone, in both renderers.
const source = readFileSync(join(JS_ROOT, "shared", "touch_stage.mjs"), "utf-8");
// Each rule is one line of the CSS; ${...} closes a brace in the middle of
// some of them, so a rule is read to the end of its line.
const rule = (selector) => source.match(new RegExp(`\\n${selector.replace(/[.[\]>,]/g, "\\$&")}\\{([^\\n]*)`))?.[1] ?? null;
// Everything the face puts in its stage, with its rule. Listed, so a new
// element is a deliberate call: in flow, it sets the stage's height.
const inStage = {
frame: ".ausboss-touch-frame",
wake: ".ausboss-touch-wake",
'el("div", "ausboss-touch-busy")': ".ausboss-touch-busy",
noteEl: ".ausboss-touch-note",
};
const appended = source.match(/\bstage\.append\((.*)\);/);
assert.ok(appended, "the stage is no longer filled by one stage.append(...)");
for (const child of appended[1].split(/,\s*(?![^()]*\))/).map((part) => part.trim())) {
assert.ok(inStage[child], `${child} is new in the stage - keep it out of flow and list its rule in this test`);
}
for (const selector of Object.values(inStage)) {
assert.ok(rule(selector), `the rule for ${selector} is gone`);
assert.match(rule(selector), /position:\s*absolute/, `${selector} is back in normal flow - it will size the node in Nodes 2.0`);
}
const layers = rule(".ausboss-touch-frame>img,.ausboss-touch-frame>canvas,.ausboss-touch-frame>svg");
assert.ok(layers, "the rule for the picture and its layers is gone");
assert.match(layers, /position:\s*absolute/, "the picture is back in normal flow inside its frame");
assert.match(layers, /inset:\s*0/, "the layers no longer share the picture's rectangle");
// What they are placed in, and the floor left once nothing is in flow.
const stage = rule(".ausboss-touch-stage");
assert.ok(stage, "the stage's own rule is gone");
assert.match(stage, /position:\s*relative/, "the stage no longer holds its out-of-flow picture");
assert.match(stage, /overflow:\s*hidden/, "the stage no longer clips its picture");
assert.match(stage, /min-height:\s*0;/, "the stage's own rule no longer starts from min-height:0");
assert.match(
source,
/\n\[data-node-id\] \.ausboss-touch-stage\{[^\n]*?min-height:\s*\$\{STAGE_MIN\}px/,
"the stage has no floor inside a Nodes 2.0 node - a corner drag can flatten it",
);
});
test("the canvas never sets Layout Boxes' height", () => {
// The picture's fault once more. The canvas, the background picture on it
// and the boxes drawn over it sit out of flow, so the stage takes its size
// from the node alone, in both renderers, and the canvas is fitted into it.
const source = readFileSync(join(JS_ROOT, "layout_boxes", "index.js"), "utf-8");
// Each rule is one line of the CSS; ${...} closes a brace in the middle of
// some of them, so a rule is read to the end of its line.
const rule = (selector) => source.match(new RegExp(`\\n${selector.replace(/[.[\]>,]/g, "\\$&")}\\{([^\\n]*)`))?.[1] ?? null;
// Everything put in the stage, with its rule. Listed, so a new element is a
// deliberate call: in flow, it sets the stage's height.
const inStage = { frame: ".ausboss-lb-frame", empty: ".ausboss-lb-empty", readout: ".ausboss-lb-readout" };
const appended = source.match(/\bstage\.append\(([^)]*)\)/);
assert.ok(appended, "the stage is no longer filled by one stage.append(...)");
for (const child of appended[1].split(",").map((part) => part.trim())) {
assert.ok(inStage[child], `${child} is new in the stage - keep it out of flow and list its rule in this test`);
}
for (const selector of [...Object.values(inStage), ".ausboss-lb-bg", ".ausboss-lb-grid", ".ausboss-lb-box"]) {
assert.ok(rule(selector), `the rule for ${selector} is gone`);
assert.match(rule(selector), /position:\s*absolute/, `${selector} is back in normal flow - it will size the node in Nodes 2.0`);
}
assert.match(rule(".ausboss-lb-bg"), /inset:\s*0/, "the picture no longer fills the canvas");
const stage = rule(".ausboss-lb-stage");
assert.ok(stage, "the stage's own rule is gone");
assert.match(stage, /position:\s*relative/, "the stage no longer holds its out-of-flow canvas");
assert.match(stage, /overflow:\s*hidden/, "the stage no longer clips its canvas");
assert.match(stage, /min-height:\s*0;/, "the stage can no longer shrink below its content");
});
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 clip never sets the video panel's height", () => {
// The preview picture's fault, in the stage Load Video and Save Video
// share. With the clip in normal flow, a 9:16 clip drawn at full width set
// the panel's height in Nodes 2.0: Load Video at 320 px wide could not be
// made shorter than 965 px (584 px in the classic renderer), and a node
// saved at its classic size was drawn over the node under it. Out of flow,
// the stage takes its size from the node alone and the clip scales to fit.
const shared = readFileSync(join(JS_ROOT, "shared", "video_ui.mjs"), "utf-8");
// A rule by its exact selector. None of these has a ${...} inside.
const rule = (selector) => {
const at = shared.indexOf(`\n${selector}{`);
return at < 0 ? null : shared.slice(at + selector.length + 2, shared.indexOf("}", at));
};
// Everything the two nodes put in the stage, with its rule. Listed, so a
// new element is a deliberate call: in flow, it sets the stage's height.
const inStage = {
video: ".ausboss-video-stage video",
image: ".ausboss-video-still",
status: ".ausboss-video-status",
tools: ".ausboss-video-tools",
};
for (const name of ["load_video", "save_video"]) {
const entry = readFileSync(join(JS_ROOT, name, "index.js"), "utf-8");
const appended = entry.match(/\bstage\.append\(([^)]*)\)/);
assert.ok(appended, `${name}: the stage is no longer filled by one stage.append(...)`);
for (const child of appended[1].split(",").map((part) => part.trim())) {
assert.ok(
inStage[child],
`${name}: ${child} is new in the video stage - keep it out of flow and list its rule in this test`,
);
}
}
for (const selector of Object.values(inStage)) {
assert.ok(rule(selector), `the rule for ${selector} is gone`);
assert.match(
rule(selector),
/position:\s*absolute/,
`${selector} is back in normal flow - it will size the node in Nodes 2.0`,
);
}
// The player fills the stage, so its controls sit along the stage's bottom
// edge at full width. Centred at its own size, like the preview picture, a
// portrait clip's controls would be squeezed into a narrow strip.
for (const selector of [inStage.video, inStage.image]) {
assert.match(rule(selector), /inset:\s*0/, `${selector} no longer fills the stage`);
assert.match(rule(selector), /width:\s*100%/, `${selector} no longer fills the stage's width`);
assert.match(rule(selector), /height:\s*100%/, `${selector} no longer fills the stage's height`);
assert.match(rule(selector), /object-fit:\s*contain/, `${selector} no longer scales the clip to fit`);
}
// What they are placed in, and the floor left once nothing is in flow.
const stage = rule(".ausboss-video-stage");
assert.ok(stage, "the stage's own rule is gone");
assert.match(stage, /position:\s*relative/, "the stage no longer holds its out-of-flow clip");
assert.match(stage, /overflow:\s*hidden/, "the stage no longer clips its clip");
assert.match(stage, /min-height:\s*112px/, "the stage has no floor of its own - Nodes 2.0 can flatten it");
});
test("the note's text never sets the Callout's height", () => {
// The picture's fault once more, with text. Nodes 2.0 lets a node's content
// decide its height, and the note's text was in normal flow: a note could
// not be made shorter than its own text. One saved 888x122 was drawn 20 px
// taller once its text had been fitted in the classic renderer, a note
// dragged taller would not come back down, and one dragged narrow grew to
// 692 px. The text was also fitted to a card it had sized itself. Out of
// flow, the card takes its size from the node alone, in both renderers.
const source = readFileSync(join(JS_ROOT, "callout", "index.js"), "utf-8");
// Each rule is one line of the CSS; ${...} closes a brace in the middle of
// some of them, so a rule is read to the end of its line.
const rule = (selector) => source.match(new RegExp(`\\n${selector.replace(/\./g, "\\.")}\\{([^\\n]*)`))?.[1] ?? null;
// Everything the note puts in its card, with its rule. Listed, so a new
// element is a deliberate call: in flow, it sets the card's height.
const inCard = {
inner: ".ausboss-callout-inner",
tools: ".ausboss-callout-tools",
wrap: ".ausboss-callout-editor",
};
const appended = [...source.matchAll(/\bcard\.append\(([^)]*)\)/g)]
.flatMap((match) => match[1].split(",").map((part) => part.trim()));
assert.ok(appended.includes("inner"), "the note's text is no longer appended to the card by name");
for (const child of appended) {
assert.ok(
inCard[child],
`${child} is new in the note's card - keep it out of flow and list its rule in this test`,
);
}
for (const selector of Object.values(inCard)) {
assert.ok(rule(selector), `the rule for ${selector} is gone`);
assert.match(
rule(selector),
/position:\s*absolute/,
`${selector} is back in normal flow - it will size the node in Nodes 2.0`,
);
}
// The text keeps its own height and is centred by its auto margins, which
// also holds when it is taller than the card: it is cut the same at the
// top and at the bottom.
const text = rule(inCard.inner);
assert.match(text, /top:\s*0/, "the text is no longer pinned to the card's top edge");
assert.match(text, /bottom:\s*0/, "the text is no longer pinned to the card's bottom edge");
assert.match(text, /height:\s*fit-content/, "the text no longer keeps its own height - the fit measures it");
assert.match(text, /margin:\s*auto 0/, "the text is no longer centred in the card");
assert.match(text, /width:\s*100%/, "the text no longer wraps at the card's width");
// What it is placed in.
const card = rule(".ausboss-callout-card");
assert.ok(card, "the card's own rule is gone");
assert.match(card, /position:\s*relative/, "the card no longer holds its out-of-flow text");
assert.match(card, /overflow:\s*hidden/, "the card no longer clips text that does not fit");
assert.match(card, /height:\s*100%/, "the card no longer takes the panel's height");
});
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}`);
});