Files

261 lines
16 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { linkPipeline } from "../pipeline_link.js";
const slots = [{ id: "model", node: "N", inputs: { ckpt_name: "a" } }];
function rig(models) {
const writes = [];
let opened, heard;
const project = { project: { models }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
return { writes, open: () => opened(), edit: (s) => heard(s) };
}
test("opening a project whose pipeline is unchanged does not rewrite it; an edit does", async () => {
const r = rig({ slots, removed: [], unwired: {}, whole: false });
await r.open();
assert.equal(r.writes.length, 0);
r.edit([{ ...slots[0], inputs: { ckpt_name: "b" } }]);
assert.equal(r.writes.length, 1);
});
test("an older project with no saved pipeline gets the default and is not rewritten; a new one keeps the live pipeline", async () => {
for (const fresh of [false, true]) {
const writes = [], asked = [];
let opened;
const project = { fresh, project: { models: { slots: [] } }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async (...a) => { asked.push(a[4]); return true; }, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.deepStrictEqual([asked[0], writes.length], [fresh, fresh ? 1 : 0]);
}
});
test("an older file with no whole flag is not rewritten just to add one", async () => {
const r = rig({ slots, removed: [], unwired: {} });
await r.open();
assert.equal(r.writes.length, 0);
});
test("an older file guessed whole is not rewritten to record the guess", async () => {
const writes = [];
let opened;
const project = { project: { models: { slots, removed: [], unwired: {} } }, setField: (k, v) => { writes.push(v); } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => true, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.equal(writes.length, 0);
});
test("switching project while the first is still loading never writes into either the wrong pipeline", async () => {
const writes = [];
let opened, heard, release;
const A = { id: "a", models: { slots: [{ ...slots[0], inputs: { ckpt_name: "A" } }], removed: [], unwired: {}, whole: false } };
const B = { id: "b", models: { slots: [{ ...slots[0], inputs: { ckpt_name: "B" } }], removed: [], unwired: {}, whole: false } };
let live = A.models.slots;
const project = { project: A, setField: (k, v) => { writes.push([project.project.id, v.slots[0].inputs.ckpt_name]); project.project[k] = v; } };
const pipeline = {
adopt: async (saved) => { if (saved[0].inputs.ckpt_name === "A") await new Promise((r) => { release = r; }); live = saved; return true; },
slots: () => live, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; },
};
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
const first = opened();
heard(live); // a pipeline change while A is still loading
project.project = B;
await opened();
release();
await first; // A's late finish must not touch B
heard([{ ...slots[0], inputs: { ckpt_name: "B2" } }]);
assert.deepStrictEqual(writes, [["b", "B2"]]);
});
test("an Undo copy of the same project keeps receiving edits", async () => {
const writes = [];
let opened, heard;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
project.project = JSON.parse(JSON.stringify(project.project));
heard([{ ...slots[0], inputs: { ckpt_name: "b" } }]);
assert.equal(writes.length, 1);
});
test("a new project keeps the preset pipeline it inherits as whole, so it reopens exactly", async () => {
const writes = [];
let opened;
const project = { fresh: true, project: { id: "n", models: { slots: [] } }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => ["clip"], unwiredMap: () => ({}), whole: () => true, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.equal(writes[0].whole, true);
});
test("a retry left over from a project that failed to load never reloads the one opened after it", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let opened, calls = 0, fail = true;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: () => {} };
const pipeline = { adopt: async () => { calls += 1; return !fail; }, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened(); // A fails: a retry is waiting
fail = false;
project.project = { id: "b", models: { slots, removed: [], unwired: {}, whole: false } };
await opened(); // B opens and loads
const before = calls;
t.mock.timers.tick(6000);
await new Promise((r) => setImmediate(r));
assert.equal(calls, before);
});
test("Undo of something else, with a pipeline edit still landing, keeps the edit and puts nothing back in", async () => {
const writes = [];
let opened, heard, land, adopts = 0;
let live = slots;
const models = { slots, removed: [], unwired: {}, whole: false };
const project = { project: { id: "a", models }, setField: (k, v) => { writes.push(v.slots[0].inputs.ckpt_name); project.project[k] = v; } };
const pipeline = { adopt: async () => { adopts += 1; return true; }, slots: () => live, removedIds: () => [], unwiredMap: () => ({}), whole: () => false,
subscribe: (fn) => { heard = fn; }, settled: () => new Promise((r) => { land = r; }) };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
const first = opened(); land(); await first;
const before = adopts;
project.project = JSON.parse(JSON.stringify(project.project)); // Undo puts a copy of the project in place
const undo = opened();
live = [{ ...slots[0], inputs: { ckpt_name: "b" } }]; // the edit's answer arrives
heard(live);
land();
await undo;
assert.deepStrictEqual([writes, adopts - before], [["b"], 0]);
});
test("opening an older project whose pipeline an update's defaults fill in does not rewrite it", async () => {
const writes = [];
let opened;
const project = { project: { id: "o", models: { slots, removed: [], unwired: {} } }, setField: (k, v) => { writes.push(v); } };
const filled = [{ ...slots[0], inputs: { ...slots[0].inputs, denoise: 1 } }];
const pipeline = { adopt: async () => true, slots: () => filled, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.equal(writes.length, 0);
});
test("a project opened while ComfyUI is down is owned once it answers, so later edits are saved, and the toast is said once", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const writes = [], said = [];
let opened, heard, up = false;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: (k, v) => { writes.push(v.slots[0].inputs.ckpt_name); project.project[k] = v; } };
const pipeline = { adopt: async () => up, slots: () => (up ? slots : null), removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; }, say: (x) => said.push(x) });
const flush = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); };
await opened();
for (let i = 0; i < 5; i++) { t.mock.timers.tick(16000); await flush(); }
assert.equal(said.length, 1);
up = true;
heard(slots); // a panel's own load got the pipeline in and announced it
await flush();
heard([{ ...slots[0], inputs: { ckpt_name: "edited" } }]);
assert.deepStrictEqual(writes, ["edited"]);
});
test("a slow pipeline load is waited for, never retried over while it can still land", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let opened, calls = 0, finish;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: () => {} };
const pipeline = { adopt: () => { calls += 1; return new Promise((r) => { finish = r; }); }, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
const opening = opened();
for (let i = 0; i < 4; i++) { t.mock.timers.tick(10000); await new Promise((r) => setImmediate(r)); }
finish(true);
await opening;
assert.equal(calls, 1);
});
test("a project whose pipeline could not get through mid-session keeps being retried, and is owned once ComfyUI answers", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const writes = [];
let opened, heard, up = false;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: (k, v) => { writes.push(v.slots[0].inputs.ckpt_name); project.project[k] = v; } };
const pipeline = { adopt: async () => up, slots: () => slots, unreachable: () => !up, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
const link = linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
const flush = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); };
await opened();
for (let i = 0; i < 4; i++) { t.mock.timers.tick(6000); await flush(); }
assert.match(link.why(), /ComfyUI is not answering/);
up = true;
t.mock.timers.tick(16000); await flush();
assert.equal(link.owns(), true);
heard([{ ...slots[0], inputs: { ckpt_name: "edited" } }]);
assert.deepStrictEqual(writes, ["edited"]);
});
test("a pipeline ComfyUI refuses says the server's reason; a value tweak on the stand-in is refused, a change of shape becomes the project's pipeline", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const writes = [], said = [];
let opened, heard;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: (k, v) => { writes.push(v.slots[0].inputs.ckpt_name); project.project[k] = v; } };
const pipeline = { adopt: async () => false, slots: () => slots, unreachable: () => false, refusal: () => "slot 0 role 0 has no input",
removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
const link = linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; }, say: (x) => said.push(x) });
const flush = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); };
await opened();
for (let i = 0; i < 4; i++) { t.mock.timers.tick(6000); await flush(); }
assert.match(said.join(" "), /refused .*slot 0 role 0 has no input/);
assert.match(link.why(), /refused/);
heard(slots, "load"); // the module list arriving is not the person's edit
heard(slots, "edit"); // nor is a value: the saved pipeline must not go for a Steps change
assert.deepStrictEqual(writes, []);
heard([{ ...slots[0], inputs: { ckpt_name: "fixed" } }], "shape");
assert.deepStrictEqual(writes, ["fixed"]);
});
test("while a project's pipeline could not go in, edits to the stand-in are refused with the reason; once it is in, they are not", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let opened, guard, up = false, ok = true;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: () => {} };
const pipeline = { adopt: async () => (ok = up), adoptedOk: () => ok, slots: () => slots, unreachable: () => true, setEditGuard: (fn) => { guard = fn; },
removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
const flush = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); };
const first = opened();
await first;
assert.match(guard(), /^Could not save: .*still loading/);
for (let i = 0; i < 4; i++) { t.mock.timers.tick(6000); await flush(); }
assert.match(guard(), /ComfyUI is not answering/);
up = true;
t.mock.timers.tick(16000); await flush();
assert.strictEqual(guard(), null);
});
test("an edit that waited on a retry which worked goes in: the guard reads how the load ended, not the retry count", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let opened, guard, ok = false, calls = 0, atLanding;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: () => {} };
// The real state clears its gate (and an edit waiting on it asks the guard) before the link hears the answer.
const pipeline = { adopt: async () => { ok = ++calls > 1; if (ok) atLanding = guard(); return ok; }, adoptedOk: () => ok,
slots: () => slots, unreachable: () => true, setEditGuard: (fn) => { guard = fn; },
removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.match(guard(), /^Could not save/);
t.mock.timers.tick(6000);
for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r));
assert.strictEqual(calls, 2);
assert.strictEqual(atLanding, null);
});
test("once the person's edit has replaced a refused pipeline, later edits are not refused", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let opened, guard, heard;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: () => {} };
const pipeline = { adopt: async () => false, adoptedOk: () => false, slots: () => slots, unreachable: () => false, refusal: () => "bad slot", setEditGuard: (fn) => { guard = fn; },
removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
for (let i = 0; i < 4; i++) { t.mock.timers.tick(6000); for (let j = 0; j < 5; j++) await new Promise((r) => setImmediate(r)); }
assert.strictEqual(guard("shape"), null); // refused for good: a change of shape replaces it...
assert.match(guard("edit"), /Models & Pipeline/); // ...a value tweak on the stand-in is refused, saying how
heard(slots, "shape");
assert.strictEqual(guard("edit"), null); // and the one after is not blocked
});