v4's arrangement, which is the one that reads as an editor: a zone head holds what acts on THAT zone, so the app grows a bar of its own for what acts on the app. * The menu bar. Brand, View and Settings, and whether ComfyUI is still on the other end. Only menus with something real in them exist -- an empty File menu is a promise the app does not keep. * The prompt is written in the CONSTRUCTOR, a window. v4 called this the Composer; that name is taken here by the element kit, and two things called Composer in one repo is one thing nobody can search for. It is the longest thing a person writes and the least often read, and a permanent third of the centre column is the room the timeline needs. The host is built at startup and outlives every opening, so a window that opens twice does not throw away what was typed. * The timeline replaces it, and Generate moves into its head with what the run is doing at the far end of the same head. No bar across the bottom: one that spans the window belongs to no region. Four faults found on the way, three of them mine and one much older: * THE OVERLAY ROOT WAS A STACKING CONTEXT. It documents at length that it must never be one -- and then set `position: fixed`, which makes one on its own. So the whole z ladder was measured inside that single element: an open menu at z 500 painted UNDER a button at z 1. Every overlay in the app was one positive z-index away from being buried, and the only reason nobody had seen it is that almost nothing claims a z-index. The root is static and zero-sized now; every layer in it is fixed already. * Generate's hover ran `filter: brightness()`, which lightens the LABEL along with the fill under it -- the one thing that had to stay readable lost contrast in both themes. Only the fill moves now, and which way it moves depends on the theme: dark's accent carries near-black text so it goes lighter, light's carries white so it goes darker. Measured: no gradient stop under 4.1:1, hovered or at rest. * A fixed-height head could not shrink below the width of everything in it, and a head now holds a title, three controls and a status. That min-content became the centre column's, so a 768px window laid out 878px wide and scrolled sideways. It wraps now, as v4's does. * A CSS patch of mine silently did nothing because its anchor text had moved and I had left the assertion off that one replacement. Every patch asserts now.
154 lines
5.7 KiB
JavaScript
154 lines
5.7 KiB
JavaScript
// The transport, driven by run states.
|
|
//
|
|
// The reason this is one component reading one state object: a button here, a
|
|
// progress bar there and a status somewhere else each get their own idea of
|
|
// what is happening, and the user believes whichever spoke last.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
|
import { IDLE, QUEUED, RUNNING, DONE, FAILED, CANCELLED } from "../run.js";
|
|
|
|
let createTransport, describe;
|
|
test.before(async () => {
|
|
setupDom();
|
|
await import("../../composer/composer.js");
|
|
({ createTransport, describe } = await import("../transport.js"));
|
|
});
|
|
test.after(() => teardownDom());
|
|
|
|
const state = (over = {}) => ({
|
|
phase: IDLE, promptId: null, progress: null, images: [], audio: [],
|
|
error: null, node: null, ...over,
|
|
});
|
|
|
|
const built = (handlers) => {
|
|
// Mounted the way the shell mounts it: the controls go in a zone head, and
|
|
// there is no bar of its own to append.
|
|
const t = createTransport(handlers);
|
|
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;
|
|
};
|
|
|
|
test("Generate is disabled while a run is in flight and comes back after", () => {
|
|
const t = built({});
|
|
t.draw(state({ phase: RUNNING }));
|
|
assert.equal(t.generate.node.disabled, true);
|
|
|
|
t.draw(state({ phase: DONE, images: [{ filename: "a.png" }] }));
|
|
assert.equal(t.generate.node.disabled, false, "a finished run left Generate dead");
|
|
});
|
|
|
|
test("Cancel is only offered while there is something to cancel", () => {
|
|
const t = built({});
|
|
assert.equal(t.cancel.node.hasAttribute("hidden"), true, "idle offered a cancel");
|
|
|
|
t.draw(state({ phase: QUEUED }));
|
|
assert.equal(t.cancel.node.hasAttribute("hidden"), false);
|
|
|
|
t.draw(state({ phase: CANCELLED }));
|
|
assert.equal(t.cancel.node.hasAttribute("hidden"), true);
|
|
});
|
|
|
|
test("the progress bar appears only when there is a real measure", () => {
|
|
const t = built({});
|
|
// Running with nothing to measure yet: a bar at zero reads as "stuck", which
|
|
// is worse than no bar.
|
|
t.draw(state({ phase: RUNNING }));
|
|
assert.equal(t.progress.node.hasAttribute("hidden"), true);
|
|
|
|
t.draw(state({ phase: RUNNING, progress: { value: 3, max: 20 } }));
|
|
assert.equal(t.progress.node.hasAttribute("hidden"), false);
|
|
assert.equal(t.progress.node.getAttribute("aria-valuenow"), "3");
|
|
assert.equal(t.progress.node.getAttribute("aria-valuemax"), "20");
|
|
});
|
|
|
|
test("the buttons report to the handlers they were given", () => {
|
|
let generated = 0, cancelled = 0;
|
|
const t = built({ onGenerate: () => { generated += 1; }, onCancel: () => { cancelled += 1; } });
|
|
t.generate.node.click();
|
|
t.cancel.node.click();
|
|
assert.equal(generated, 1);
|
|
assert.equal(cancelled, 1);
|
|
});
|
|
|
|
test("a run that saved nothing does not read the same as one that saved something", () => {
|
|
// ComfyUI reports execution_success for a graph with no save node too. "Done"
|
|
// over an empty preview is the kind of quiet wrong this project keeps finding.
|
|
assert.match(describe(state({ phase: DONE, images: [] })), /nothing was saved/);
|
|
assert.match(describe(state({ phase: DONE, images: [{}] })), /1 result/);
|
|
assert.match(describe(state({ phase: DONE, images: [{}, {}] })), /2 results/);
|
|
});
|
|
|
|
test("a failure says which node and what it said", () => {
|
|
const text = describe(state({
|
|
phase: FAILED,
|
|
error: { node: "KSampler", message: "expected 4 channels" },
|
|
}));
|
|
assert.match(text, /KSampler/);
|
|
assert.match(text, /4 channels/);
|
|
});
|
|
|
|
test("every phase says something, including ones added later", () => {
|
|
for (const phase of [IDLE, QUEUED, RUNNING, DONE, FAILED, CANCELLED, "something-new"]) {
|
|
const text = describe(state({ phase }));
|
|
assert.ok(text && text.trim(), `${phase} draws an empty status`);
|
|
}
|
|
});
|
|
|
|
// --- a refusal from the queue, which names the nodes ------------------------
|
|
|
|
test("a graph the queue refused says which node and why", () => {
|
|
// run.js captured ComfyUI's node_errors from the start; describe() only ever
|
|
// read the singular `node`, which is set by the WebSocket crash path and
|
|
// never by this one. So every value refusal showed ComfyUI's top-level
|
|
// string -- "Prompt outputs failed validation" -- over a pipeline that can
|
|
// hold a dozen loaders, with nothing saying which to fix.
|
|
const said = describe({
|
|
phase: FAILED, images: [],
|
|
error: {
|
|
message: "Prompt outputs failed validation",
|
|
nodes: {
|
|
vae: {
|
|
class_type: "FunPackVAELoader",
|
|
errors: [{ type: "value_not_in_list", message: "Value not in list",
|
|
details: "vae_name: 'gone.safetensors' not in []" }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
assert.match(said, /FunPackVAELoader/);
|
|
assert.match(said, /vae/);
|
|
assert.match(said, /Value not in list/);
|
|
assert.match(said, /gone\.safetensors/);
|
|
});
|
|
|
|
test("a crash during execution still names its one node", () => {
|
|
const said = describe({
|
|
phase: FAILED, images: [],
|
|
error: { node: "sampler", message: "shape mismatch" },
|
|
});
|
|
assert.equal(said, "sampler: shape mismatch");
|
|
});
|
|
|
|
test("a refusal with no node errors falls back to what it did say", () => {
|
|
const said = describe({
|
|
phase: FAILED, images: [],
|
|
error: { message: "the queue could not be reached: fetch failed", nodes: {} },
|
|
});
|
|
assert.match(said, /could not be reached/);
|
|
});
|
|
|
|
test("a node error carrying nothing readable does not swallow the message", () => {
|
|
// An entry with an empty errors array would otherwise return "" and read as
|
|
// a run that failed for no reason at all.
|
|
const said = describe({
|
|
phase: FAILED, images: [],
|
|
error: { message: "refused", nodes: { "4": { class_type: "X", errors: [] } } },
|
|
});
|
|
assert.equal(said, "refused");
|
|
});
|