A LoRA from the note's list that sits in a subfolder only showed as installed, while the model, text encoder and VAE lines offered Use it. The LoRA line now shows the folder and offers Use it as well, which writes the found path into the LoRA Loader row. LoRA Loader follows a row change made by another node in Nodes 2.0 too. Use it also wrote the found path with forward slashes. A Windows server lists files in subfolders with backslashes, and a loader only takes a file exactly as listed, so the loader kept an unknown value. Use it now writes the folder's own spelling.
270 lines
14 KiB
JavaScript
270 lines
14 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { readFileSync } from "node:fs";
|
|
import {
|
|
MODEL_COLUMNS,
|
|
baseName,
|
|
listedSpelling,
|
|
loadersNeeding,
|
|
loraRowsNeeding,
|
|
subfolderOf,
|
|
emptyNote,
|
|
folderPath,
|
|
groupModels,
|
|
hostLabel,
|
|
matchInstalled,
|
|
noteFit,
|
|
noteIsEmpty,
|
|
normalizeNote,
|
|
packsFromGraph,
|
|
parseInline,
|
|
parseMarkdown,
|
|
rowFromCells,
|
|
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("the editor's model table has a column for every field a model row keeps", () => {
|
|
// A field with no column is lost the first time someone saves the form.
|
|
const kept = Object.keys(normalizeNote({ models: [{ name: "m" }] }).models[0]);
|
|
assert.deepEqual(MODEL_COLUMNS.map((column) => column.key).sort(), kept.sort());
|
|
});
|
|
|
|
test("saving the editor's model table unchanged keeps every note", () => {
|
|
// Model rows from the LTX 2.3 Video Outpaint example, one without a note.
|
|
const note = normalizeNote({
|
|
title: "LTX 2.3 Video Outpaint",
|
|
models: [
|
|
{ name: "ltx-2.3_text_projection_bf16.safetensors", dir: "text_encoders", size: "2.2 GB",
|
|
url: "https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/text_encoders/ltx-2.3_text_projection_bf16.safetensors",
|
|
note: "second slot of the text encoder loader" },
|
|
{ name: "LTX23_video_vae_bf16.safetensors", dir: "vae", size: "1.4 GB" },
|
|
{ name: "ltx-2.3-22b-ic-lora-outpaint.safetensors", dir: "loras",
|
|
note: "outpaint IC-LoRA by oumoumad, fills pure-black bars" },
|
|
],
|
|
});
|
|
// What the form does: one cell per column, read back on Save.
|
|
const cells = note.models.map((row) => MODEL_COLUMNS.map((column) => row[column.key]));
|
|
const saved = normalizeNote({ ...note, models: cells.map((values) => rowFromCells(MODEL_COLUMNS, values)) });
|
|
assert.deepEqual(saved, note);
|
|
assert.deepEqual(saved.models.map((row) => row.note), [
|
|
"second slot of the text encoder loader", "", "outpaint IC-LoRA by oumoumad, fills pure-black bars",
|
|
]);
|
|
});
|
|
|
|
test("rowFromCells names each cell by its column and fills missing cells", () => {
|
|
const columns = [{ key: "label" }, { key: "url" }];
|
|
assert.deepEqual(rowFromCells(columns, ["GitHub", "https://github.com/ausboss"]), { label: "GitHub", url: "https://github.com/ausboss" });
|
|
assert.deepEqual(rowFromCells(columns, ["GitHub"]), { label: "GitHub", url: "" });
|
|
assert.deepEqual(rowFromCells(columns, undefined), { label: "", url: "" });
|
|
});
|
|
|
|
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" }] }]);
|
|
});
|
|
|
|
test("a loader asking for a bare name is pointed at the copy in a subfolder", () => {
|
|
assert.equal(subfolderOf("LTXV 2.3/ltx-2.3.safetensors"), "LTXV 2.3");
|
|
assert.equal(subfolderOf("a\\b\\c.safetensors"), "a/b");
|
|
assert.equal(subfolderOf("top.safetensors"), "");
|
|
const unet = { name: "unet_name", value: "ltx-2.3.safetensors", options: ["LTXV 2.3/ltx-2.3.safetensors", "other.safetensors"] };
|
|
const fine = { name: "vae_name", value: "LTX/vae.safetensors", options: ["LTX/vae.safetensors"] };
|
|
const bothCopies = { name: "clip", value: "gemma.safetensors", options: ["gemma.safetensors", "sub/gemma.safetensors"] };
|
|
const unrelated = { name: "sampler", value: "euler", options: ["euler", "dpmpp"] };
|
|
assert.deepEqual(loadersNeeding("ltx-2.3.safetensors", "LTXV 2.3/ltx-2.3.safetensors", [unet, fine, bothCopies, unrelated]), [unet]);
|
|
// A loader that already holds a copy it can load is left alone.
|
|
assert.deepEqual(loadersNeeding("vae.safetensors", "LTX/vae.safetensors", [fine]), []);
|
|
assert.deepEqual(loadersNeeding("gemma.safetensors", "sub/gemma.safetensors", [bothCopies]), []);
|
|
// Case and separators do not matter for the name.
|
|
const windows = { value: "LTX-2.3.SAFETENSORS", options: ["LTXV 2.3/ltx-2.3.safetensors"] };
|
|
assert.deepEqual(loadersNeeding("ltx-2.3.safetensors", "LTXV 2.3\\ltx-2.3.safetensors", [windows]), [windows]);
|
|
});
|
|
|
|
test("a LoRA Loader row with a bare name is pointed at the copy in a subfolder", () => {
|
|
const files = ["Krea 2/anypaint.safetensors", "top.safetensors", "Styles/ink.safetensors"];
|
|
const node = { title: "LoRA Loader" };
|
|
const widget = {};
|
|
const rows = [
|
|
{ name: "anypaint.safetensors" }, // bare name: the note's own spelling
|
|
{ name: "top.safetensors" }, // another file
|
|
{ name: "" }, // an empty row
|
|
{ name: "D:\\models\\ANYPAINT.safetensors" }, // a path from someone else's install
|
|
{ name: "Krea 2/anypaint.safetensors" }, // already the copy that was found
|
|
];
|
|
const needing = loraRowsNeeding("anypaint.safetensors", "Krea 2/anypaint.safetensors", [{ node, widget, rows }], files);
|
|
assert.deepEqual(needing.map((entry) => entry.index), [0, 3]);
|
|
assert.equal(needing[0].node, node);
|
|
assert.equal(needing[0].widget, widget);
|
|
// A row that holds another real copy of the file is the user's choice.
|
|
const twoCopies = ["Krea 2/anypaint.safetensors", "anypaint.safetensors"];
|
|
assert.deepEqual(loraRowsNeeding("anypaint.safetensors", "Krea 2/anypaint.safetensors", [{ node, widget, rows }], twoCopies).map((entry) => entry.index), [3]);
|
|
// Windows lists the folder with backslashes.
|
|
const windows = ["Krea 2\\anypaint.safetensors"];
|
|
assert.deepEqual(loraRowsNeeding("anypaint.safetensors", "Krea 2/anypaint.safetensors", [{ node, widget, rows }], windows).map((entry) => entry.index), [0, 3]);
|
|
// Nothing to point at when the found copy is not in the folder.
|
|
assert.deepEqual(loraRowsNeeding("anypaint.safetensors", "Gone/anypaint.safetensors", [{ node, widget, rows }], files), []);
|
|
assert.deepEqual(loraRowsNeeding("anypaint.safetensors", "Krea 2/anypaint.safetensors", null, files), []);
|
|
});
|
|
|
|
test("Use it writes a found file the way its folder lists it", () => {
|
|
// The note works with "/" paths; a Windows loader only takes "Krea 2\\model.safetensors".
|
|
assert.equal(listedSpelling("Krea 2/model.safetensors", ["other.safetensors", "Krea 2\\model.safetensors"]), "Krea 2\\model.safetensors");
|
|
assert.equal(listedSpelling("Krea 2/model.safetensors", ["Krea 2/model.safetensors"]), "Krea 2/model.safetensors");
|
|
assert.equal(listedSpelling("Krea 2\\model.safetensors", []), "Krea 2/model.safetensors");
|
|
const source = readFileSync(new URL("../js/workflow_note/index.js", import.meta.url), "utf-8");
|
|
assert.match(source, /commitWidgetValue\(entry\.node, entry\.widget, listedSpelling\(path, entry\.options\)/, "Use it hands a loader the path with the note's own slashes again");
|
|
assert.match(source, /name: listedSpelling\(path, files\)/, "Use it hands a LoRA row the path with the note's own slashes again");
|
|
});
|