Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
261 lines
16 KiB
JavaScript
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
|
|
});
|