feat: GGUF loader support, and fix silent pipeline-editor state loss
GGUF: port v4's gguf_support.py (backend selection, content-based .gguf detection, dequantize fallback) and wire it into both the diffusion-model and CLIP loaders. Pipeline editor: reopening the Models-and-pipeline settings window was silently discarding the whole edited pipeline (a loaded preset, every loader file, every sampler setting) because GET /api/pipeline is deliberately stateless server-side and boot.js never fed its own already-saved slots back into a freshly remounted editor -- found live while walking through the MiniMax-H3 setup flow. Fixed by re-validating the saved slots through check() on reopen instead of always replaying the raw stateless default. Also fixes, found via the extensive_testing adversarial loop over both of the above: a stale file-listing cache in the pipeline window (no way to see a newly-added model file without a full page reload), and a chain of races/leaks in its own refresh/commit error-banner handling that surfaced across five review rounds -- a rejection from a superseded request overwriting a fresher success, a failure banner never clearing on success, banners bleeding across groups on navigation, and a commit()'s refusal landing on the wrong view after the user backs out before it answers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4744d30087
commit
7a226830b1
+125
-13
@@ -92,6 +92,24 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
let editing = null; // group name
|
||||
let selected = null; // slot id
|
||||
let notes = { refused: [], incomplete: [] };
|
||||
// Kept separate from `notes.refused`: that field is about an EDIT the
|
||||
// server rejected (commit()), and stays on screen until the user does
|
||||
// something about it. A rescan() failure is unrelated to any edit -- and
|
||||
// sharing one field meant a rescan that happened to succeed a moment later
|
||||
// could silently wipe out a real, still-unaddressed commit() refusal the
|
||||
// user had not yet seen or acted on.
|
||||
let scanFailed = null;
|
||||
let learnGen = 0; // guards against two learn() calls landing out of order
|
||||
// Bumped by leave() only: every enter() is reached through the index,
|
||||
// which only ever renders after a leave(), so leave() alone sees every
|
||||
// group-to-group transition. Which VIEW an in-flight commit() belongs to
|
||||
// -- a commit started in one group can still answer after the user has
|
||||
// backed out to the index. Its DATA (slots) must still apply either way,
|
||||
// an edit that happened does not un-happen because someone stopped
|
||||
// looking, but its refusal banner belongs to a group that is no longer on
|
||||
// screen, and painting it on the index is the same misattribution
|
||||
// rescanGen/scanFailed already exist to prevent for a scan failure.
|
||||
let viewGen = 0;
|
||||
|
||||
const body = composer.region.stack({ gap: "md", label: "Pipeline", fill: true });
|
||||
|
||||
@@ -100,19 +118,38 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
async function refresh() {
|
||||
const answer = await load();
|
||||
slots = answer.slots;
|
||||
notes = { refused: [], incomplete: answer.incomplete || [] };
|
||||
// `load` can be backed by a plain read (always `refused: []`) or, when a
|
||||
// previously-saved pipeline is being re-validated on open, by the same
|
||||
// check() a structural edit uses -- which can refuse. Reading it here
|
||||
// rather than hardcoding `[]` is what keeps that case from replacing the
|
||||
// screen with an unexplained empty pipeline.
|
||||
notes = { refused: answer.refused || [], incomplete: answer.incomplete || [] };
|
||||
await learn(slots.map((slot) => slot.node));
|
||||
}
|
||||
|
||||
/** Descriptions for classes we have not seen. Asked once per class. */
|
||||
async function learn(classes) {
|
||||
const unknown = [...new Set(classes)].filter((c) => c && !nodes.has(c));
|
||||
if (!unknown.length) return;
|
||||
const described = await describe(unknown);
|
||||
/**
|
||||
* Descriptions for classes we have not seen -- or, with `force`, for
|
||||
* classes we have. A class's combo choices are a file listing (model
|
||||
* names, LoRAs), and this window's `nodes` cache lives for as long as the
|
||||
* window is open: without forcing a re-ask on entry, a file added to disk
|
||||
* mid-session stayed invisible in an already-opened group until the whole
|
||||
* page reloaded, which throws the in-progress edit away with it.
|
||||
*/
|
||||
async function learn(classes, { force = false } = {}) {
|
||||
const wanted = [...new Set(classes)].filter(Boolean);
|
||||
const ask = force ? wanted : wanted.filter((c) => !nodes.has(c));
|
||||
if (!ask.length) return;
|
||||
const mine = ++learnGen;
|
||||
const described = await describe(ask);
|
||||
// A newer learn() (another force-refresh, or the group being left and
|
||||
// re-entered) answered first. Applying this older one now would put its
|
||||
// stale choices back over the fresh ones just drawn -- the exact bug this
|
||||
// whole mechanism exists to fix, reintroduced by its own race.
|
||||
if (mine !== learnGen) return;
|
||||
// Every class asked about gets an entry, `null` included: without it an
|
||||
// absent node is asked for again on every redraw, and reads on screen as a
|
||||
// node still being looked up rather than one that is not installed.
|
||||
for (const name of unknown) nodes.set(name, described[name] ?? null);
|
||||
for (const name of ask) nodes.set(name, described[name] ?? null);
|
||||
}
|
||||
|
||||
// Which edit is the current one. Two structural edits can be in flight at
|
||||
@@ -126,12 +163,13 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
/** Send the pipeline as it now stands and keep what the server says of it. */
|
||||
async function commit(next, action = {}) {
|
||||
const mine = ++edits;
|
||||
const myView = viewGen;
|
||||
let answer;
|
||||
try {
|
||||
answer = await check({ slots: next, ...action });
|
||||
} catch (err) {
|
||||
if (mine !== edits) return false;
|
||||
notes = { refused: [err.message], incomplete: notes.incomplete };
|
||||
if (myView === viewGen) notes = { refused: [err.message], incomplete: notes.incomplete };
|
||||
draw();
|
||||
return false;
|
||||
}
|
||||
@@ -140,15 +178,25 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
if (mine !== edits) return false;
|
||||
// A refused edit did not happen, so what is on screen must stay what it
|
||||
// was: showing the refusal beside the change it refused is how someone
|
||||
// comes to believe an edit landed when it did not.
|
||||
// comes to believe an edit landed when it did not. But the refusal is
|
||||
// only shown if the user is still looking at the view that made the
|
||||
// edit -- otherwise it paints a group's own rejection onto whatever the
|
||||
// user has since navigated to, the same misattribution scanFailed exists
|
||||
// to prevent for a failed rescan.
|
||||
if (answer.refused.length) {
|
||||
notes = { refused: answer.refused, incomplete: notes.incomplete };
|
||||
// `incomplete` describes the WHOLE pipeline, not this one edit, so it
|
||||
// stays live regardless of view -- only `refused`, which is about
|
||||
// THIS edit specifically, is view-scoped.
|
||||
notes = { refused: myView === viewGen ? answer.refused : notes.refused, incomplete: notes.incomplete };
|
||||
draw();
|
||||
return false;
|
||||
}
|
||||
// The edit happened server-side regardless of where the user is looking
|
||||
// now, so the data applies unconditionally -- only the refusal notes are
|
||||
// view-scoped, same reasoning as the branch above.
|
||||
forgetDraftsFor(slots, answer.slots);
|
||||
slots = answer.slots;
|
||||
notes = { refused: [], incomplete: answer.incomplete };
|
||||
notes = { refused: myView === viewGen ? [] : notes.refused, incomplete: answer.incomplete };
|
||||
await learn(slots.map((slot) => slot.node));
|
||||
if (onApply) onApply(slots);
|
||||
draw();
|
||||
@@ -225,6 +273,7 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
function messages({ everything = false } = {}) {
|
||||
return [
|
||||
...notes.refused.map((text) => composer.banner.danger({ text })),
|
||||
...(scanFailed ? [composer.banner.danger({ text: scanFailed })] : []),
|
||||
// Not an error: a fresh install has no model picked, and colouring that
|
||||
// red says something went wrong when nothing has yet.
|
||||
...(everything ? notes.incomplete.map((text) => composer.banner.warn({ text })) : []),
|
||||
@@ -315,17 +364,72 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
|
||||
// ---- one group: nodes on the left, the selected node's parameters right
|
||||
|
||||
function enter(name) {
|
||||
let rescanGen = 0;
|
||||
|
||||
/**
|
||||
* Force-refresh a group's node descriptions and report if the server
|
||||
* could not be reached -- describe() is a real fetch and a network hiccup
|
||||
* or a restarted server leaves it rejecting, same as every other server
|
||||
* call this window makes. Silently doing nothing here would make a refresh
|
||||
* failure look identical to "nothing new to show", which defeats the point.
|
||||
*
|
||||
* `learnGen` inside learn() only guards which answer gets WRITTEN to the
|
||||
* `nodes` cache; it says nothing about which rescan() call is allowed to
|
||||
* touch `scanFailed`. Without its own token, an older rescan's late
|
||||
* rejection could still slap a stale error banner over a newer rescan's
|
||||
* fresh, successful result.
|
||||
*/
|
||||
async function rescan(classes) {
|
||||
const mine = ++rescanGen;
|
||||
try {
|
||||
await learn(classes, { force: true });
|
||||
if (mine !== rescanGen) return;
|
||||
scanFailed = null;
|
||||
} catch (err) {
|
||||
if (mine !== rescanGen) return;
|
||||
scanFailed = err.message;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
async function enter(name) {
|
||||
editing = name;
|
||||
draft = new Map();
|
||||
selected = (groupsOf(slots, extraGroups).byGroup.get(name) || [])[0]?.id ?? null;
|
||||
const mine = groupsOf(slots, extraGroups).byGroup.get(name) || [];
|
||||
selected = mine[0]?.id ?? null;
|
||||
// A scan failure belongs to the group whose rescan raised it. Carrying
|
||||
// it into a different group -- or the index, via leave() -- points
|
||||
// someone at the wrong place to investigate; this group's own rescan()
|
||||
// below sets its own, current answer moments later.
|
||||
scanFailed = null;
|
||||
// Same reasoning as rescanGen above, for an in-flight commit() started
|
||||
// in whatever group was open before this one: its refusal belongs there,
|
||||
// not here.
|
||||
viewGen += 1;
|
||||
draw();
|
||||
// Refetched fresh rather than served from `nodes`: a combo's choices are
|
||||
// a file listing, and a file added to disk since this window opened must
|
||||
// show up without forcing a full page reload, which would drop the whole
|
||||
// in-progress edit along with the stale cache.
|
||||
await rescan(mine.map((slot) => slot.node));
|
||||
}
|
||||
|
||||
function leave() {
|
||||
editing = null;
|
||||
draft = null;
|
||||
selected = null;
|
||||
scanFailed = null;
|
||||
// Invalidates any rescan() still in flight for the group being left --
|
||||
// without this, its answer lands after leave() (with editing now null)
|
||||
// and a LATE failure writes scanFailed straight onto the index, since
|
||||
// rescan()'s own guard only checks whether a NEWER rescan() has started,
|
||||
// never whether the one it belongs to has since been left.
|
||||
rescanGen += 1;
|
||||
// Same for an in-flight commit(): its DATA still applies when it lands
|
||||
// (an edit that happened does not un-happen because someone stopped
|
||||
// looking), but its refusal banner belongs to the group just left, not
|
||||
// to the index this draw() is about to show.
|
||||
viewGen += 1;
|
||||
draw();
|
||||
}
|
||||
|
||||
@@ -347,6 +451,14 @@ export function mount({ load, describe, check, search, onApply, presets,
|
||||
label: "Remove", tone: "ghost", disabled: !selected,
|
||||
onClick: () => remove(selected),
|
||||
}),
|
||||
composer.button.sm({
|
||||
label: "Refresh models", tone: "ghost",
|
||||
// Same file listing a re-entry already forces -- exposed here too,
|
||||
// because a file dropped in AFTER this group was opened has no
|
||||
// other way to appear short of reloading the page, which would
|
||||
// throw away every edit made in this session.
|
||||
onClick: () => rescan(mine.map((slot) => slot.node)),
|
||||
}),
|
||||
] }),
|
||||
] });
|
||||
|
||||
|
||||
@@ -385,6 +385,270 @@ test("a node is described once, however many slots point at it", async () => {
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("reopening a group re-asks for its node descriptions, so a file added mid-session shows up", async () => {
|
||||
// The bug this pins: `nodes` used to cache a class's description for the
|
||||
// life of the window, keyed only by class name. A model file dropped onto
|
||||
// disk after the window opened was invisible in an already-known node's
|
||||
// panel with no way to see it short of reloading the page -- which throws
|
||||
// the whole in-progress pipeline edit away with the stale cache.
|
||||
let files = ["a.safetensors"];
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
custom.describe = async (classes) => {
|
||||
custom.calls.push({ describe: [...classes] });
|
||||
return { Loader: { ...DESCRIPTIONS.Loader, widgets: [
|
||||
{ name: "model_name", type: "COMBO", choices: [...files] },
|
||||
] } };
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
await win.enter("Loaders");
|
||||
const optionsOf = () => [...rowFor(win, "Model name").querySelector("select").options]
|
||||
.map((o) => o.textContent);
|
||||
assert.deepEqual(optionsOf().filter((t) => t !== "— not set —"), ["a.safetensors"]);
|
||||
|
||||
files.push("b.safetensors");
|
||||
win.leave();
|
||||
await win.enter("Loaders");
|
||||
assert.deepEqual(optionsOf().filter((t) => t !== "— not set —"),
|
||||
["a.safetensors", "b.safetensors"],
|
||||
"reopening the group did not pick up the file added since it was last open");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("Refresh models re-asks without leaving the group", async () => {
|
||||
let files = ["a.safetensors"];
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
custom.describe = async (classes) => {
|
||||
custom.calls.push({ describe: [...classes] });
|
||||
return { Loader: { ...DESCRIPTIONS.Loader, widgets: [
|
||||
{ name: "model_name", type: "COMBO", choices: [...files] },
|
||||
] } };
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
await win.enter("Loaders");
|
||||
|
||||
files.push("b.safetensors");
|
||||
click(button(win, "Refresh models"));
|
||||
await new Promise(setImmediate);
|
||||
|
||||
const optionsOf = [...rowFor(win, "Model name").querySelector("select").options]
|
||||
.map((o) => o.textContent);
|
||||
assert.deepEqual(optionsOf.filter((t) => t !== "— not set —"), ["a.safetensors", "b.safetensors"]);
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("a failed Refresh models says so, instead of looking like there was nothing new", async () => {
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
// mount() destructures `describe` once, at open time -- reassigning
|
||||
// `custom.describe` afterward would not reach it. A stable wrapper that
|
||||
// reads a mutable flag is what lets a later part of the test change what
|
||||
// happens on the NEXT call.
|
||||
let fail = false;
|
||||
const base = custom.describe;
|
||||
custom.describe = (classes) => {
|
||||
if (fail) return Promise.reject(new Error("those nodes could not be described (500)"));
|
||||
return base(classes);
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
await win.enter("Loaders");
|
||||
|
||||
fail = true;
|
||||
click(button(win, "Refresh models"));
|
||||
await new Promise(setImmediate);
|
||||
|
||||
assert.match(bannerTexts(win).join(" "), /could not be described/);
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("an older learn() answering after a newer one does not put its stale choices back", async () => {
|
||||
// Two overlapping force-refreshes (e.g. two quick clicks of Refresh models,
|
||||
// or leave+enter fast enough that the first request is still in flight):
|
||||
// whichever answer comes back LAST must not be allowed to be the OLDER one.
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
let deferred = false;
|
||||
let resolvers = [];
|
||||
const base = custom.describe;
|
||||
custom.describe = (classes) => {
|
||||
if (!deferred) return base(classes);
|
||||
custom.calls.push({ describe: [...classes] });
|
||||
return new Promise((resolve) => resolvers.push({ resolve, classes }));
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready; // past the initial, non-force learn() before describe() defers
|
||||
|
||||
deferred = true;
|
||||
const first = win.enter("Loaders"); // in flight
|
||||
const second = win.enter("Loaders"); // also in flight, started after
|
||||
// Answer the newer request first, then the older one.
|
||||
resolvers[1].resolve({ Loader: { ...DESCRIPTIONS.Loader, widgets: [
|
||||
{ name: "model_name", type: "COMBO", choices: ["fresh.safetensors"] },
|
||||
] } });
|
||||
await second;
|
||||
resolvers[0].resolve({ Loader: { ...DESCRIPTIONS.Loader, widgets: [
|
||||
{ name: "model_name", type: "COMBO", choices: ["stale.safetensors"] },
|
||||
] } });
|
||||
await first;
|
||||
|
||||
const options = [...rowFor(win, "Model name").querySelector("select").options]
|
||||
.map((o) => o.textContent);
|
||||
assert.ok(options.includes("fresh.safetensors"), "the newer answer should have won");
|
||||
assert.ok(!options.includes("stale.safetensors"), "the older, late-arriving answer overwrote it");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("a superseded rescan's late failure does not overwrite a newer rescan's success with a false error", async () => {
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
let deferred = false;
|
||||
let handlers = [];
|
||||
const base = custom.describe;
|
||||
custom.describe = (classes) => {
|
||||
if (!deferred) return base(classes);
|
||||
return new Promise((resolve, reject) => handlers.push({ resolve, reject }));
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
|
||||
deferred = true;
|
||||
const first = win.enter("Loaders"); // will fail, but only after the second has already won
|
||||
const second = win.enter("Loaders"); // succeeds
|
||||
handlers[1].resolve({ Loader: DESCRIPTIONS.Loader });
|
||||
await second;
|
||||
handlers[0].reject(new Error("stale network failure from the OLD request"));
|
||||
await first;
|
||||
|
||||
assert.deepEqual(bannerTexts(win), [],
|
||||
"a rejection from a superseded request must not overwrite the newer success with an error banner");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("a successful rescan clears a refusal banner an earlier failed one left behind", async () => {
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
let fail = false;
|
||||
const base = custom.describe;
|
||||
custom.describe = (classes) => {
|
||||
if (fail) return Promise.reject(new Error("those nodes could not be described (500)"));
|
||||
return base(classes);
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
await win.enter("Loaders");
|
||||
|
||||
fail = true;
|
||||
click(button(win, "Refresh models"));
|
||||
await new Promise(setImmediate);
|
||||
assert.match(bannerTexts(win).join(" "), /could not be described/);
|
||||
|
||||
fail = false;
|
||||
click(button(win, "Refresh models"));
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(bannerTexts(win), [], "the earlier failure's banner should be gone after a success");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("a scan failure does not follow the user to the index or into a different group", async () => {
|
||||
const custom = server({ slots: [
|
||||
{ id: "l", group: "Loaders", node: "Loader", inputs: {} },
|
||||
{ id: "s", group: "Sampling", node: "Sampler",
|
||||
inputs: { model: ["l", 0], steps: 20, sampler_name: "euler" } },
|
||||
] });
|
||||
let fail = false;
|
||||
const base = custom.describe;
|
||||
custom.describe = (classes) => {
|
||||
if (fail) return Promise.reject(new Error("could not be described (500)"));
|
||||
return base(classes);
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
|
||||
fail = true;
|
||||
await win.enter("Loaders");
|
||||
assert.match(bannerTexts(win).join(" "), /could not be described/, "sanity: the failure shows in Loaders");
|
||||
|
||||
win.leave();
|
||||
assert.deepEqual(bannerTexts(win), [], "Loaders' scan failure bled into the index");
|
||||
|
||||
fail = false;
|
||||
await win.enter("Sampling");
|
||||
assert.deepEqual(bannerTexts(win), [], "Loaders' scan failure bled into an unrelated group");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("leaving a group before its rescan answers does not let a late failure land on the index", async () => {
|
||||
const custom = server({ slots: [{ id: "l", group: "Loaders", node: "Loader", inputs: {} }] });
|
||||
let deferred = false;
|
||||
let handlers = [];
|
||||
const base = custom.describe;
|
||||
custom.describe = (classes) => {
|
||||
if (!deferred) return base(classes);
|
||||
return new Promise((resolve, reject) => handlers.push({ resolve, reject }));
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
|
||||
deferred = true;
|
||||
const entering = win.enter("Loaders"); // rescan() now in flight, unresolved
|
||||
win.leave(); // back out before it answers
|
||||
assert.deepEqual(bannerTexts(win), [], "sanity: nothing shown right after leaving");
|
||||
|
||||
handlers[0].reject(new Error("stale network failure from the left group"));
|
||||
await entering;
|
||||
|
||||
assert.deepEqual(bannerTexts(win), [],
|
||||
"a rescan answering after its group was left must not paint the index with its failure");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("leaving a group before its commit() answers does not let a late refusal land on the index", async () => {
|
||||
// The commit()-side counterpart of the rescan race above: an edit (Remove,
|
||||
// Save, wire...) started in one group can still answer after the user has
|
||||
// backed out to the index. Its DATA must still apply -- an edit that
|
||||
// happened does not un-happen -- but its refusal banner belongs to the
|
||||
// group that made it, not to whatever the user is looking at by the time
|
||||
// the answer arrives.
|
||||
const custom = server({ refuse: "removing 'l' is ambiguous", slots: [
|
||||
{ id: "l", group: "Loaders", node: "Loader", inputs: {} },
|
||||
] });
|
||||
let deferred = false;
|
||||
let handlers = [];
|
||||
const base = custom.check;
|
||||
custom.check = (body) => {
|
||||
if (!deferred) return base(body);
|
||||
return new Promise((resolve) => handlers.push({ resolve, body }));
|
||||
};
|
||||
const win = openWindow(custom);
|
||||
await win.ready;
|
||||
await win.enter("Loaders");
|
||||
|
||||
deferred = true;
|
||||
click(button(win, "Remove")); // commit() now in flight, unresolved
|
||||
win.leave(); // back out before it answers
|
||||
assert.deepEqual(bannerTexts(win), [], "sanity: nothing shown right after leaving");
|
||||
|
||||
handlers[0].resolve({ slots: [], refused: ["removing 'l' is ambiguous"], incomplete: [], queueable: false });
|
||||
await new Promise(setImmediate);
|
||||
|
||||
assert.deepEqual(bannerTexts(win), [],
|
||||
"a commit() refusal answering after its group was left must not paint the index with it");
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("a load() that comes back refused still shows why, not an empty pipeline", async () => {
|
||||
// refresh()'s `load` can be backed by a plain stateless read (always
|
||||
// refused: []) or, when re-validating an already-saved pipeline on open,
|
||||
// by the same check() a structural edit uses -- which CAN refuse (a real
|
||||
// network failure/500 from the server mid-request). Silently dropping
|
||||
// that field would replace the screen with an unexplained empty pipeline.
|
||||
const win = openWindow({
|
||||
load: async () => ({ slots: [], refused: ["the pipeline could not be read (500)"], incomplete: [] }),
|
||||
describe: async () => ({}), check: async () => ({}), search: async () => ({ nodes: [], total: 0 }),
|
||||
});
|
||||
await win.ready;
|
||||
assert.match(bannerTexts(win).join(" "), /could not be read/);
|
||||
win.close();
|
||||
});
|
||||
|
||||
test("a pipeline that cannot be read says so instead of showing an empty window", async () => {
|
||||
const win = openWindow({
|
||||
load: async () => { throw new Error("the pipeline could not be read (500)"); },
|
||||
|
||||
Reference in New Issue
Block a user