Files
ausboss-ComfyUI-AusBoss/tests/run_check.test.mjs
T
ausboss f909b6704e Stop a run that has no picture yet, and show a failed check once
Many shared workflows save their loaders blank so people load their own
picture. Pressing Run first started the run, and core Load Image failed
inside it with "[Errno 21] Is a directory". Now the run stops before it
is sent: one message, "Load a picture first:" and the node's name, and
the loader gets a red outline (on the classic canvas and in Nodes 2.0).

It covers core Load Image, Load Image (as Mask), Load Image (from
Outputs) and Load Video, and the pack's Load Image + Pad, Image Crop +
Rotate + Pad, Load Video and both Video Crop + Rotate + Pad nodes (the
file or the local path, whichever the mode reads). It looks at the
prompt the frontend is about to send and walks it the way ComfyUI does,
so bypassed and muted loaders, loaders fed by a link, loaders no output
needs, loaders behind a lazy input and outputs left out of "Queue
selected output nodes" never stop a run. Prompts sent straight to the
API are not checked.

The frontend has no extension hook that can stop a queue, so
app.queuePrompt is wrapped; it only reads, and builds the prompt a
second time only when a blank loader is on the canvas. Anything
unexpected lets the run through unchanged.

When ComfyUI does reject a prompt, a check that reads several inputs
comes back once per input. api.queuePrompt's answer is tidied so each
AusBoss node shows one entry per message, on the input the message is
about. Other packs' nodes are left as ComfyUI sends them.
2026-09-29 21:44:01 -05:00

