845 lines
45 KiB
JavaScript
845 lines
45 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert";
|
|
import { createPipelineState } from "../pipeline_state.js";
|
|
|
|
let lastApi;
|
|
function load(posts, opts = {}) {
|
|
const defaults = [
|
|
{ id: "model", node: "Loader", group: "Loaders", inputs: { file: "", dtype: "bf16" } },
|
|
{ id: "gen", node: "Gen", group: "Sampling", inputs: { steps: 8, model: ["model", 0] } },
|
|
];
|
|
const API = {
|
|
pipeline: async () => ({ slots: JSON.parse(JSON.stringify(defaults)), incomplete: [], refused: [], queueable: true }),
|
|
editPipeline: async (body) => {
|
|
body = JSON.parse(JSON.stringify(body)); // sent over HTTP: the server never shares the page's objects
|
|
posts.push(body);
|
|
if (body.action === "replace") { const sl = body.slots.find((x) => x.id === body.slot); if (sl) { sl.node = body.node; sl.inputs = {}; } }
|
|
if (opts.delay) await new Promise((r) => setTimeout(r, opts.delay));
|
|
const bad = (body.slots || []).findIndex((s) => typeof s.node !== "string");
|
|
if (bad >= 0) { const e = new Error(`slot ${bad} has no node`); throw e; } for (const [id, edits] of Object.entries(body.inputs || {})) { const sl = body.slots.find((x) => x.id === id); if (sl) sl.inputs = { ...sl.inputs, ...edits }; }
|
|
return { slots: body.slots, incomplete: [], refused: [], queueable: true }; },
|
|
modules: async () => ({ modules: [] }),
|
|
probeFamily: async () => ({}),
|
|
};
|
|
lastApi = API;
|
|
return createPipelineState(API, { answerMs: opts.answerMs });
|
|
}
|
|
|
|
test("a project's saved pipeline lays its values over the server's default, keeping what the default added", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.adopt([
|
|
{ id: "model", node: "Loader", group: "Mine", inputs: { file: "h3.safetensors" } },
|
|
{ id: "gen", node: "OtherNode", inputs: { steps: 99 } }, // a swapped node: the person's swap stays
|
|
{ id: "extra", node: "Added", inputs: { x: 1 } }, // the person's own
|
|
]);
|
|
const by = Object.fromEntries(PS.slots().map((s) => [s.id, s]));
|
|
assert.deepStrictEqual(by.model.inputs, { file: "h3.safetensors", dtype: "bf16" });
|
|
assert.strictEqual(by.model.group, "Mine");
|
|
assert.deepStrictEqual([by.gen.node, by.gen.inputs.steps], ["OtherNode", 99]);
|
|
assert.ok(by.extra);
|
|
});
|
|
|
|
test("a project's pipeline going in is announced once it is in, so views redraw; a real edit is announced too", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
let heard = [];
|
|
PS.subscribe((s) => { heard.push(s.find((x) => x.id === "model").inputs.file); });
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "a" } }]);
|
|
assert.deepStrictEqual(heard, ["a"]);
|
|
await PS.save({ inputs: { model: { file: "b" } } });
|
|
assert.deepStrictEqual(heard, ["a", "b"]);
|
|
});
|
|
|
|
test("nothing saved leaves the session's pipeline as it is", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const before = JSON.stringify(PS.slots());
|
|
await PS.adopt([]);
|
|
assert.strictEqual(JSON.stringify(PS.slots()), before);
|
|
});
|
|
|
|
test("a v4-shaped or hostile saved slot cannot brick the pipeline", async () => {
|
|
const PS = load([]);
|
|
await PS.adopt([
|
|
{ id: "model", node: "Loader", inputs: { file: "h3.safetensors" } },
|
|
{ id: "abc123", node_class: "OldV4Node", inputs: {} },
|
|
{ id: "bad", node: "X", inputs: [1, 2] },
|
|
]);
|
|
assert.deepStrictEqual(PS.slots().map((s) => s.id), ["model", "gen"]);
|
|
assert.strictEqual(PS.slots()[0].inputs.file, "h3.safetensors");
|
|
await PS.save({ inputs: { model: { file: "new" } } });
|
|
assert.strictEqual(PS.slots()[0].inputs.file, "new");
|
|
});
|
|
|
|
test("an answer about the previous project's pipeline does not overwrite the one just opened", async () => {
|
|
const PS = load([], { delay: 30 });
|
|
await PS.ensureLoaded();
|
|
const inflight = PS.save({ inputs: { model: { file: "A-file" } } });
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "B-file" } }]);
|
|
await inflight;
|
|
assert.strictEqual(PS.slots()[0].inputs.file, "B-file");
|
|
});
|
|
|
|
test("nothing loaded (ComfyUI unreachable) leaves slots null, never an empty pipeline", async () => {
|
|
const PS = load([]);
|
|
lastApi.pipeline = async () => { throw new Error("down"); };
|
|
const P2 = createPipelineState(lastApi);
|
|
await P2.adopt([{ id: "model", node: "Loader", inputs: {} }]);
|
|
assert.strictEqual(P2.slots(), null);
|
|
});
|
|
|
|
test("a project's pipeline waits out an unreachable server and goes in on the first successful load", async () => {
|
|
const PS = load([]);
|
|
const good = lastApi.pipeline;
|
|
let up = false;
|
|
lastApi.pipeline = async () => { if (!up) throw new Error("down"); return good(); };
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "h3.safetensors" } }]);
|
|
assert.strictEqual(PS.slots(), null);
|
|
up = true;
|
|
await PS.ensureLoaded();
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "h3.safetensors");
|
|
});
|
|
|
|
test("adopt says so when the project's pipeline could not go in", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
lastApi.editPipeline = async () => { throw new Error("tunnel"); };
|
|
assert.strictEqual(await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }]), false);
|
|
assert.strictEqual(await PS.adopt([]), true);
|
|
});
|
|
|
|
test("an edit made while a project's pipeline goes in lands on that pipeline", async () => {
|
|
const PS = load([], { delay: 30 });
|
|
await PS.ensureLoaded();
|
|
const adopting = PS.adopt([{ id: "model", node: "Loader", inputs: { file: "B-file", dtype: "fp8" } }]);
|
|
const edit = PS.save({ inputs: { model: { dtype: "int8" } } });
|
|
await Promise.all([adopting, edit]);
|
|
const model = PS.slots().find((s) => s.id === "model");
|
|
assert.deepStrictEqual([model.inputs.file, model.inputs.dtype], ["B-file", "int8"]);
|
|
});
|
|
|
|
test("a failed module-list fetch is retried on the next save, not remembered as done", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
let calls = 0;
|
|
lastApi.modules = async () => { if (++calls === 1) throw new Error("down"); return { modules: [{ id: "m" }] }; };
|
|
await PS.ensureLoaded();
|
|
assert.deepStrictEqual(Object.keys(PS.modulesById()), []);
|
|
await PS.save({ inputs: { model: { file: "z.safetensors" } } });
|
|
assert.deepStrictEqual(Object.keys(PS.modulesById()), ["m"]);
|
|
});
|
|
|
|
|
|
test("a slot the person removed stays removed when the project is opened again", async () => {
|
|
const PS = load([]);
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: {} }], ["gen"]);
|
|
assert.deepStrictEqual(PS.slots().map((s) => s.id), ["model"]);
|
|
assert.deepStrictEqual(PS.removedIds(), ["gen"]);
|
|
});
|
|
|
|
test("a structural edit is sent on its own and its refusal is handed back", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.ensureLoaded();
|
|
lastApi.editPipeline = async (body) => {
|
|
posts.push(body);
|
|
return { slots: body.slots, refused: ["nope"], incomplete: [], queueable: false };
|
|
};
|
|
const res = await PS.edit({ action: "remove", slot: "gen" });
|
|
assert.deepStrictEqual(res.refused, ["nope"]);
|
|
assert.strictEqual(posts.at(-1).action, "remove");
|
|
assert.deepStrictEqual(PS.removedIds(), []); // refused: nothing is remembered as removed
|
|
});
|
|
|
|
test("a default link the person unwired stays unwired when the project is opened again", async () => {
|
|
const PS = load([]);
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } },
|
|
{ id: "gen", node: "Gen", inputs: { steps: 8 } }], [], { gen: ["model"] });
|
|
assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, false);
|
|
assert.deepStrictEqual(PS.unwiredMap(), { gen: ["model"] });
|
|
});
|
|
|
|
test("a default link an older project never had (an update added it) is kept", async () => {
|
|
const PS = load([]);
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } },
|
|
{ id: "gen", node: "Gen", inputs: { steps: 8 } }]); // nothing recorded as unwired
|
|
assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, true);
|
|
});
|
|
|
|
test("a group moved while a save is in flight is still moved when it lands", async () => {
|
|
const PS = load([], { delay: 30 });
|
|
await PS.ensureLoaded();
|
|
const inflight = PS.save({ inputs: { model: { file: "z" } } });
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await PS.setGroup("gen", "Mine");
|
|
await inflight;
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "gen").group, "Mine");
|
|
});
|
|
|
|
test("a bypass switched while a save is in flight still holds when it lands, and travels with the saved pipeline", async () => {
|
|
const PS = load([], { delay: 30 });
|
|
await PS.ensureLoaded();
|
|
const inflight = PS.save({ inputs: { model: { file: "z" } } });
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await PS.setBypass("gen", true);
|
|
await inflight;
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "gen").bypassed, true);
|
|
const again = load([]);
|
|
await again.adopt(JSON.parse(JSON.stringify(PS.slots())), [], {});
|
|
assert.strictEqual(again.slots().find((s) => s.id === "gen").bypassed, true);
|
|
await PS.setBypass("gen", false);
|
|
assert.strictEqual("bypassed" in PS.slots().find((s) => s.id === "gen"), false, "off leaves no flag behind");
|
|
});
|
|
|
|
test("a default slot swapped for the same node, or removed and added back, does not get its links back on reopen", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const API = lastApi;
|
|
API.editPipeline = async (body) => ({ refused: [], incomplete: [], queueable: true,
|
|
slots: body.slots.map((s) => (s.id === "gen" ? { ...s, inputs: {} } : s)) });
|
|
await PS.edit({ action: "replace", slot: "gen", node: "Gen" });
|
|
assert.deepStrictEqual(PS.unwiredMap(), { gen: ["model"] });
|
|
const saved = JSON.parse(JSON.stringify(PS.slots()));
|
|
const again = load([]);
|
|
await again.adopt(saved, [], PS.unwiredMap());
|
|
assert.strictEqual("model" in again.slots().find((s) => s.id === "gen").inputs, false);
|
|
});
|
|
|
|
test("a project with every slot removed does not get the default back", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.adopt([], ["model", "gen"]);
|
|
assert.ok(posts.length > 0);
|
|
assert.deepStrictEqual(posts.at(-1).slots, []);
|
|
});
|
|
|
|
test("a group change queued behind a save in flight is not re-stamped over a later group", async () => {
|
|
const PS = load([], { delay: 20 });
|
|
await PS.ensureLoaded();
|
|
const inflight = PS.save({ inputs: { model: { file: "z" } } });
|
|
const first = PS.setGroup("gen", "Mine");
|
|
await Promise.all([inflight, first]);
|
|
await PS.setGroup("gen", "Other");
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "gen").group, "Other");
|
|
});
|
|
|
|
test("restore puts back the pipeline as snapshotted: slots, removed, unwired, and tells the project", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const snap = PS.snapshot();
|
|
await PS.save({ inputs: { model: { file: "changed" } } });
|
|
lastApi.editPipeline = async (body) => ({ refused: [], incomplete: [], queueable: true, slots: body.slots });
|
|
await PS.edit({ action: "remove", slot: "gen" });
|
|
assert.deepStrictEqual(PS.removedIds(), ["gen"]);
|
|
let heard = 0;
|
|
PS.subscribe(() => { heard++; });
|
|
const res = await PS.restore(snap);
|
|
assert.deepStrictEqual(res.refused, []);
|
|
assert.deepStrictEqual(PS.slots(), snap.slots);
|
|
assert.deepStrictEqual(PS.removedIds(), []);
|
|
assert.strictEqual(heard, 1);
|
|
});
|
|
|
|
test("a refused restore leaves the pipeline as it is", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const snap = PS.snapshot();
|
|
await PS.save({ inputs: { model: { file: "changed" } } });
|
|
lastApi.editPipeline = async () => ({ refused: ["no"], slots: [] });
|
|
const res = await PS.restore(snap);
|
|
assert.deepStrictEqual(res.refused, ["no"]);
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "changed");
|
|
});
|
|
|
|
test("a module switched off is remembered with the settings, listed as off, and can be switched back on", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.ensureLoaded();
|
|
lastApi.modules = async () => ({ modules: [{ id: "sharpen", settings: {} }, { id: "alg", settings: {} }], control: {} });
|
|
await PS.refreshControl();
|
|
await PS.setOff("sharpen", true);
|
|
assert.strictEqual(PS.isOff("sharpen"), true);
|
|
assert.strictEqual(PS.isOff("alg"), false);
|
|
assert.deepStrictEqual(posts.at(-1).values._off, { modules: ["sharpen"] });
|
|
await PS.setOff("alg", true);
|
|
await PS.setOff("sharpen", false);
|
|
assert.deepStrictEqual(posts.at(-1).values._off, { modules: ["alg"] });
|
|
});
|
|
|
|
test("Disable all enhancements is saved on its own, hides every module, and keeps the per-module choices underneath", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.ensureLoaded();
|
|
lastApi.modules = async () => ({ modules: [{ id: "sharpen", settings: {} }, { id: "alg", settings: {} }], control: {} });
|
|
await PS.refreshControl();
|
|
await PS.setOff("alg", true);
|
|
await PS.setAllOff(true);
|
|
assert.strictEqual(PS.allOff(), true);
|
|
assert.deepStrictEqual(posts.at(-1).values._off, { modules: ["alg"], all: true });
|
|
assert.deepStrictEqual(PS.activeModules(), []);
|
|
await PS.setAllOff(false);
|
|
assert.strictEqual(PS.allOff(), false);
|
|
assert.strictEqual(PS.isOff("alg"), true);
|
|
});
|
|
|
|
test("a value edit is in the live slots at once, before any save answers", async () => {
|
|
const PS = load([]);
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "" } }, { id: "cfg", node: "Settings", inputs: { values: "{\"_off\":{}}" } }]);
|
|
const sink = () => PS.slots().find((x) => x.id === "cfg").inputs.values;
|
|
PS.setAllOff(true);
|
|
assert.strictEqual(JSON.parse(sink())._off.all, true);
|
|
});
|
|
|
|
test("a project opened after another does not inherit the first one's pending switch", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
await PS.setOff("sharpen", true);
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }]);
|
|
assert.strictEqual(PS.isOff("sharpen"), false);
|
|
});
|
|
|
|
test("a pipeline replaced whole (a preset) is reopened as it was: no default slot or role comes back", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const preset = [{ id: "ckpt", node: "Checkpoint", inputs: { file: "sd.safetensors" } },
|
|
{ id: "gen", node: "Gen", roles: [{ at: "generation.seed", input: "seed" }], inputs: { steps: 20, model: ["ckpt", 0] } }];
|
|
await PS.restore({ slots: preset, removed: [], unwired: {} });
|
|
assert.deepStrictEqual([PS.removedIds(), PS.whole()], [["model"], true]);
|
|
const again = load([]);
|
|
await again.adopt(PS.slots(), PS.removedIds(), {}, PS.whole());
|
|
assert.deepStrictEqual(again.slots(), preset);
|
|
assert.strictEqual(again.whole(), true);
|
|
});
|
|
|
|
test("a whole pipeline saved before the flag existed is still reopened as it was", async () => {
|
|
const preset = [{ id: "ckpt", node: "Checkpoint", inputs: { file: "sd.safetensors" } }, { id: "gen", node: "Gen", inputs: { steps: 20 } }];
|
|
const PS = load([]);
|
|
await PS.adopt(preset, [], {});
|
|
assert.deepStrictEqual(PS.slots(), preset);
|
|
});
|
|
|
|
test("an edited default stays a layer: the default's new values still fill in, and Revert to it is not whole", async () => {
|
|
const PS = load([]);
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "a" } }, { id: "gen", node: "Gen", inputs: { steps: 3 } }], [], {}, false);
|
|
assert.strictEqual(PS.whole(), false);
|
|
assert.deepStrictEqual(PS.slots().find((s) => s.id === "model").inputs, { file: "a", dtype: "bf16" });
|
|
const snap = PS.snapshot();
|
|
await PS.restore({ slots: [{ id: "x", node: "X", inputs: {} }], removed: [], unwired: {} });
|
|
await PS.restore(snap);
|
|
assert.strictEqual(PS.whole(), false);
|
|
});
|
|
|
|
const withCheckpoint = (PS) => { lastApi.pipeline = async () => ({ slots: [{ id: "model", node: "FunUICheckpointLoader", inputs: { ckpt_name: "a" } }], incomplete: [], refused: [], queueable: true }); return PS; };
|
|
|
|
test("a module's settings count only when something would read them: its own node, or a user of what it serves that is on", async () => {
|
|
const learner = (mode, enabled = true) => ({ id: "learner", settings: { enabled: { type: "bool", default: enabled }, mode: { type: "enum", default: mode } }, uses: ["taste_store"], uses_when: { mode: "learned" } });
|
|
const cam = { id: "cam", nodes: ["CamNode"], settings: { on: { default: false } } }, taste = { id: "taste", serves: ["taste_store"], settings: { key: { default: "" } } };
|
|
const check = async (mods) => { const PS = withCheckpoint(load([])); lastApi.modules = async () => ({ modules: mods }); await PS.ensureLoaded(); const by = PS.modulesById(); return [PS.useful(by.cam), PS.useful(by.taste)]; };
|
|
assert.deepStrictEqual(await check([cam, taste, learner("learned")]), [false, true]); // the pipeline has no CamNode
|
|
assert.deepStrictEqual(await check([cam, taste, learner("manual")]), [false, false]); // its one user is on a manual value
|
|
const always = { ...learner("manual"), uses_when: {} }; // a manual mode that still reads the store (shot memory)
|
|
assert.deepStrictEqual(await check([cam, taste, always]), [false, true]);
|
|
assert.deepStrictEqual(await check([cam, taste, learner("learned", false)]), [false, false]);
|
|
assert.deepStrictEqual(await check([{ ...cam, nodes: ["FunUICheckpointLoader"] }, taste]), [true, false]);
|
|
});
|
|
|
|
test("a file picked in a save is announced after its modules are known", async () => {
|
|
const PS = withCheckpoint(load([]));
|
|
await PS.ensureLoaded();
|
|
lastApi.probeFamily = async () => ({ detected: true, traits: [] });
|
|
lastApi.modules = async () => ({ modules: [{ id: "new" }] });
|
|
let seen = null;
|
|
PS.subscribe(() => { seen = Object.keys(PS.modulesById()); });
|
|
await PS.save({ inputs: { model: { ckpt_name: "picked" } } });
|
|
assert.deepStrictEqual(seen, ["new"]);
|
|
});
|
|
|
|
test("a model file no module recognises says so, and why nothing is hidden", async () => {
|
|
const PS = withCheckpoint(load([]));
|
|
lastApi.probeFamily = async () => ({ detected: false, reason: "a.ckpt: only .safetensors can be inspected without loading it" });
|
|
await PS.ensureLoaded();
|
|
assert.match(PS.saveNotes().join(" "), /only \.safetensors.*every module is offered/);
|
|
});
|
|
|
|
test("a save reaches listeners before the model probe answers, so a project switched to meanwhile cannot take it", async () => {
|
|
const PS = withCheckpoint(load([]));
|
|
await PS.ensureLoaded();
|
|
let answer;
|
|
lastApi.probeFamily = () => new Promise((r) => { answer = r; });
|
|
let heard = 0;
|
|
PS.subscribe(() => { heard += 1; });
|
|
const saving = PS.save({ inputs: { model: { ckpt_name: "picked" } } });
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.ok(heard >= 1, "announced while the probe is still out");
|
|
answer({ detected: true, traits: [] });
|
|
await saving;
|
|
});
|
|
|
|
const sink = (blob) => ({ id: "settings", node: "Sink", inputs: { settings: JSON.stringify(blob) } });
|
|
|
|
test("a setting changed while another project's pipeline goes in is built on that project's values", async () => {
|
|
const posts = [];
|
|
const PS = load(posts, { delay: 30 });
|
|
lastApi.modules = async () => ({ modules: [{ id: "sharpen", settings: { enabled: { type: "bool", default: false }, amount: { type: "float", default: 0.5 } } }] });
|
|
const edit = lastApi.editPipeline;
|
|
lastApi.editPipeline = (body) => { // the server writes the whole values blob into the sink, as core's place() does
|
|
if (body.values) body.slots.filter((x) => x.node === "Sink").forEach((x) => { x.inputs = { settings: JSON.stringify(body.values) }; });
|
|
return edit(body);
|
|
};
|
|
await PS.adopt([sink({ sharpen: { enabled: true, amount: 0.9 } })]);
|
|
const opening = PS.adopt([sink({ sharpen: { enabled: false, amount: 0.1 } })]);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await PS.setModuleValue("sharpen", "amount", 0.5);
|
|
await opening;
|
|
assert.deepStrictEqual(PS.currentValues().sharpen, { enabled: false, amount: 0.5 });
|
|
});
|
|
|
|
test("a new project made while another project's pipeline goes in inherits it, never a half-replaced one", async () => {
|
|
const posts = [];
|
|
const PS = load(posts, { delay: 30 });
|
|
await PS.ensureLoaded();
|
|
const opening = PS.adopt([{ id: "model", node: "Loader", inputs: { file: "A" } }]);
|
|
const fresh = PS.adopt([], [], {}, undefined, true);
|
|
let seenByNew = null;
|
|
await fresh.then(() => { seenByNew = PS.slots().find((s) => s.id === "model").inputs.file; });
|
|
await opening;
|
|
assert.strictEqual(seenByNew, "A");
|
|
});
|
|
|
|
test("a modifier's settings count only when the pipeline has both the settings node and the loader that installs it", async () => {
|
|
const loader = { id: "mods", nodes: ["FunUIModifierSettings", "FunUILoadModifiers"], uses: ["modifier"] };
|
|
const sharpen = { id: "sharpen", serves: ["modifier"], settings: { amount: { default: 0.5 } } };
|
|
const learner = { id: "q", serves: ["modifier"], uses: ["taste_store"], settings: { enabled: { type: "bool", default: true } } };
|
|
const taste = { id: "taste", serves: ["modifier", "taste_store"], settings: { key: { default: "" } } };
|
|
const check = async (nodes, mods = [loader, sharpen, learner, taste]) => {
|
|
const PS = load([]);
|
|
lastApi.pipeline = async () => ({ slots: nodes.map((node, i) => ({ id: `s${i}`, node, inputs: {} })), incomplete: [], refused: [], queueable: true });
|
|
lastApi.modules = async () => ({ modules: mods });
|
|
await PS.ensureLoaded();
|
|
const by = PS.modulesById();
|
|
return [PS.useful(by.sharpen), PS.useful(by.taste)];
|
|
};
|
|
assert.deepStrictEqual(await check(["FunUIModifierSettings", "FunUILoadModifiers"]), [true, true]);
|
|
assert.deepStrictEqual(await check(["FunUIModifierSettings"]), [false, false]); // loader removed: nothing installs them, so no learner reads taste
|
|
assert.deepStrictEqual(await check(["CheckpointLoaderSimple", "KSampler"]), [false, false]); // a plain imported workflow
|
|
assert.deepStrictEqual(await check(["FunUIModifierSettings", "FunUILoadModifiers"], [loader, sharpen, taste]), [true, false]); // the key installed, but no learner reads it
|
|
});
|
|
|
|
test("a project retried after the first load failed goes in, and nothing waits forever", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
const real = lastApi.pipeline;
|
|
lastApi.pipeline = async () => { throw new Error("tunnel blip"); };
|
|
const saved = [{ id: "model", node: "Loader", inputs: { file: "mine" } }];
|
|
assert.strictEqual(await PS.adopt(saved), false);
|
|
lastApi.pipeline = real;
|
|
const done = await Promise.race([PS.adopt(saved), new Promise((r) => setTimeout(() => r("HUNG"), 1000))]);
|
|
assert.strictEqual(done, true);
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "mine");
|
|
await Promise.race([PS.save({ inputs: { model: { file: "next" } } }), new Promise((_, no) => setTimeout(() => no(new Error("save hung")), 1000))]);
|
|
});
|
|
|
|
test("with every enhancement disabled, the modules kept underneath still count, so the Modules page can list them", async () => {
|
|
const loader = { id: "mods", nodes: ["FunUIModifierSettings", "FunUILoadModifiers"], uses: ["modifier"] };
|
|
const sharpen = { id: "sharpen", serves: ["modifier"], settings: { amount: { default: 0.5 } } };
|
|
const PS = load([]);
|
|
lastApi.pipeline = async () => ({ slots: [{ id: "a", node: "FunUIModifierSettings", inputs: {} }, { id: "b", node: "FunUILoadModifiers", inputs: {} }], incomplete: [], refused: [], queueable: true });
|
|
lastApi.modules = async () => ({ modules: [loader, sharpen], control: {} });
|
|
await PS.ensureLoaded();
|
|
await PS.setAllOff(true);
|
|
assert.strictEqual(PS.allOff(), true);
|
|
assert.strictEqual(PS.usefulness()(PS.modulesById().sharpen), true);
|
|
});
|
|
|
|
test("a request that never answers gives up, so the pipeline loads once the server answers and edits go through", async () => {
|
|
const PS = load([], { answerMs: 50 });
|
|
const real = lastApi.pipeline;
|
|
lastApi.pipeline = () => new Promise(() => {}); // hangs forever
|
|
const saved = [{ id: "model", node: "Loader", inputs: { file: "mine" } }];
|
|
assert.strictEqual(await PS.adopt(saved), false);
|
|
lastApi.pipeline = real;
|
|
assert.strictEqual(await PS.adopt(saved), true);
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "mine");
|
|
await PS.save({ inputs: { model: { file: "next" } } });
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "next");
|
|
});
|
|
|
|
test("a module list that failed once is asked for again, and the error clears when it comes", async () => {
|
|
const PS = load([]);
|
|
const real = async () => ({ modules: [{ id: "sharpen", settings: {} }] });
|
|
lastApi.modules = async () => { throw new Error("HTTP 500"); };
|
|
await PS.ensureLoaded();
|
|
assert.ok(PS.loadError());
|
|
lastApi.modules = real;
|
|
let heard = 0;
|
|
PS.subscribe(() => { heard += 1; });
|
|
await PS.ensureLoaded();
|
|
assert.deepStrictEqual([PS.loadError(), Object.keys(PS.modulesById()), heard], [null, ["sharpen"], 1]);
|
|
});
|
|
|
|
test("a project opened while ComfyUI is down never gets an earlier project's waiting pipeline", async () => {
|
|
const PS = load([]);
|
|
const real = lastApi.pipeline;
|
|
lastApi.pipeline = async () => { throw new Error("down"); };
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "A" } }]);
|
|
await PS.adopt([], [], {}, undefined, false); // an older project with nothing saved
|
|
lastApi.pipeline = real;
|
|
await PS.ensureLoaded();
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "");
|
|
});
|
|
|
|
test("a saved pipeline the server answers with a refusal is a refusal, not an outage; no answer is an outage", async () => {
|
|
for (const [err, outage] of [[Object.assign(new Error("slot 0 role 0 has no input"), { status: 400 }), false], [new Error("Failed to fetch"), true], [Object.assign(new Error("Bad gateway"), { status: 502 }), true], [Object.assign(new Error("Internal Server Error"), { status: 500 }), true]]) {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const real = lastApi.editPipeline;
|
|
lastApi.editPipeline = async () => { throw err; };
|
|
assert.strictEqual(await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }]), false);
|
|
assert.strictEqual(PS.unreachable(), outage, err.message);
|
|
lastApi.editPipeline = real;
|
|
}
|
|
});
|
|
|
|
test("settings can be read from a frozen pipeline, apart from the live one and edits not yet landed", async () => {
|
|
const PS = load([]);
|
|
lastApi.modules = async () => ({ modules: [{ id: "continuity", settings: { carry: { type: "bool", default: true } } }] });
|
|
await PS.adopt([{ id: "settings", node: "Sink", inputs: { settings: JSON.stringify({ continuity: { carry: true } }) } }]);
|
|
const frozen = structuredClone(PS.slots());
|
|
await PS.adopt([{ id: "settings", node: "Sink", inputs: { settings: JSON.stringify({ continuity: { carry: false }, _off: { all: true } }) } }]);
|
|
assert.deepStrictEqual([PS.currentValues(frozen).continuity.carry, PS.allOff(frozen)], [true, false]);
|
|
assert.deepStrictEqual([PS.currentValues().continuity.carry, PS.allOff()], [false, true]);
|
|
});
|
|
|
|
const withSink = (blob, mods) => {
|
|
const PS = load([]);
|
|
lastApi.pipeline = async () => ({ slots: [{ id: "settings", node: "Sink", inputs: { settings: blob } }, { id: "other", node: "X", inputs: { note: '{"sharpen": {"amount": 9}}' } }], incomplete: [], refused: [], queueable: true });
|
|
lastApi.modules = async () => ({ modules: mods, sinks: [{ node: "Sink", input: "settings" }], control: {} });
|
|
return PS;
|
|
};
|
|
const sharpen = { id: "sharpen", settings: { enabled: { type: "bool", default: false }, amount: { type: "float", default: 0.5 } } };
|
|
|
|
test("a first setting on a fresh pipeline is in its settings slot at once, so a failed save cannot leave the run on old values", async () => {
|
|
const PS = withSink("{}", [sharpen]);
|
|
await PS.ensureLoaded();
|
|
lastApi.editPipeline = async () => { throw new Error("boom"); };
|
|
await PS.setModuleValue("sharpen", "enabled", true);
|
|
assert.strictEqual(JSON.parse(PS.slots().find((s) => s.id === "settings").inputs.settings).sharpen.enabled, true);
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "other").inputs.note, '{"sharpen": {"amount": 9}}'); // not a sink: never read or written
|
|
});
|
|
|
|
test("settings of a module the current model hides are kept when another setting is changed", async () => {
|
|
const PS = withSink(JSON.stringify({ reins: { enabled: true } }), [sharpen]);
|
|
await PS.ensureLoaded();
|
|
await PS.setModuleValue("sharpen", "enabled", true);
|
|
const blob = JSON.parse(PS.slots().find((s) => s.id === "settings").inputs.settings);
|
|
assert.deepStrictEqual([blob.reins, blob.sharpen.enabled, PS.currentValues().sharpen.amount], [{ enabled: true }, true, 0.5]);
|
|
});
|
|
|
|
test("a pipeline the server refuses leaves the plain default with no flags from the project it could not load", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
lastApi.editPipeline = async () => { throw Object.assign(new Error("refused"), { status: 400 }); };
|
|
assert.strictEqual(await PS.adopt([{ id: "model", node: "Loader", inputs: {} }], ["gen"], { model: ["x"] }, true), false);
|
|
assert.deepStrictEqual([PS.removedIds(), PS.unwiredMap(), PS.whole()], [[], {}, false]);
|
|
});
|
|
|
|
test("a second setting changed while the first is saving survives the first landing, and a failure of its own save", async () => {
|
|
const PS = withSink(JSON.stringify({ sharpen: { enabled: false, amount: 0.5 } }), [sharpen]);
|
|
await PS.ensureLoaded();
|
|
let calls = 0;
|
|
const real = lastApi.editPipeline;
|
|
lastApi.editPipeline = async (body) => {
|
|
const sent = structuredClone(body), n = ++calls; // what left the browser at the call, as a network takes it
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
if (n === 2) throw new Error("502");
|
|
return real(sent);
|
|
};
|
|
const first = PS.setModuleValue("sharpen", "enabled", true);
|
|
await PS.setModuleValue("sharpen", "amount", 0.9);
|
|
await first;
|
|
assert.strictEqual(PS.currentValues().sharpen.amount, 0.9);
|
|
assert.strictEqual(JSON.parse(PS.slots().find((s) => s.id === "settings").inputs.settings).sharpen.amount, 0.9); // what a run reads
|
|
});
|
|
|
|
test("a guarded edit is refused, said, and leaves nothing on screen that will not run", async () => {
|
|
const PS = withSink("{}", [sharpen]);
|
|
await PS.ensureLoaded();
|
|
PS.setEditGuard(() => "Could not save: not loaded");
|
|
await PS.setModuleValue("sharpen", "enabled", true);
|
|
assert.deepStrictEqual([PS.currentValues().sharpen.enabled, PS.saveNotes()], [false, ["Could not save: not loaded"]]);
|
|
assert.deepStrictEqual((await PS.edit({ action: "remove", slot: "other" })).refused, ["Could not save: not loaded"]);
|
|
});
|
|
|
|
test("the stand-in for a pipeline that could not go in does not inherit the last project's readiness", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
assert.strictEqual(PS.queueable(), true);
|
|
lastApi.editPipeline = async () => { throw Object.assign(new Error("refused"), { status: 400 }); };
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: {} }]);
|
|
assert.strictEqual(PS.queueable(), false);
|
|
});
|
|
|
|
test("a settings node swapped in (or put back by Revert) starts with the project's settings, saved, so the run stays queueable", async () => {
|
|
for (const how of ["replace", "restore"]) {
|
|
const PS = withSink(JSON.stringify({ sharpen: { enabled: true, amount: 0.9 } }), [sharpen]);
|
|
await PS.ensureLoaded();
|
|
const old = PS.snapshot();
|
|
old.slots.find((s) => s.id === "settings").inputs = {}; // a snapshot from before it had settings
|
|
const real = lastApi.editPipeline, posts = [];
|
|
lastApi.editPipeline = async (body) => {
|
|
posts.push(structuredClone(body));
|
|
if (body.action === "replace") return { slots: body.slots.map((s) => (s.id === body.slot ? { ...s, inputs: {} } : s)), incomplete: [], refused: [], queueable: false };
|
|
return real(body);
|
|
};
|
|
const res = how === "replace" ? await PS.edit({ action: "replace", slot: "settings", node: "Sink" }) : await PS.restore(old);
|
|
assert.deepStrictEqual(res.refused, [], how);
|
|
const blob = (sl) => JSON.parse(sl.find((s) => s.id === "settings").inputs.settings);
|
|
assert.deepStrictEqual(blob(PS.slots()).sharpen, { enabled: true, amount: 0.9 }, how);
|
|
assert.deepStrictEqual(blob(posts.at(-1).slots).sharpen, { enabled: true, amount: 0.9 }, how); // and sent, not only shown
|
|
}
|
|
});
|
|
|
|
test("whether the last project's pipeline went in is known the moment the load ends", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const real = lastApi.editPipeline;
|
|
lastApi.editPipeline = async () => { throw Object.assign(new Error("refused"), { status: 400 }); };
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: {} }]);
|
|
assert.strictEqual(PS.adoptedOk(), false);
|
|
lastApi.editPipeline = real;
|
|
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "a" } }]);
|
|
assert.strictEqual(PS.adoptedOk(), true);
|
|
});
|
|
|
|
test("a module that acts through a pipeline input counts only in a pipeline that has that input", async () => {
|
|
const carry = { id: "continuity", roles: ["assets.source_image"], settings: { carry: { type: "bool", default: true } } };
|
|
for (const [roles, shown] of [[[], false], [[{ at: "assets.source_image", input: "media_id" }], true]]) {
|
|
const PS = load([]);
|
|
lastApi.pipeline = async () => ({ slots: [{ id: "img", node: "Img", roles, inputs: {} }], incomplete: [], refused: [], queueable: true });
|
|
lastApi.modules = async () => ({ modules: [carry], control: {} });
|
|
await PS.ensureLoaded();
|
|
assert.strictEqual(PS.usefulness()(PS.modulesById().continuity), shown);
|
|
}
|
|
});
|
|
|
|
test("a value edit and a change of shape are told apart, to the guard and to listeners", async () => {
|
|
const PS = withSink("{}", [sharpen]);
|
|
await PS.ensureLoaded();
|
|
const asked = [], heard = [];
|
|
PS.setEditGuard((kind) => { asked.push(kind); return null; });
|
|
PS.subscribe((_s, kind) => heard.push(kind));
|
|
await PS.setModuleValue("sharpen", "enabled", true);
|
|
await PS.edit({ action: "remove", slot: "other" });
|
|
assert.deepStrictEqual([asked.includes("edit"), asked.includes("shape")], [true, true]);
|
|
assert.deepStrictEqual([heard.includes("edit"), heard.includes("shape")], [true, true]);
|
|
});
|
|
|
|
test("Revert to what is already live changes nothing and is not a change of shape", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
const heard = [];
|
|
PS.subscribe((_s, kind) => heard.push(kind));
|
|
const posts = [];
|
|
lastApi.editPipeline = async (b) => { posts.push(b); return { slots: b.slots, incomplete: [], refused: [], queueable: true }; };
|
|
assert.deepStrictEqual((await PS.restore(PS.snapshot())).refused, []);
|
|
assert.deepStrictEqual([posts.length, heard], [0, []]);
|
|
});
|
|
|
|
test("a typed node value is in the live slots at once, not when the server answers; one typed during the round trip survives it", async () => {
|
|
const posts = [];
|
|
const PS = load(posts, { delay: 30 });
|
|
await PS.ensureLoaded();
|
|
const steps = () => PS.slots().find((s) => s.id === "gen").inputs.steps;
|
|
const first = PS.save({ inputs: { gen: { steps: 12 } } });
|
|
assert.strictEqual(steps(), 12, "Generate now reads what was typed");
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const second = PS.save({ inputs: { gen: { steps: 20 } } }); // typed while the first is on its way
|
|
assert.strictEqual(steps(), 20);
|
|
await new Promise((r) => setTimeout(r, 32)); // the first answer lands, carrying 12
|
|
assert.strictEqual(steps(), 20, "the older answer does not put the old value back on screen");
|
|
await Promise.all([first, second]);
|
|
assert.strictEqual(steps(), 20);
|
|
});
|
|
|
|
test("a run does not wait on a value save (the value is already live), but does wait for a node change", async () => {
|
|
const posts = [];
|
|
const PS = load(posts, { delay: 200 });
|
|
await PS.ensureLoaded();
|
|
const valueSave = PS.save({ inputs: { gen: { steps: 12 } } });
|
|
const t0 = Date.now();
|
|
assert.strictEqual(await PS.readyToRun(), true);
|
|
assert.ok(Date.now() - t0 < 100, "ready while the value save is still on its way");
|
|
await valueSave;
|
|
const shape = PS.edit({ remove: [] });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const t1 = Date.now();
|
|
await PS.readyToRun();
|
|
assert.ok(Date.now() - t1 > 100, "a node change is waited for");
|
|
await shape;
|
|
});
|
|
|
|
test("a value typed while a node change is on its way survives the change's answer, and the run sees it", async () => {
|
|
const PS = load([], { delay: 60 });
|
|
await PS.ensureLoaded();
|
|
const shape = PS.edit({ remove: [] });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
PS.save({ inputs: { gen: { steps: 33 } } }); // typed during it
|
|
await PS.readyToRun();
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "gen").inputs.steps, 33);
|
|
await shape;
|
|
});
|
|
|
|
test("after a preset or import goes in, a run is ready at once (the node-change wait is released)", async () => {
|
|
const PS = load([], { delay: 5 });
|
|
await PS.ensureLoaded();
|
|
const next = JSON.parse(JSON.stringify(PS.slots()));
|
|
next.find((s) => s.id === "gen").inputs.steps = 40; // a different pipeline: restore really sends it
|
|
await PS.restore({ slots: next });
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "gen").inputs.steps, 40);
|
|
const t0 = Date.now();
|
|
assert.strictEqual(await PS.readyToRun(), true);
|
|
assert.ok(Date.now() - t0 < 100);
|
|
});
|
|
|
|
test("a value typed for a node while it is being swapped is dropped, not put on the node that replaces it", async () => {
|
|
const posts = [];
|
|
const PS = load(posts, { delay: 40 });
|
|
await PS.ensureLoaded();
|
|
const swap = PS.edit({ action: "replace", slot: "gen", node: "Other" });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const typed = PS.save({ inputs: { gen: { steps: 33 } } });
|
|
await swap; await typed; await PS.settled();
|
|
const gen = PS.slots().find((x) => x.id === "gen");
|
|
assert.strictEqual(gen.node, "Other");
|
|
assert.strictEqual(gen.inputs.steps, undefined, "no stray input the new node cannot take");
|
|
assert.ok(posts.every((b) => !(b.inputs && b.inputs.gen && "steps" in b.inputs.gen) || !b.slots.some((x) => x.id === "gen" && x.node === "Other")));
|
|
});
|
|
|
|
test("a node change asked for while a value save is on its way holds a run back until it lands", async () => {
|
|
const PS = load([], { delay: 60 });
|
|
await PS.ensureLoaded();
|
|
PS.save({ inputs: { gen: { steps: 12 } } });
|
|
const swap = PS.edit({ action: "replace", slot: "gen", node: "Other" }); // queued behind the save
|
|
await PS.readyToRun();
|
|
assert.strictEqual(PS.slots().find((x) => x.id === "gen").node, "Other", "the run freezes the pipeline after the swap");
|
|
await swap;
|
|
});
|
|
|
|
test("a skipped optional node keeps only the controls its pass-through needs, in view() and never in the saved slots", async () => {
|
|
const slots = [
|
|
{ id: "enhance", node: "Enhance", roles: [{ at: "generation.prompt", input: "text" }, { at: "generation.prompt", input: "enabled" }], inputs: { text: "", enabled: false } },
|
|
{ id: "gen", node: "Gen", inputs: { steps: 8 } },
|
|
];
|
|
const API = {
|
|
pipeline: async () => ({ slots, incomplete: [], refused: [], queueable: true, skipped: [{ slot: "enhance", node: "Enhance", keeps: ["text"] }] }),
|
|
editPipeline: async (b) => ({ slots: b.slots, incomplete: [], refused: [], queueable: true }),
|
|
modules: async () => ({ modules: [] }), probeFamily: async () => ({}),
|
|
};
|
|
const PS = createPipelineState(API, {});
|
|
await PS.ensureLoaded();
|
|
assert.deepStrictEqual(PS.view().find((s) => s.id === "enhance").roles.map((r) => r.input), ["text"]);
|
|
assert.strictEqual(PS.slots().find((s) => s.id === "enhance").roles.length, 2); // what is saved still has both
|
|
assert.deepStrictEqual(Object.keys(PS.skipped()), ["enhance"]);
|
|
});
|
|
|
|
test("the Project tab, the frame grid and the scene tab read the pipeline through view(), not slots()", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const read = (f) => readFileSync(new URL(`../../features/${f}`, import.meta.url), "utf8");
|
|
assert.match(read("inspector/index.js"), /projectRows\(p, slots,/);
|
|
assert.match(read("inspector/index.js"), /app\.pipeline\.view/);
|
|
assert.match(read("inspector/scene.js"), /app\.pipeline\.view/);
|
|
});
|
|
|
|
test("a preset-started project gains the media slots its preset got since, and nothing else", async () => {
|
|
const media = (id, at) => ({ id, node: "LoadIt", roles: [{ at, input: "media_id" }], inputs: { media_id: "" } });
|
|
const oldPreset = [{ id: "ckpt", node: "Checkpoint", inputs: { file: "h3.safetensors" } }, media("ref_source_1", "assets.reference_1")];
|
|
const newPreset = [...oldPreset, { id: "ckpt2", node: "Checkpoint", inputs: {} }, media("ref_video_source_1", "assets.reference_video_1")];
|
|
const PS = load([]);
|
|
lastApi.pipelinePresets = async () => ({ presets: [{ id: "p", slots: newPreset }] });
|
|
await PS.adopt(oldPreset, ["model", "gen"], {}, true);
|
|
assert.deepStrictEqual(PS.slots().map((s) => s.id), ["ckpt", "ref_source_1", "ref_video_source_1"]);
|
|
});
|
|
|
|
test("the same holds when the saved copy is not marked whole", async () => {
|
|
const media = (id, at) => ({ id, node: "LoadIt", roles: [{ at, input: "media_id" }], inputs: { media_id: "" } });
|
|
const old = [{ id: "ckpt", node: "Checkpoint", inputs: {} }, media("ref_source_1", "assets.reference_1")];
|
|
const PS = load([]);
|
|
lastApi.pipelinePresets = async () => ({ presets: [{ id: "p", slots: [...old, media("ref_video_source_1", "assets.reference_video_1")] }] });
|
|
await PS.adopt(old, ["model"], {}, false);
|
|
assert.ok(PS.slots().some((s) => s.id === "ref_video_source_1"));
|
|
});
|
|
|
|
test("reloading the modules picks up a setting's new choices without touching the pipeline", async () => {
|
|
const PS = load([]);
|
|
await PS.ensureLoaded();
|
|
lastApi.modules = async () => ({ modules: [{ id: "ptd", settings: { adapter: { options: [{ value: "a" }] } } }], control: {} });
|
|
assert.strictEqual(await PS.reloadModules(), true);
|
|
assert.deepStrictEqual(PS.activeModules().find((m) => m.id === "ptd").settings.adapter.options, [{ value: "a" }]);
|
|
lastApi.modules = async () => ({ modules: [{ id: "ptd", settings: { adapter: { options: [{ value: "a" }, { value: "b" }] } } }], control: {} });
|
|
await PS.reloadModules();
|
|
assert.strictEqual(PS.activeModules().find((m) => m.id === "ptd").settings.adapter.options.length, 2);
|
|
lastApi.modules = async () => { throw new Error("down"); };
|
|
assert.strictEqual(await PS.reloadModules(), false);
|
|
assert.strictEqual(PS.activeModules().find((m) => m.id === "ptd").settings.adapter.options.length, 2);
|
|
});
|
|
|
|
test("Disable camera prompt enhancement is its own flag: it leaves 'all' and the module choices alone", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.ensureLoaded();
|
|
await PS.setOff("alg", true);
|
|
await PS.setCameraOff(true);
|
|
assert.strictEqual(PS.cameraOff(), true);
|
|
assert.strictEqual(PS.allOff(), false);
|
|
assert.strictEqual(PS.isOff("alg"), true);
|
|
assert.deepStrictEqual(posts.at(-1).values._off, { modules: ["alg"], camera: true });
|
|
await PS.setCameraOff(false);
|
|
assert.strictEqual(PS.cameraOff(), false);
|
|
assert.strictEqual(PS.isOff("alg"), true);
|
|
});
|
|
|
|
test("Disable all guidance enhancements is its own flag and keeps the other choices", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
await PS.ensureLoaded();
|
|
await PS.setOff("alg", true);
|
|
await PS.setGuidanceOff(true);
|
|
assert.strictEqual(PS.guidanceOff(), true);
|
|
assert.strictEqual(PS.allOff(), false);
|
|
assert.deepStrictEqual(posts.at(-1).values._off, { modules: ["alg"], guidance: true });
|
|
await PS.setGuidanceOff(false);
|
|
assert.strictEqual(PS.guidanceOff(), false);
|
|
assert.strictEqual(PS.isOff("alg"), true);
|
|
});
|
|
|
|
test("a Video to Video pipeline does not gain the Image to Video preset's identity loader just because both have a source_image", async () => {
|
|
const media = (id, at, wireTo) => ({ id, node: "LoadIt", roles: [{ at, input: "media_id", ...(wireTo ? { wireTo } : {}) }], inputs: { media_id: "" } });
|
|
const direct = [{ id: "ckpt", node: "Checkpoint", inputs: {} }, { id: "encode", node: "VAEEncode", inputs: {} }, media("source_image", "assets.source_image")];
|
|
const i2v = [{ id: "ckpt", node: "Checkpoint", inputs: {} }, { id: "identity", node: "Identity", inputs: {} }, media("source_image", "assets.source_image"),
|
|
media("identity_source", "assets.reference_1", { slot: "identity", input: "image" })];
|
|
const PS = load([]);
|
|
lastApi.pipelinePresets = async () => ({ presets: [{ id: "i2v", slots: i2v }, { id: "direct", slots: direct }] });
|
|
await PS.adopt(direct, ["model"], {}, true);
|
|
assert.deepStrictEqual(PS.slots().map((s) => s.id), ["ckpt", "encode", "source_image"]);
|
|
});
|
|
|
|
test("settings saved for a module that no longer exists are cleared from the project when it opens", async () => {
|
|
const posts = [];
|
|
const PS = load(posts);
|
|
lastApi.modules = async () => ({ modules: [{ id: "sharpen", settings: { amount: { default: 1 } } }], installed: ["sharpen", "hidden_by_model"], failed: [], sinks: [{ node: "Gen", input: "settings" }], control: {} });
|
|
await PS.adopt([{ id: "gen", node: "Gen", inputs: { steps: 8, settings: JSON.stringify({ sharpen: { amount: 2 }, video_detail: { on: true }, hidden_by_model: { x: 1 } }) } }]);
|
|
const blob = () => JSON.parse(PS.slots().find((s) => s.id === "gen").inputs.settings);
|
|
assert.deepStrictEqual(Object.keys(blob()).sort(), ["hidden_by_model", "sharpen"]); // the removed module is gone, one the model merely hides stays
|
|
assert.strictEqual(blob().sharpen.amount, 2);
|
|
assert.ok(posts.some((p) => p.values && !("video_detail" in p.values)), "and it was saved, not left for a later edit");
|
|
});
|
|
|
|
test("nothing is cleared while a module failed to load: it may be back next time", async () => {
|
|
const PS = load([]);
|
|
lastApi.modules = async () => ({ modules: [], installed: ["sharpen"], failed: [{ where: "x", why: "broke" }], sinks: [{ node: "Gen", input: "settings" }], control: {} });
|
|
await PS.adopt([{ id: "gen", node: "Gen", inputs: { steps: 8, settings: JSON.stringify({ lost_for_now: { a: 1 } }) } }]);
|
|
assert.ok("lost_for_now" in JSON.parse(PS.slots().find((s) => s.id === "gen").inputs.settings));
|
|
});
|