A note saved a few pixels shorter than its content showed a scrollbar and hid its last row. The LTX 2.3 Video Outpaint note in the smaller example layout (440 x 810) was 6 px short, so its links sat behind the scrollbar. The classic canvas keeps a node's saved height; Nodes 2.0 already sized the note to its content. The note now measures its card's natural height at the node's width and grows the node to fit when a workflow opens, after an edit, after the file check, and when the node's width changes, since the text rewraps. It never shrinks the node. The load fit runs before ComfyUI snapshots the opened workflow, so a note that grows on load does not mark the workflow modified. A corner drag stops at the fit, as it does in Nodes 2.0: holdNodeMinHeight lifts the node's computeSize height to the measured floor. A card less than half a pixel short is left alone, because Chrome rounds that away and draws no scrollbar. Nodes 2.0 is untouched.
175 lines
8.3 KiB
JavaScript
175 lines
8.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
baseName,
|
|
emptyNote,
|
|
folderPath,
|
|
groupModels,
|
|
hostLabel,
|
|
matchInstalled,
|
|
noteFit,
|
|
noteIsEmpty,
|
|
normalizeNote,
|
|
packsFromGraph,
|
|
parseInline,
|
|
parseMarkdown,
|
|
safeUrl,
|
|
serializeNote,
|
|
} from "../js/shared/workflow_note.mjs";
|
|
|
|
test("normalizeNote accepts the widget string, an object, or garbage", () => {
|
|
assert.deepEqual(normalizeNote(""), emptyNote());
|
|
assert.deepEqual(normalizeNote("not json"), emptyNote());
|
|
assert.deepEqual(normalizeNote(null), emptyNote());
|
|
assert.deepEqual(normalizeNote([1, 2]), emptyNote());
|
|
const note = normalizeNote({ title: " Krea 2 Outpaint ", layout: "banner", accent: "#00B4AA" });
|
|
assert.equal(note.title, "Krea 2 Outpaint");
|
|
assert.equal(note.layout, "banner");
|
|
assert.equal(note.accent, "#00b4aa");
|
|
});
|
|
|
|
test("rows drop empties, coerce types, and keep only http(s) urls", () => {
|
|
const note = normalizeNote({
|
|
models: [
|
|
{ name: "a.safetensors", dir: "/loras/", url: "javascript:alert(1)", size: 12 },
|
|
{ name: "", url: "https://x" },
|
|
"nonsense",
|
|
{ name: "b.safetensors", dir: "vae", url: "https://huggingface.co/x/b.safetensors" },
|
|
],
|
|
packs: [{ name: "ComfyUI-AusBoss", url: "http://github.com/a", node: "AUSBOSS_NODES_Seed" }, { url: "https://x" }],
|
|
links: [{ label: "", url: "https://civitai.com/user/AusBoss" }, { label: "", url: "ftp://nope" }],
|
|
layout: "weird",
|
|
accent: "red",
|
|
});
|
|
assert.deepEqual(note.models, [
|
|
{ name: "a.safetensors", dir: "loras", url: "", size: "12", note: "" },
|
|
{ name: "b.safetensors", dir: "vae", url: "https://huggingface.co/x/b.safetensors", size: "", note: "" },
|
|
]);
|
|
assert.deepEqual(note.packs, [{ name: "ComfyUI-AusBoss", url: "http://github.com/a", node: "AUSBOSS_NODES_Seed" }]);
|
|
assert.deepEqual(note.links, [{ label: "civitai.com", url: "https://civitai.com/user/AusBoss" }]);
|
|
assert.equal(note.layout, "card");
|
|
assert.equal(note.accent, "");
|
|
});
|
|
|
|
test("serializeNote round-trips through normalizeNote", () => {
|
|
const note = normalizeNote({ title: "T", body: "b", models: [{ name: "m", dir: "vae" }] });
|
|
assert.deepEqual(normalizeNote(serializeNote(note)), note);
|
|
});
|
|
|
|
test("noteIsEmpty is true only for a card with nothing on it", () => {
|
|
assert.equal(noteIsEmpty(emptyNote()), true);
|
|
assert.equal(noteIsEmpty(normalizeNote({ body: " \n" })), true);
|
|
assert.equal(noteIsEmpty(normalizeNote({ links: [{ url: "https://a.b" }] })), false);
|
|
});
|
|
|
|
test("safeUrl, hostLabel and baseName", () => {
|
|
assert.equal(safeUrl(" https://huggingface.co/a/b "), "https://huggingface.co/a/b");
|
|
assert.equal(safeUrl("data:text/html,hi"), "");
|
|
assert.equal(safeUrl("https://a b"), "");
|
|
assert.equal(hostLabel("https://www.youtube.com/@ausboss"), "youtube.com");
|
|
assert.equal(hostLabel("nope"), "");
|
|
assert.equal(baseName("Krea 2\\krea2_turbo_fp8.safetensors"), "krea2_turbo_fp8.safetensors");
|
|
assert.equal(baseName("plain.png"), "plain.png");
|
|
});
|
|
|
|
test("folderPath and groupModels read like the install tree", () => {
|
|
assert.equal(folderPath("loras"), "ComfyUI/models/loras");
|
|
assert.equal(folderPath("input"), "ComfyUI/input");
|
|
assert.equal(folderPath(""), "ComfyUI/models");
|
|
const groups = groupModels([
|
|
{ name: "a", dir: "vae" }, { name: "b", dir: "loras" }, { name: "c", dir: "vae" },
|
|
]);
|
|
assert.deepEqual(groups.map((g) => [g.dir, g.rows.map((r) => r.name)]), [["vae", ["a", "c"]], ["loras", ["b"]]]);
|
|
});
|
|
|
|
test("matchInstalled finds exact paths first, then basenames in any subfolder", () => {
|
|
const files = ["Krea 2/krea2_turbo_fp8.safetensors", "other/thing.safetensors", "top.safetensors"];
|
|
assert.deepEqual(matchInstalled("krea2_turbo_fp8.safetensors", files), { found: true, path: "Krea 2/krea2_turbo_fp8.safetensors" });
|
|
assert.deepEqual(matchInstalled("TOP.safetensors", files), { found: true, path: "top.safetensors" });
|
|
assert.deepEqual(matchInstalled("other\\thing.safetensors", files), { found: true, path: "other/thing.safetensors" });
|
|
assert.deepEqual(matchInstalled("missing.safetensors", files), { found: false, path: null });
|
|
assert.deepEqual(matchInstalled("x", null), { found: false, path: null });
|
|
});
|
|
|
|
test("packsFromGraph lists each custom pack once and skips core modules", () => {
|
|
const moduleOf = (type) => ({
|
|
KSampler: "nodes",
|
|
ImageBlend: "comfy_extras.nodes_post_processing",
|
|
AUSBOSS_NODES_Seed: "custom_nodes.ComfyUI-AusBoss",
|
|
AUSBOSS_NODES_LoraLoader: "custom_nodes.ComfyUI-AusBoss",
|
|
"Power Lora Loader (rgthree)": "custom_nodes.rgthree-comfy",
|
|
Unknown: undefined,
|
|
})[type];
|
|
assert.deepEqual(
|
|
packsFromGraph(["KSampler", "AUSBOSS_NODES_Seed", "ImageBlend", "AUSBOSS_NODES_LoraLoader", "Power Lora Loader (rgthree)", "Unknown"], moduleOf),
|
|
[
|
|
{ name: "ComfyUI-AusBoss", url: "", node: "AUSBOSS_NODES_Seed" },
|
|
{ name: "rgthree-comfy", url: "", node: "Power Lora Loader (rgthree)" },
|
|
],
|
|
);
|
|
});
|
|
|
|
// The numbers are the LTX 2.3 Video Outpaint note saved at 440 x 810 (card
|
|
// natural height 794.125) and Krea 2 Inpaint Masked at 440 x 749 (727.391),
|
|
// measured in the classic canvas, where the node is 22 px taller than its
|
|
// card (2 px above the first widget plus the 20 px widget frame).
|
|
test("noteFit grows a note that is short of its content to the exact fit", () => {
|
|
assert.deepEqual(noteFit({ natural: 794.125, nodeHeight: 810, chrome: 22 }), { fit: 816.125, grow: true });
|
|
// Half a pixel short is where Chrome starts drawing the scrollbar.
|
|
assert.equal(noteFit({ natural: 100.5, nodeHeight: 122, chrome: 22 }).grow, true);
|
|
// A fractional box rounds too: 100.3 draws as 100, content 100.5 as 101.
|
|
assert.equal(noteFit({ natural: 100.5, nodeHeight: 122.3, chrome: 22 }).grow, true);
|
|
});
|
|
|
|
test("noteFit leaves a note that already shows everything alone", () => {
|
|
// Saved exactly at its fit.
|
|
assert.deepEqual(noteFit({ natural: 794.125, nodeHeight: 816.125, chrome: 22 }), { fit: 816.125, grow: false });
|
|
// Less than half a pixel short: no scrollbar, so the saved size stays.
|
|
assert.deepEqual(noteFit({ natural: 727.391, nodeHeight: 749, chrome: 22 }), { fit: 749.391, grow: false });
|
|
// Taller than its content: the band stays, the node never shrinks by itself.
|
|
assert.deepEqual(noteFit({ natural: 794.125, nodeHeight: 1000, chrome: 22 }), { fit: 816.125, grow: false });
|
|
});
|
|
|
|
test("noteFit ignores a card it could not measure", () => {
|
|
for (const natural of [null, undefined, NaN, 0, -5, "tall"]) {
|
|
assert.deepEqual(noteFit({ natural, nodeHeight: 810, chrome: 22 }), { fit: null, grow: false });
|
|
}
|
|
assert.deepEqual(noteFit({ natural: 794.125, nodeHeight: undefined, chrome: 22 }), { fit: null, grow: false });
|
|
});
|
|
|
|
test("parseInline handles bold, italic, code and safe links", () => {
|
|
assert.deepEqual(parseInline("a **b** *c* `d` [e](https://f.g) [h](javascript:x) i"), [
|
|
{ type: "text", text: "a " },
|
|
{ type: "strong", text: "b" },
|
|
{ type: "text", text: " " },
|
|
{ type: "em", text: "c" },
|
|
{ type: "text", text: " " },
|
|
{ type: "code", text: "d" },
|
|
{ type: "text", text: " " },
|
|
{ type: "link", text: "e", url: "https://f.g" },
|
|
{ type: "text", text: " " },
|
|
{ type: "text", text: "h" },
|
|
{ type: "text", text: " i" },
|
|
]);
|
|
// A lone asterisk or one inside a word is just text.
|
|
assert.deepEqual(parseInline("2 * 3 and snake_case*x"), [{ type: "text", text: "2 * 3 and snake_case*x" }]);
|
|
});
|
|
|
|
test("parseMarkdown yields headings, paragraphs with soft breaks, lists, rules and code", () => {
|
|
const blocks = parseMarkdown("# Title\n\nline one\nline two\n\n- a\n- **b**\n more\n1. one\n2. two\n\n---\n```\nx = 1\n```\n");
|
|
assert.deepEqual(blocks.map((b) => b.type), ["heading", "paragraph", "list", "list", "hr", "code"]);
|
|
assert.equal(blocks[0].level, 1);
|
|
assert.deepEqual(blocks[1].spans, [
|
|
{ type: "text", text: "line one" }, { type: "br" }, { type: "text", text: "line two" },
|
|
]);
|
|
assert.equal(blocks[2].ordered, false);
|
|
assert.deepEqual(blocks[2].items[1], [{ type: "strong", text: "b" }, { type: "br" }, { type: "text", text: "more" }]);
|
|
assert.equal(blocks[3].ordered, true);
|
|
assert.equal(blocks[5].text, "x = 1");
|
|
});
|
|
|
|
test("parseMarkdown on empty or plain text is a single paragraph or nothing", () => {
|
|
assert.deepEqual(parseMarkdown(""), []);
|
|
assert.deepEqual(parseMarkdown("just words"), [{ type: "paragraph", spans: [{ type: "text", text: "just words" }] }]);
|
|
});
|