187 lines
9.1 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
collapseRepeatedErrors,
emptySourceMessage,
findEmptySources,
nodeLacksSource,
nodesThatRun,
} from "../js/shared/run_check.mjs";
// Node definitions as the frontend holds them: only output_node and the
// lazy flag matter to the check.
const DEFINITIONS = {
SaveImage: { output_node: true, input: { required: { images: ["IMAGE"] } } },
PreviewImage: { output_node: true, input: { required: { images: ["IMAGE"] } } },
AUSBOSS_NODES_SaveVideo: { output_node: true, input: { optional: { frames: ["IMAGE"] } } },
PickOne: { input: { required: { first: ["IMAGE", { lazy: true }], second: ["IMAGE"] } } },
};
const definitionOf = (classType) => DEFINITIONS[classType] ?? null;
const node = (class_type, inputs = {}, title = class_type) => ({ class_type, inputs, _meta: { title } });
test("a blank Load Image that feeds a save is found, by its own title", () => {
const prompt = {
1: node("LoadImage", { image: "" }, "Your image"),
2: node("SaveImage", { images: ["1", 0] }),
};
assert.deepEqual(findEmptySources(prompt, definitionOf), [{ id: "1", title: "Your image", input: "image", kind: "picture" }]);
assert.equal(emptySourceMessage(findEmptySources(prompt, definitionOf)), "Load a picture first: Your image");
});
test("a picked picture, a wired name or a missing widget value are told apart", () => {
const run = (image) => findEmptySources({ 1: node("LoadImage", { image }), 2: node("SaveImage", { images: ["1", 0] }) }, definitionOf);
assert.deepEqual(run("pier.png"), []);
assert.deepEqual(run(["7", 0]), [], "a name fed by another node is set at run time");
assert.equal(run(undefined).length, 1);
assert.equal(run(" ").length, 1);
});
test("a loader no output needs never blocks the run", () => {
const prompt = {
1: node("LoadImage", { image: "" }),
2: node("LoadImage", { image: "pier.png" }),
3: node("SaveImage", { images: ["2", 0] }),
};
assert.deepEqual(findEmptySources(prompt, definitionOf), []);
});
test("bypassed and muted loaders are not in the prompt, so they are never found", () => {
// The frontend leaves them out of the prompt and reroutes a bypassed one.
const prompt = { 2: node("LoadImage", { image: "pier.png" }), 3: node("SaveImage", { images: ["2", 0] }) };
assert.deepEqual(findEmptySources(prompt, definitionOf), []);
});
test("a loader only behind a lazy input may never be read, so it is not found", () => {
const prompt = {
1: node("LoadImage", { image: "" }),
2: node("LoadImage", { image: "pier.png" }),
3: node("PickOne", { first: ["1", 0], second: ["2", 0] }),
4: node("SaveImage", { images: ["3", 0] }),
};
assert.deepEqual(findEmptySources(prompt, definitionOf), []);
prompt[3].inputs = { first: ["2", 0], second: ["1", 0] };
assert.deepEqual(findEmptySources(prompt, definitionOf).map((found) => found.id), ["1"]);
});
test("queueing selected outputs only checks what those outputs read", () => {
const prompt = {
1: node("LoadImage", { image: "" }),
2: node("SaveImage", { images: ["1", 0] }),
3: node("LoadImage", { image: "pier.png" }),
4: node("PreviewImage", { images: ["3", 0] }),
};
assert.deepEqual(findEmptySources(prompt, definitionOf, ["4"]), []);
assert.deepEqual(findEmptySources(prompt, definitionOf, ["2"]).map((found) => found.id), ["1"]);
assert.deepEqual([...nodesThatRun(prompt, definitionOf, [])].sort(), ["1", "2", "3", "4"], "no targets: every output");
});
test("nodes inside subgraphs keep their execution ids", () => {
const prompt = {
"12:5": node("LoadImage", { image: "" }, "Inner image"),
"12:6": node("SaveImage", { images: ["12:5", 0] }),
};
assert.deepEqual(findEmptySources(prompt, definitionOf).map((found) => found.id), ["12:5"]);
});
test("Load Image + Pad needs no file while a picture is wired in", () => {
const run = (inputs) => findEmptySources({ 1: node("AUSBOSS_NODES_LoadImagePad", inputs), 2: node("SaveImage", { images: ["1", 0] }) }, definitionOf);
assert.equal(run({ image: "" }).length, 1);
assert.deepEqual(run({ image: "", source_image: ["9", 0] }), []);
});
test("the video nodes check the file or the local path, whichever the mode reads", () => {
const run = (class_type, inputs) =>
findEmptySources({ 1: node(class_type, inputs, "Clip"), 2: node("AUSBOSS_NODES_SaveVideo", { frames: ["1", 0] }) }, definitionOf);
for (const class_type of ["AUSBOSS_NODES_VideoCropRotatePad", "AUSBOSS_NODES_VideoCropRotatePadClip"]) {
assert.deepEqual(run(class_type, { video: "", source_mode: "input folder", local_path: "" }).map((f) => f.input), ["video"]);
assert.deepEqual(run(class_type, { video: "pier.mp4", source_mode: "input folder", local_path: "" }), []);
assert.deepEqual(run(class_type, { video: "", source_mode: "local path", local_path: "" }).map((f) => f.input), ["local_path"]);
assert.deepEqual(run(class_type, { video: "", source_mode: "local path", local_path: "/comfy/output/a.mp4" }), []);
assert.deepEqual(run(class_type, { video: "", source_mode: ["4", 0], local_path: "" }), [], "a wired mode is known at run time");
}
const found = run("AUSBOSS_NODES_LoadVideo", { video: "" });
assert.equal(emptySourceMessage(found), "Load a video first: Clip");
assert.deepEqual(run("LoadVideo", { file: "" }).map((f) => f.input), ["file"]);
});
test("the message names the first loader and counts the rest", () => {
const found = [
{ id: "1", title: "Your image", kind: "picture" },
{ id: "2", title: "Load Video 🆎", kind: "video" },
{ id: "3", title: "Mask", kind: "picture" },
];
assert.equal(emptySourceMessage(found.slice(0, 2)), "Load a picture first: Your image (and 1 more node)");
assert.equal(emptySourceMessage(found), "Load a picture first: Your image (and 2 more nodes)");
assert.equal(emptySourceMessage([]), "");
});
test("the widget scan reads the live node the same way", () => {
const widget = (name, value) => ({ name, value });
assert.equal(nodeLacksSource({ comfyClass: "LoadImage", widgets: [widget("image", "")] }), true);
assert.equal(nodeLacksSource({ comfyClass: "LoadImage", widgets: [widget("image", "pier.png")] }), false);
assert.equal(
nodeLacksSource({ comfyClass: "LoadImage", widgets: [widget("image", "")], inputs: [{ name: "image", widget: { name: "image" }, link: 4 }] }),
false,
"a widget fed by a link is set",
);
assert.equal(
nodeLacksSource({ comfyClass: "AUSBOSS_NODES_LoadImagePad", widgets: [widget("image", "")], inputs: [{ name: "source_image", link: 9 }] }),
false,
);
assert.equal(nodeLacksSource({ type: "KSampler", widgets: [widget("seed", 0)] }), false);
assert.equal(nodeLacksSource(null), false);
});
// ---------------------------------------------------------------- collapse
const failed = (input_name, message) => ({
type: "custom_validation_failed",
message: "Custom validation failed for node",
details: `${input_name} - ${message}`,
extra_info: { input_name },
});
test("a failed check repeated on every input it read comes back once", () => {
const message = "Video Crop + Rotate + Pad: Select or upload a source file first.";
const errors = {
7: {
class_type: "AUSBOSS_NODES_VideoCropRotatePadClip",
errors: [failed("video", message), failed("source_mode", message), failed("local_path", message)],
dependent_outputs: ["9"],
},
};
collapseRepeatedErrors(errors);
assert.deepEqual(errors[7].errors.map((error) => error.extra_info.input_name), ["video"]);
assert.deepEqual(errors[7].dependent_outputs, ["9"]);
});
test("the error stays on the input its message is about", () => {
const nameMessage = "Save Image: exact_name may not contain '..'.";
const pathMessage = "Video Crop + Rotate + Pad: Local path mode requires a video path.";
const errors = {
1: { class_type: "AUSBOSS_NODES_SaveImage", errors: ["filename_prefix", "exact_name", "output_dir"].map((name) => failed(name, nameMessage)) },
2: { class_type: "AUSBOSS_NODES_VideoCropRotatePad", errors: ["video", "source_mode", "local_path"].map((name) => failed(name, pathMessage)) },
};
collapseRepeatedErrors(errors);
assert.deepEqual(errors[1].errors.map((error) => error.extra_info.input_name), ["exact_name"]);
assert.deepEqual(errors[2].errors.map((error) => error.extra_info.input_name), ["local_path"]);
});
test("different problems, other kinds of error and other packs' nodes are left alone", () => {
const range = { type: "value_smaller_than_min", message: "Value -5 smaller than min of 0", details: "crop_x", extra_info: { input_name: "crop_x" } };
const errors = {
1: { class_type: "AUSBOSS_NODES_LoraLoader", errors: [failed("loras", "LoRA Loader: a"), failed("on_missing", "LoRA Loader: a"), failed("loras", "LoRA Loader: b"), range] },
2: { class_type: "SomePackLoader", errors: [failed("x", "same"), failed("y", "same")] },
};
collapseRepeatedErrors(errors);
assert.deepEqual(errors[1].errors.map((error) => [error.extra_info.input_name, error.details]), [
["loras", "loras - LoRA Loader: a"],
["loras", "loras - LoRA Loader: b"],
["crop_x", "crop_x"],
]);
assert.equal(errors[2].errors.length, 2);
assert.equal(collapseRepeatedErrors(null), null);
});