Files
olivv-cs-ComfyUI-FunPack/app/ui/shell/tests/session.test.js
T

610 lines
24 KiB
JavaScript

// Starting a run, and taking one back over.
//
// This wiring used to live in boot.js, which runs on import and needs a
// document, so none of it could be driven -- and both faults found here were
// ordering faults in exactly that untested wiring.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let createGenerator, reattach, wire, waitForTerminal, createTransport;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ createGenerator, reattach, wire, waitForTerminal } = await import("../session.js"));
({ createTransport } = await import("../transport.js"));
});
test.after(() => teardownDom());
// The REAL transport, not a stand-in.
//
// A hand-rolled one recorded setText and setDisabled into arrays and had a
// draw() that did nothing -- so it could not show the fault it was standing in
// for: the real draw() writes the status from the run state, and a refusal set
// just before it was overwritten with "Ready" a moment after it appeared. The
// test passed and the button did nothing on screen.
function transport() {
// The real transport, mounted the way the shell mounts it: its controls go in
// a zone head and what it is saying goes at the far end of the same head.
// There is no bar -- an editor's controls belong to the region they act on.
const t = createTransport({});
const head = document.createElement("header");
for (const handle of [...t.actions, ...t.status, t.warning]) head.appendChild(handle.node);
document.body.replaceChildren(head);
return {
...t,
head,
get text() { return t.statusText.node.textContent; },
get disabled() { return t.generate.node.disabled; },
};
}
/** A run whose start() can be held open, like a real POST.
*
* Open by DEFAULT, and held only when a test asks. A gate that starts shut
* means any regression letting an extra start() through hangs the suite
* forever instead of failing it, and a hang says far less than an assertion. */
function fakeRun(phase = "idle") {
const started = [];
let release = () => {};
let gate = Promise.resolve();
return {
hold() {
gate = new Promise((r) => { release = () => r(); });
},
started,
release: () => release(),
state: { phase, promptId: null, progress: null, images: [], audio: [], error: null, node: null },
listened: 0,
listen() { this.listened += 1; },
// The real run delivers the CURRENT state on subscribe, at once. Leaving
// that off is how a fake stays tidier than the thing it stands in for: the
// synchronous first delivery is exactly what handed a deliberately disabled
// button straight back, and a fake without it could not show that.
subscribers: new Set(),
subscribe(fn) {
this.subscribers.add(fn);
try { fn(this.state); } catch { /* as the real one does */ }
return () => this.subscribers.delete(fn);
},
announce() { for (const fn of this.subscribers) fn(this.state); },
seen: () => [],
adopt(id) {
this.state = { ...this.state, phase: "running", promptId: id, images: [] };
this.announce();
return true;
},
async start(prompt) {
started.push(prompt);
this.state = { ...this.state, phase: "queued" };
await gate;
return "p-1";
},
};
}
const plan = (over = {}) => async () => ({
slots: [], refused: [], incomplete: [], queueable: true, prompt: { "1": {} }, ...over,
});
test("two clicks during the round trip queue one run, not two", async () => {
// The button only goes dead when the RUN is queued, and getting there takes a
// round trip to ask what to queue. That round trip is the whole window.
const run = fakeRun();
run.hold();
const t = transport();
let asked = 0;
const check = async () => { asked += 1; return (await plan()()); };
const onGenerate = createGenerator({ run, transport: t, check });
const first = onGenerate();
const second = onGenerate();
run.release();
const [a, b] = await Promise.all([first, second]);
assert.equal(run.started.length, 1, "two /prompt posts went out");
assert.equal(asked, 1, "the pipeline was assembled twice for one click");
assert.equal(a, true);
assert.equal(b, false, "the second click reported that it had started a run");
});
test("the button goes dead at the click, not at the queue", async () => {
const run = fakeRun();
run.hold();
const t = transport();
const onGenerate = createGenerator({ run, transport: t, check: plan() });
const running = onGenerate();
assert.equal(t.disabled, true, "the button stayed live during the round trip");
run.release();
await running;
});
test("a run already in flight is not started again", async () => {
for (const phase of ["queued", "running"]) {
const run = fakeRun(phase);
const onGenerate = createGenerator({ run, transport: transport(), check: plan() });
assert.equal(await onGenerate(), false);
assert.equal(run.started.length, 0, `a ${phase} run was started a second time`);
}
});
test("a pipeline that is not ready says why, beside Generate", async () => {
const run = fakeRun();
const t = transport();
const onGenerate = createGenerator({
run, transport: t,
check: plan({ queueable: false, prompt: null, incomplete: ["model: nothing is chosen"] }),
});
assert.equal(await onGenerate(), false);
// Read off the element, so a later redraw wiping it is visible here.
assert.equal(t.text, "model: nothing is chosen");
assert.equal(run.started.length, 0);
assert.equal(t.disabled, false, "a refused attempt left Generate dead");
});
test("a refusal is shown even when nothing is incomplete", async () => {
const t = transport();
const onGenerate = createGenerator({
run: fakeRun(), transport: t,
check: plan({ queueable: false, prompt: null, refused: ["a slot cannot feed itself"] }),
});
await onGenerate();
assert.equal(t.text, "a slot cannot feed itself");
});
test("a pipeline that cannot be read at all is reported rather than swallowed", async () => {
const t = transport();
const onGenerate = createGenerator({
run: fakeRun(), transport: t,
check: async () => { throw new TypeError("Failed to fetch"); },
});
assert.equal(await onGenerate(), false);
assert.match(t.text, /could not be read/);
});
test("a failed attempt leaves Generate usable again", async () => {
const run = fakeRun();
const t = transport();
const onGenerate = createGenerator({
run, transport: t, check: plan({ queueable: false, prompt: null }),
});
await onGenerate();
assert.equal(t.disabled, false, "nothing gave the button back after the attempt ended");
// And a second click is allowed, which it would not be if the guard stuck.
assert.equal(await onGenerate(), false);
assert.equal(t.disabled, false);
});
// --- reattach ---------------------------------------------------------------
test("a run still in the queue is taken back over", async () => {
const run = fakeRun();
const got = await reattach(run, "me", {
queuedFor: async () => ({ promptId: "still-going", running: true, sceneId: null, projectId: null }),
finishedFor: async () => { throw new Error("history should not have been asked"); },
});
assert.equal(got.promptId, "still-going");
assert.equal(run.state.phase, "running");
});
test("a run that finished during the load is found in history", async () => {
const run = fakeRun();
run.seen = () => ["ended-just-now"];
const got = await reattach(run, "me", {
queuedFor: async () => null,
finishedFor: async (_id, seen) => (seen.includes("ended-just-now") ? "ended-just-now" : null),
});
assert.equal(got.promptId, "ended-just-now");
});
test("which scene/project a run belongs to travels with it, for both halves", async () => {
const run = fakeRun();
const queued = await reattach(run, "me", {
queuedFor: async () => ({ promptId: "q1", running: true, sceneId: "s1", projectId: "p1" }),
finishedFor: async () => { throw new Error("should not be asked"); },
});
assert.deepEqual(queued, { promptId: "q1", sceneId: "s1", projectId: "p1" });
const run2 = fakeRun();
run2.seen = () => ["f1"];
const finished = await reattach(run2, "me", {
queuedFor: async () => null,
finishedFor: async (_id, _seen, { onFound } = {}) => {
if (onFound) onFound({ client_id: "me", funpack_scene_id: "s2", funpack_project_id: "p2" });
return "f1";
},
});
assert.deepEqual(finished, { promptId: "f1", sceneId: "s2", projectId: "p2" });
});
test("onAdopt fires BEFORE the run is adopted, not after reattach resolves", async () => {
// An already-finished run can go straight to DONE inside adopt() itself
// (whatever the socket buffered gets replayed there) -- a listener that
// learns which scene this is for only once reattach's own promise settles
// would learn it one tick after that DONE already reached run.subscribe with
// no scene to attach it to. This is the ordering the fix actually depends on.
const order = [];
const run = fakeRun();
const realAdopt = run.adopt.bind(run);
run.adopt = (id, opts) => { order.push("adopt"); return realAdopt(id, opts); };
run.subscribe((state) => { if (state.phase === "running") order.push("subscriber-saw-it"); });
await reattach(run, "me", {
queuedFor: async () => ({ promptId: "q1", running: true, sceneId: "s1", projectId: "p1" }),
finishedFor: async () => { throw new Error("should not be asked"); },
onAdopt: (sceneId, projectId) => { order.push(`onAdopt:${sceneId}:${projectId}`); },
});
assert.deepEqual(order, ["onAdopt:s1:p1", "adopt", "subscriber-saw-it"]);
});
test("onAdopt's third argument carries every scene a multi-scene run covers", async () => {
// No chain sampler to split a multi-scene selection into several /prompt
// calls, so one run can cover several scenes at once. sceneId alone (the
// first) is what every existing caller still reads; sceneIds is additive,
// for a caller that needs the whole list back on reattach.
const run = fakeRun();
let got = null;
await reattach(run, "me", {
queuedFor: async () => ({ promptId: "q1", running: true, sceneId: "s1",
sceneIds: ["s1", "s2", "s3"], projectId: "p1" }),
finishedFor: async () => { throw new Error("should not be asked"); },
onAdopt: (sceneId, projectId, sceneIds) => { got = { sceneId, projectId, sceneIds }; },
});
assert.deepEqual(got, { sceneId: "s1", projectId: "p1", sceneIds: ["s1", "s2", "s3"] });
// The finished-run path recovers it from the raw extra_data finishedFor
// hands back through onFound, the same way it already reads funpack_scene_id.
const run2 = fakeRun();
run2.seen = () => ["f1"];
let got2 = null;
await reattach(run2, "me", {
queuedFor: async () => null,
finishedFor: async (_id, _seen, { onFound } = {}) => {
if (onFound) onFound({ client_id: "me", funpack_scene_id: "s2",
funpack_scene_ids: ["s2", "s4"], funpack_project_id: "p2" });
return "f1";
},
onAdopt: (sceneId, projectId, sceneIds) => { got2 = { sceneId, projectId, sceneIds }; },
});
assert.deepEqual(got2, { sceneId: "s2", projectId: "p2", sceneIds: ["s2", "s4"] });
});
test("onAdopt's third argument falls back to just the first scene when nothing said more", async () => {
const run = fakeRun();
let got = null;
await reattach(run, "me", {
queuedFor: async () => ({ promptId: "q1", running: true, sceneId: "s1", projectId: "p1" }),
finishedFor: async () => { throw new Error("should not be asked"); },
onAdopt: (sceneId, projectId, sceneIds) => { got = sceneIds; },
});
assert.deepEqual(got, ["s1"]);
});
test("history is not asked about a run this page never saw", async () => {
const run = fakeRun();
let asked = false;
const got = await reattach(run, "me", {
queuedFor: async () => null,
finishedFor: async () => { asked = true; return "something-old"; },
});
assert.equal(got, null);
assert.equal(asked, false, "a result from before this page load could be resurrected");
});
test("a page already running its own generation is not reattached to another", async () => {
const run = fakeRun("running");
const got = await reattach(run, "me", {
queuedFor: async () => ({ promptId: "some-other", running: true }),
finishedFor: async () => null,
});
assert.equal(got, null);
});
test("no queue and no history is silence, not an error", async () => {
const run = fakeRun();
const got = await reattach(run, "me", {
queuedFor: async () => { throw new TypeError("Failed to fetch"); },
finishedFor: async () => null,
});
assert.equal(got, null);
assert.equal(run.state.phase, "idle");
});
test("a reason for not starting is not wiped by the next redraw", () => {
// The redraw happens on the way out of every attempt, and an attempt that was
// refused leaves the run exactly where it was: idle. Drawing an idle run says
// "Ready", which is how the reason appeared and vanished in the same frame.
const t = transport();
t.say("model: nothing is chosen");
t.draw({ phase: "idle", progress: null, images: [], error: null, node: null });
assert.equal(t.text, "model: nothing is chosen");
});
test("a run that actually starts outranks the last refusal", () => {
const t = transport();
t.say("model: nothing is chosen");
t.draw({ phase: "queued", progress: null, images: [], error: null, node: null });
assert.equal(t.text, "Queued");
t.draw({ phase: "idle", progress: null, images: [], error: null, node: null });
assert.equal(t.text, "Ready", "the stale reason came back after a real run");
});
// --- the load ordering, which is the thing that keeps breaking ---------------
//
// Every fault on this path has been an ordering fault, and every one lived in
// boot.js, where nothing could reach it. These drive the real order.
function wired({ running = null, finished = null, phase = "idle" } = {}) {
const t = transport();
const run = fakeRun(phase);
let releaseQueue;
const queueAnswered = new Promise((r) => { releaseQueue = r; });
const session = wire({
run,
page: { transport: t },
check: plan(),
id: "me",
queuedFor: async () => { await queueAnswered; return running ? { promptId: running, running: true } : null; },
finishedFor: async () => finished,
});
return { t, run, session, releaseQueue };
}
test("Generate waits for the page to work out whether a run is already going", async () => {
// The reload-during-a-generation case. The button is on screen and the phase
// still reads idle for the whole length of the queue lookup, so a click in
// that window used to queue a second job and orphan the first.
const { t, run, session, releaseQueue } = wired({ running: "already-going" });
assert.equal(t.disabled, true, "Generate was live before anything was known");
const clicked = session.generate();
releaseQueue();
await session.ready;
assert.equal(await clicked, false, "a second run was queued over the one already going");
assert.equal(run.started.length, 0);
assert.equal(run.state.promptId, "already-going", "the run in progress was orphaned");
});
test("with nothing already running, Generate comes back and works", async () => {
const { t, run, session, releaseQueue } = wired({ running: null });
releaseQueue();
await session.ready;
assert.equal(t.disabled, false, "Generate never came back");
assert.equal(t.text, "Ready", `the loading note stuck: ${t.text}`);
const started = session.generate();
run.release();
assert.equal(await started, true);
assert.equal(run.started.length, 1);
});
test("the page says what it is doing while it works it out", async () => {
const { t, releaseQueue, session } = wired({ running: null });
assert.match(t.text, /Looking for a run/);
releaseQueue();
await session.ready;
});
test("a run found in history is adopted before Generate is offered again", async () => {
const { t, run, session, releaseQueue } = wired({ running: null, finished: "ended-just-now" });
run.seen = () => ["ended-just-now"];
releaseQueue();
await session.ready;
assert.equal(run.state.promptId, "ended-just-now");
// The button now follows the RUN, not the lookup: the loading note is gone
// and what is on screen is whatever that run is doing.
assert.doesNotMatch(t.text, /Looking for a run/);
});
test("a queue that cannot be reached still gives the button back", async () => {
// The dev server, and any ComfyUI that is down. Waiting forever on a lookup
// that will never answer is the same as no button at all.
const t = transport();
const run = fakeRun();
const session = wire({
run, page: { transport: t }, check: plan(), id: "me",
queuedFor: async () => { throw new TypeError("Failed to fetch"); },
finishedFor: async () => null,
});
await session.ready;
assert.equal(t.disabled, false);
assert.equal(t.text, "Ready");
});
test("a state delivered while the page is still looking does not hand Generate back", () => {
// subscribe() delivers the current state at once, and every later change
// redraws. Each of those draws decides the button from the run's phase alone,
// so an idle run -- which is what a page has while it is still finding out
// whether it has a run at all -- re-enabled a button that had just been
// deliberately disabled. Live-looking, and doing nothing when pressed.
const t = transport();
const run = fakeRun();
wire({
run, page: { transport: t }, check: plan(), id: "me",
queuedFor: () => new Promise(() => {}), // never answers
finishedFor: async () => null,
});
assert.equal(t.disabled, true);
run.announce(); // any redraw at all
assert.equal(t.disabled, true, "a redraw handed the button back mid-lookup");
t.draw(run.state);
assert.equal(t.disabled, true, "drawing the idle run handed the button back");
});
test("results of a run go to the bin, and only to the bin", async () => {
// The subscription draws the transport AND hands results on. It hands them to
// the BIN, which owns what is on screen: pointing the viewer at the newest
// image from here as well put two things in charge of one view, and every
// progress message then walked a user's choice of an older result back to the
// newest one.
const taken = [];
const shown = [];
const t = transport();
const run = fakeRun();
wire({
run,
page: {
transport: t,
bin: { absorb: (images) => { taken.push(images); return []; } },
viewer: { setSource: (src, kind) => shown.push([src, kind]) },
},
check: plan(), id: "me",
queuedFor: async () => null, finishedFor: async () => null,
});
const images = [{ filename: "a.png", subfolder: "", type: "output" }];
run.state = { ...run.state, phase: "done", images };
run.announce();
assert.deepEqual(taken[taken.length - 1], images);
assert.deepEqual(shown, [], "the viewer was driven from here as well as from the bin");
});
test("a run found waiting in the queue is adopted as waiting", async () => {
const t = transport();
const run = fakeRun();
const adopted = [];
run.adopt = (id, opts) => { adopted.push([id, opts]); return true; };
const session = wire({
run, page: { transport: t }, check: plan(), id: "me",
queuedFor: async () => ({ promptId: "waiting", running: false }),
finishedFor: async () => null,
});
await session.ready;
assert.deepEqual(adopted, [["waiting", { running: false }]]);
});
test("what the panels hold is sent with every run", async () => {
// Until this was wired, every module panel in the app was decoration: the
// values lived in the browser and nothing ever sent them, so a modifier
// switched on was a modifier the run never heard about.
const run = fakeRun();
const asked = [];
const check = async (body) => { asked.push(body); return plan()(); };
const onGenerate = createGenerator({
run, transport: transport(), check,
values: () => ({ sampling_alg: { enabled: true } }),
});
await onGenerate();
assert.deepEqual(asked[0].values, { sampling_alg: { enabled: true } });
});
test("with no panels holding anything, an empty set is still sent", async () => {
// "Nothing is set" is an answer. Sending nothing at all is a client that
// cannot say the difference, and the server would have to guess.
const run = fakeRun();
const asked = [];
const check = async (body) => { asked.push(body); return plan()(); };
await createGenerator({ run, transport: transport(), check })();
assert.deepEqual(asked[0].values, {});
});
test("a setting that will not be applied is said, and does not stop the run", async () => {
const run = fakeRun();
const t = transport();
const note = "nothing in this pipeline accepts module settings";
const started = await createGenerator({
run, transport: t, check: plan({ notes: [note] }),
})();
assert.equal(started, true, "a note stopped a run that was otherwise fine");
assert.match(t.warning.node.textContent, /accepts module settings/);
assert.equal(t.warning.node.hasAttribute("hidden"), false);
});
test("a note stays up while the run it is about is going", async () => {
// The status line is redrawn by every message a run sends, so a warning put
// there was gone the moment the run said "Queued" -- which is roughly when
// the user looks at it.
const run = fakeRun();
const t = transport();
await createGenerator({ run, transport: t, check: plan({ notes: ["will not be applied"] }) })();
t.draw({ phase: "running", images: [], progress: { value: 1, max: 20 } });
t.draw({ phase: "done", images: [] });
assert.match(t.warning.node.textContent, /will not be applied/);
assert.equal(t.warning.node.hasAttribute("hidden"), false);
});
test("a pipeline with nothing to say takes the note back down", async () => {
const run = fakeRun();
const t = transport();
await createGenerator({ run, transport: t, check: plan({ notes: ["will not be applied"] }) })();
assert.equal(t.warning.node.hasAttribute("hidden"), false);
run.state = { ...run.state, phase: "idle" };
await createGenerator({ run, transport: t, check: plan() })();
assert.equal(t.warning.node.hasAttribute("hidden"), true, "a stale warning stayed up");
assert.equal(t.warning.node.textContent.trim(), "");
});
test("what is typed on the main window is sent with the run", async () => {
const run = fakeRun();
const asked = [];
const check = async (body) => { asked.push(body); return plan()(); };
await createGenerator({
run, transport: transport(), check,
inputs: () => ({ positive: { text: "a cat on a roof" } }),
})();
assert.deepEqual(asked[0].inputs, { positive: { text: "a cat on a roof" } });
});
// --- waitForTerminal ---------------------------------------------------------
test("waitForTerminal resolves with the phase a run actually ends at", async () => {
const run = fakeRun();
const waiting = waitForTerminal(run);
run.state = { ...run.state, phase: "running" };
run.announce();
run.state = { ...run.state, phase: "done" };
run.announce();
assert.equal(await waiting, "done");
});
test("the snapshot delivered at subscribe time is not mistaken for a transition", async () => {
// A page reload adopting a run that already finished delivers ITS finished
// state the moment subscribe() is called -- if that counted, this would
// resolve before the caller's own "start" step had even run.
const run = fakeRun("done");
let resolved = false;
waitForTerminal(run).then(() => { resolved = true; });
await Promise.resolve(); await Promise.resolve();
assert.equal(resolved, false, "resolved from the snapshot, not a real transition");
run.announce(); // the same "done" state, delivered again -- now a real one
await Promise.resolve(); await Promise.resolve();
assert.equal(resolved, true);
});
test("cancel() unsubscribes a waiter whose run never actually started", () => {
// A generation refused before it ever reaches run.start() (an incomplete
// pipeline, a queue that says no) never transitions the run -- nothing
// would ever resolve this promise, and the subscription would sit on the
// run forever without a way out.
const run = fakeRun();
assert.equal(run.subscribers.size, 0);
const waiting = waitForTerminal(run);
assert.equal(run.subscribers.size, 1);
waiting.cancel();
assert.equal(run.subscribers.size, 0, "cancel() did not unsubscribe");
});
test("cancel() after the promise already resolved is harmless", async () => {
const run = fakeRun();
const waiting = waitForTerminal(run);
run.state = { ...run.state, phase: "done" };
run.announce();
assert.equal(await waiting, "done");
assert.doesNotThrow(() => waiting.cancel());
});