Adversarial review of the previous commit found four real defects, all confirmed: 1. A run covering more than one scene (the whole-timeline H3 shape, or any single-scene click whose gen-unit has sibling subclips) only ever sent the FIRST scene id as extra_data.funpack_scene_id. On reload mid-run, reattach recovered just that one id, so _recordSegment only ever wrote the finished media onto scene 1 -- every other scene the job actually rendered silently got nothing recorded, no error shown. Fixed by adding funpack_scene_ids (plural) alongside the existing singular field, threaded through client.js's queuedFor and session.js's reattach() (both additive -- a new optional third onAdopt argument and a new optional response field, ignored by every existing caller such as app/boot.js) down to generate_bridge.js/store.js, which now record onto every adopted scene. 2 & 3. A run adopted on reload never primed pollStart/genWallStart, so _elapsed() and the transport button's own clock read time since the Unix epoch instead of real elapsed time, and never stopped afterward -- wedging genElapsed() for the rest of the session (its own guard is "if already started, do nothing"). Fixed: the "adopt" handler now primes both clocks (flagged approximate, same as the deleted resumeRunningGeneration used to), and every terminal run phase now stops them. 4. check()'s `notes` field (e.g. "N settings didn't reach the pipeline") was fired through transport.warn() but nothing ever subscribed to GenerateBridge's "warn" event -- silently dropped, reproducing the exact bug class the deleted code's own comment said must not recur. Fixed: wired into state.notice, same as the old code did. New tests added for both the plural-scene-id path (client.test.js, session.test.js) and the fallback-to-one-scene case. 691/692 JS tests and 548/548 Python tests pass (the one JS failure is the pre-existing, unrelated projects.test.js import-race flake). Live-verified against the dev server: a multi-scene GenerateBridge.generate() call still correctly surfaces a real pipeline refusal with no console errors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
610 lines
24 KiB
JavaScript
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());
|
|
});
|