Files

340 lines
18 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { createDom } from "../../shell/dom.js";
let hub;
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
test.after(() => teardownDom());
const rig = (phase, extra = {}) => {
const handlers = {};
const doc = { id: "P", num_frames_per_scene: 97, frame_rate: 25, scenes: [{ id: "a", text: "x" }, { id: "b", gen_unit_id: "a", cut_offset_frames: 49 }] };
const g = { DONE: "done", FAILED: "failed", CANCELLED: "cancelled", run: { state: { phase, images: [{ filename: "a.mp4" }] } },
state() { return this.run.state; }, on: (k, fn) => { handlers[k] = fn; }, subscribe: () => () => {}, cancel() {},
waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra };
const project = { project: doc, get scenes() { return doc.scenes; }, selected: null,
edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc), pref: (k, d) => ((doc.editor_settings || {})[k] ?? d) };
const app = { dom: createDom(), project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const host = document.createElement("div");
hub.setup({ host, app });
return { handlers, doc, host, app };
};
test("a run that finished before the page reloaded lands on every clip of its unit", async () => {
const { handlers, doc } = rig("done");
handlers.adopt({ sceneId: "a", projectId: "P" });
await new Promise((r) => setTimeout(r, 0));
assert.equal(doc.scene_renders.a.media.filename, "a.mp4");
assert.ok(doc.scene_renders.b.inSec > 0, "the cut half plays from its cut");
});
test("a queue ComfyUI refuses is said in the refusal's own words, not thrown", async () => {
const { host } = rig("idle", { generate: async () => false, run: { state: { phase: "idle", images: [], error: { message: "node 7 refused the graph" } } } });
host.querySelector("button").click(); // ▶ Generate
await new Promise((r) => setTimeout(r, 20));
assert.match(document.body.textContent, /node 7 refused the graph/);
});
test("a batch of takes runs the unit N times, keeps every render as a take, and starts one taste generation for all of them", async () => {
let queued = 0, fresh = 0, n = 0;
const { app, doc } = rig("idle", { generate: async () => { queued += 1; return true; }, waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }) });
app.api.newTasteGeneration = () => { fresh += 1; return Promise.resolve({}); };
const g = app.generate;
Object.defineProperty(g.run.state, "images", { get: () => [{ filename: `t${++n}.mp4` }] });
g.run.state.promptId = "p";
await app.runner.units(["a"], 3);
assert.equal(queued, 3);
assert.equal(fresh, 1, "all takes stay pending for a rating");
assert.deepEqual(doc.scene_variants.a.map((t) => t.media.filename), ["t1.mp4", "t2.mp4", "t3.mp4"]);
assert.equal(doc.scene_renders.a.media.filename, "t3.mp4");
});
test("the take that was on the clip keeps its rating when a newer one replaces it; only the last twenty-four are kept", async () => {
const { app, doc } = rig("idle", { generate: async () => true });
doc.scenes[0].rating = "10";
doc.scene_renders = { a: { media: { filename: "old.mp4" }, promptId: "p0" } };
doc.scene_variants = { a: [{ media: { filename: "old.mp4" }, promptId: "p0", rating: "" }] };
app.generate.run.state.promptId = "p1";
await app.runner.units(["a"], 1);
assert.equal(doc.scene_variants.a[0].rating, "10");
doc.scene_variants.a = Array.from({ length: 30 }, (_, i) => ({ media: { filename: `${i}.mp4` }, promptId: `q${i}`, rating: "" }));
await app.runner.units(["a"], 1);
assert.equal(doc.scene_variants.a.length, 24);
});
test("a render from before takes becomes a take; the oldest unrated take goes first when the cap is hit, never a rated one", async () => {
const { app, doc } = rig("idle", { generate: async () => true });
doc.scenes[0].rating = "10";
doc.scene_renders = { a: { media: { filename: "legacy.mp4" } } };
app.generate.run.state.promptId = "p1";
await app.runner.units(["a"], 1);
assert.deepEqual(doc.scene_variants.a.map((t) => [t.media.filename, t.rating]), [["legacy.mp4", "10"], ["a.mp4", ""]]);
doc.scene_variants.a = Array.from({ length: 24 }, (_, i) => ({ media: { filename: `${i}.mp4` }, promptId: `q${i}`, rating: i === 0 ? "10" : "" }));
await app.runner.units(["a"], 1);
assert.equal(doc.scene_variants.a[0].media.filename, "0.mp4", "the rated one stays");
assert.ok(!doc.scene_variants.a.some((t) => t.media.filename === "1.mp4"), "the oldest unrated went");
});
test("a batch with no seed input says so and makes nothing", async () => {
let queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.pipeline.slots = () => [];
await app.runner.units(["a"], 3);
assert.equal(queued, 0);
assert.match(document.body.textContent, /no seed input/);
});
test("Generate refuses, saying why, while the open project's pipeline is not the live one", async () => {
let queued = 0;
const { host, app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.pipelineOwned = () => false;
host.querySelector("button").click();
await new Promise((r) => setTimeout(r, 20));
assert.equal(queued, 0);
assert.match(document.body.textContent, /Not started: this project.s pipeline is not loaded yet/);
});
test("a run uses the pipeline as it was at the click, whatever is edited while its shots are made", async () => {
const sent = [];
const { app, doc } = rig("idle", { generate: async (a) => { sent.push(a.slots); app.pipeline.slots = () => [{ id: "s", roles: [], inputs: { steps: 99 } }]; return true; } });
doc.scenes.push({ id: "c", text: "y" });
app.pipeline.slots = () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: { steps: 20 } }];
await app.runner.units(["a", "c"]);
assert.equal(sent.length, 2);
assert.ok(sent.every((s) => s[0].inputs.steps === 20));
});
test("a scene edited while an earlier shot of the run is making goes as it read at the click; the run's own render still reaches the next shot", async () => {
const seen = [];
let calls = 0;
const { app, doc } = rig("idle", { generate: async () => { calls += 1; if (calls === 1) doc.scenes.find((s) => s.id === "c").text = "edited"; return true; } });
doc.scenes.push({ id: "c", text: "at click" });
app.inputHooks = [async ({ project, scene }) => { seen.push([scene.text, Boolean((project.scene_renders || {}).a)]); return {}; }];
await app.runner.units(["a", "c"]);
assert.deepEqual(seen, [["x", false], ["at click", true]]);
});
test("Generate this scene clicked while a run is going says so, and queues nothing more", async () => {
let queued = 0, finish;
const { app, doc, host } = rig("idle", { generate: async () => { queued += 1; return true; }, waitForTerminal: () => Object.assign(new Promise((r) => { finish = r; }), { cancel() {} }) });
let heard;
app.on = (fn) => { heard = fn; return () => {}; };
host.remove();
const h2 = document.createElement("div");
hub.setup({ host: h2, app });
app.project.selected = doc.scenes[0];
const before = document.body.textContent;
app.runner.units(["a"]);
await new Promise((r) => setTimeout(r, 0));
heard("generate.scene");
await new Promise((r) => setTimeout(r, 0));
assert.equal(queued, 1);
assert.match(document.body.textContent.replace(before, ""), /already going/);
finish("cancelled");
});
test("an edit still on its way when Generate is clicked (the Story box splitting) lands before the run reads the project", async () => {
const seen = [];
const { app, doc } = rig("idle", { generate: async () => true });
app.beforeRun = [async () => { await new Promise((r) => setTimeout(r, 10)); doc.scenes[0].text = "typed last"; }];
app.inputHooks = [({ scene }) => { seen.push(scene.text); return {}; }];
await app.runner.units(["a"]);
assert.deepEqual(seen, ["typed last"]);
});
test("a run that fails while running says which node and ComfyUI's own reason", async () => {
const { app } = rig("idle", { generate: async () => true, waitForTerminal: () => Object.assign(Promise.resolve("failed"), { cancel() {} }) });
app.generate.run.state.error = { node: "SaveVideo", message: "Saving image outside the output folder is not allowed." };
const before = document.body.textContent;
await app.runner.units(["a"]);
assert.match(document.body.textContent.replace(before, ""), /failed in SaveVideo: Saving image outside the output folder/);
});
test("a project that already has a run in ComfyUI's queue (another tab) is not queued again, and the click says why", async () => {
let queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.api.projectQueued = async (id) => id === "P";
const before = document.body.textContent;
await app.runner.units(["a"]);
assert.equal(queued, 0);
assert.match(document.body.textContent.replace(before, ""), /already has a run in ComfyUI's queue/);
});
test("another tab's run that starts between this run's shots stops this run before its next shot", async () => {
let queued = 0, asks = 0;
const { app, doc } = rig("idle", { generate: async () => { queued += 1; return true; } });
doc.scenes.push({ id: "c", text: "y" });
app.api.projectQueued = async () => ++asks > 1;
await app.runner.units(["a", "c"]);
assert.equal(queued, 1);
});
test("a refusal said while a shot is being queued names the scene", async () => {
const handlers = {};
const { app, doc } = rig("idle", { on: (k, fn) => { handlers[k] = fn; }, generate: async () => { handlers.say("Length is 5000, above the largest 4096 it takes"); return false; } });
doc.scenes.push({ id: "c", text: "y" });
const before = document.body.textContent;
await app.runner.units(["c"]);
assert.match(document.body.textContent.replace(before, ""), /Scene 3: Length is 5000/);
});
test("a scene removed while it was being made: the result is not dropped in silence, it says where the file is", async () => {
const { app, doc } = rig("idle", { generate: async () => { doc.scenes.splice(0, 2); return true; } });
app.project.editFor = async (_id, fn) => fn(doc);
const before = document.body.textContent;
await app.runner.units(["a"]);
assert.match(document.body.textContent.replace(before, ""), /was removed while it was being made.*a\.mp4/);
});
test("a picture no longer in the media bin is dropped from the scene before the run, and said, instead of failing in ComfyUI", async () => {
const { app, doc } = rig("idle", { generate: async () => true });
doc.scenes[0].references = ["deadbeef0000", "aaaaaaaaaaaa"];
doc.scenes[0].source_image = "deadbeef0001";
app.api.media = async () => ({ media: [{ id: "aaaaaaaaaaaa" }] });
await app.runner.units(["a"], 1);
assert.deepEqual(doc.scenes[0].references, ["aaaaaaaaaaaa"]);
assert.equal(doc.scenes[0].source_image, "");
assert.match(document.body.textContent, /no longer in the media bin/);
});
test("with New seed every Generate off, the seed node's own number runs, and the seed used is kept for the Sampler window", async () => {
const sent = [];
const { app, doc } = rig("idle", { generate: async ({ inputs }) => { sent.push(inputs); return true; } });
app.pipeline.slots = () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: { seed: 42 } }];
doc.editor_settings = { random_seed: false };
await app.runner.units(["a"], 1);
assert.equal((sent[0].s || {}).seed, undefined, "nothing overrides the node's 42");
assert.equal(app.lastRun.seed, 42);
doc.editor_settings = {};
await app.runner.units(["a"], 1);
assert.notEqual(sent[1].s.seed, undefined, "the default draws a fresh one");
});
test("Quick A/B runs the picked scene with every enhancement off and the last run's seed, then puts the enhancements back", async () => {
const sent = [];
let off = false;
const { app, doc } = rig("idle", { generate: async ({ inputs }) => { sent.push({ inputs, off }); return true; } });
app.pipeline.allOff = () => off;
app.pipeline.setAllOff = async (on) => { off = on; };
app.selection.ids = ["a"];
await app.runner.quickAB();
assert.equal(sent.length, 0, "no earlier run, nothing to compare with");
assert.match(document.body.textContent, /generate once first/);
app.lastRun.seed = 1234;
doc.editor_settings = {}; // the default: a fresh seed every Generate, which Quick A/B must override
await app.runner.quickAB();
assert.equal(sent.length, 1);
assert.equal(sent[0].off, true, "enhancements were off while the run was queued");
assert.equal(sent[0].inputs.s.seed, 1234);
assert.equal(off, false, "and are on again afterwards");
assert.equal(app.lastRun.seed, 1234);
await app.runner.units(["a"], 1);
assert.notEqual(sent[1].inputs.s.seed, 1234, "the next ordinary Generate is fresh again");
off = true; // already plain: stays plain
await app.runner.quickAB();
assert.equal(off, true);
});
test("Quick A/B ends with the clip from before and the new clip, labelled, side by side", async () => {
let n = 0;
const { app, doc } = rig("idle", { generate: async () => true });
Object.defineProperty(app.generate.run.state, "images", { get: () => [{ filename: `new${++n}.mp4` }] });
app.pipeline.allOff = () => false;
app.pipeline.setAllOff = async () => {};
app.selection.ids = ["a"];
app.lastRun.seed = 5;
doc.scene_renders = { a: { media: { filename: "old.mp4" }, promptId: "p0" } };
app.generate.run.state.promptId = "p1";
await app.runner.quickAB();
const vids = [...document.querySelectorAll(".cx-modal video")].map((v) => v.getAttribute("src"));
assert.equal(vids.length, 2);
assert.match(vids[0], /filename=old\.mp4/);
assert.match(vids[1], /filename=new\d\.mp4/);
const text = document.querySelector(".cx-modal").textContent;
assert.match(text, /Before/);
assert.match(text, /After: enhancements off, seed 5/);
});
test("Generate does not start while an upscale model is running, and starts again after", async () => {
let queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set(["upscale.model"]);
await app.runner.units(["a"], 1);
assert.equal(queued, 0);
assert.match(document.body.textContent, /upscale model is running/);
app.has.delete("upscale.model");
await app.runner.units(["a"], 1);
assert.equal(queued, 1);
});
test("the Training preview waits for an upscale model too, and the server's word counts after a reload", async () => {
let queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set();
app.api.upscaleBusy = async () => ({ busy: true });
const r = await app.runner.preview({ lora: "x.safetensors" });
assert.match(r.error, /upscale model is running/);
await app.runner.units(["a"], 1);
assert.equal(queued, 0);
app.api.upscaleBusy = async () => ({ busy: false });
await app.runner.units(["a"], 1);
assert.equal(queued, 1);
});
test("the next shot waits while an upscale model runs", async () => {
let queued = 0, busyNow = false, asked = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set();
app.api.upscaleBusy = async () => { asked += 1; if (asked === 3) busyNow = true; return { busy: asked === 3 }; }; // free at the click and before the first shot, busy only right before queueing
await app.runner.units(["a"], 1);
assert.equal(queued, 1);
assert.ok(busyNow && asked >= 4, "the shot asked again right before queueing and went on when the server said free");
});
test("the wait for an upscale model is said once per Generate, not once per shot", async () => {
const seen = [];
const watcher = new window.MutationObserver((rows) => rows.forEach((r) => r.addedNodes.forEach((n) => { for (const x of [n, ...(n.querySelectorAll ? n.querySelectorAll("*") : [])]) if (x.children && x.children.length === 0 && /Waiting for the upscale model/.test(x.textContent || "")) seen.push(x); })));
watcher.observe(document.body, { childList: true, subtree: true });
try {
let asked = 0, queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set();
app.api.upscaleBusy = async () => { asked += 1; return { busy: asked === 2 || asked === 5 }; };
await app.runner.units(["a"], 3);
await new Promise((r) => setTimeout(r, 0));
assert.equal(queued, 3);
assert.ok(seen.length >= 1, "it was said");
assert.equal(seen.length, 1, "said once although two shots waited");
} finally { watcher.disconnect(); }
});
test("a busy check that errors does not block Generate, and Stop ends the wait", async () => {
let queued = 0;
const { app, host } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set();
app.api.upscaleBusy = async () => { throw new Error("down"); };
await app.runner.units(["a"], 1);
assert.equal(queued, 1, "an error counts as not busy");
// busy for good: the second shot waits; pressing the Generating… button stops it
let asked = 0;
app.api.upscaleBusy = async () => { asked += 1; return { busy: asked > 1 }; };
const run = app.runner.units(["a"], 2);
await new Promise((r) => setTimeout(r, 300));
[...host.querySelectorAll("button")].find((b) => /Stop/.test(b.textContent)).click();
const t = Date.now();
await run;
assert.ok(Date.now() - t < 3500, "the wait ended with Stop");
assert.equal(queued, 1, "no shot of the stopped run went");
});
test("a project opened while ComfyUI's queue is being asked about is not run", async () => {
let queued = 0;
const { app, doc } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set();
app.api.upscaleBusy = async () => ({ busy: false });
app.api.projectQueued = async () => { app.project.project = { ...doc, id: "Other" }; return false; };
await app.runner.units(["a"], 1);
assert.equal(queued, 0);
});