Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/settings_window.test.js
T
digital-garbage 739a720adc app, core: Settings matches v4 -- retractable rail, fixed frame, real About
Three things the unified Settings window was still missing next to v4's
own, all found by looking at it next to v4 rather than in isolation:

- The sidebar is a 52px icon rail now, expanding to a full list on
  hover/focus and overlaying the content instead of a splitPane's
  permanently-docked two-pane shape -- v4's own sidebar, not an
  approximation of it. Fixing this surfaced two real CSS bugs along the
  way: filterList's own min-width:220px silently defeated the collapse
  (clipping the search box to "Sear" instead of showing an icon), and
  .cx-filter-list's unset overflow-x (computed to auto once overflow-y
  was set) put a horizontal scrollbar under it. Centering the icon in
  the collapsed row needed the label's flex-basis AND the row's own gap
  to both go to zero -- an invisible label still claims its share of
  both otherwise, and the icon renders off-centre.
- The modal now has a real height (v4's own 660px) rather than sizing to
  whichever section is showing, so switching between a short section
  (About) and a long one (Node packs) doesn't visibly resize the whole
  window. That exposed a THIRD latent bug: .cx-modal-body never had
  flex:1 1 auto, invisible until a modal had a real height to fill --
  every prior modal shrink-wrapped its content, so head+body+foot always
  summed to exactly the card's height by accident. Fixed at the shared
  rule, not just for Settings.
- About now shows what v4's does: commit and dirty state, a codename
  when the checkout has one, and the machine's real hardware/software
  facts (chip, memory, GPU or MPS, disk, OS, ComfyUI/Python/Torch/
  attention-backend versions, hostname) -- a new core/sysinfo.py
  (ported from v4's own, individually guarded so a missing probe never
  takes the panel down) behind a new GET /funpack/api/system, injected
  into settings_window.js the same way every other network call here is.

Also: a nav row's tone (the coloured icon chip) is now validated against
its closed set the same way button.js already validates its own tone --
an unknown one used to render silently wrong instead of failing loudly.
2026-09-07 13:47:18 +03:00

187 lines
7.9 KiB
JavaScript

// The unified Settings window: one modal, a searchable nav list, and every
// section mounted in place beside it -- picking one never leaves the window.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom, fire } from "../../composer/tests/_dom.js";
let createSettingsWindow;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ createSettingsWindow } = await import("../settings_window.js"));
});
test.after(() => teardownDom());
function fakeSection(id, title, { keywords } = {}) {
const destroyed = [];
return {
id, title, subtitle: `${title} subtitle`, keywords: keywords ?? "",
calls: destroyed,
lastCtx: null,
mount(ctx) {
this.lastCtx = ctx;
const node = document.createElement("div");
node.textContent = `${title} content`;
return { node, destroy: () => destroyed.push("destroyed") };
},
};
}
test("opening draws the first section by default", () => {
const w = createSettingsWindow({ sections: [fakeSection("a", "Alpha"), fakeSection("b", "Beta")] });
w.open();
const modal = document.querySelector(".cx-modal");
assert.ok(modal, "no modal was mounted");
assert.match(modal.textContent, /About FunPack/);
w.close();
});
test("picking a section in the nav swaps the content and tears down the last one, without closing the window", () => {
const alpha = fakeSection("a", "Alpha");
const w = createSettingsWindow({ sections: [alpha] });
w.open("a");
assert.match(document.querySelector(".cx-modal").textContent, /Alpha content/);
document.querySelector('.cx-filter-row[aria-selected="false"]').click(); // About
assert.equal(document.querySelectorAll(".cx-modal").length, 1, "picking a section opened or closed a modal");
assert.match(document.querySelector(".cx-modal").textContent, /About FunPack/);
assert.deepEqual(alpha.calls, ["destroyed"]);
w.close();
});
test("a mounted section gets a setFooter and a close, wired to this window's own modal", () => {
const alpha = fakeSection("a", "Alpha");
const w = createSettingsWindow({ sections: [alpha] });
w.open("a");
assert.equal(typeof alpha.lastCtx.setFooter, "function");
alpha.lastCtx.setFooter({ note: "hello" });
assert.match(document.querySelector(".cx-modal-foot").textContent, /hello/);
alpha.lastCtx.close();
assert.equal(document.querySelector(".cx-modal"), null, "the section's own close did not close the window");
});
test("switching sections clears the previous one's footer instead of leaving it showing", () => {
const alpha = fakeSection("a", "Alpha");
const beta = fakeSection("b", "Beta");
const w = createSettingsWindow({ sections: [alpha, beta] });
w.open("a");
alpha.lastCtx.setFooter({ note: "alpha's own note" });
w.open("b");
assert.doesNotMatch(document.querySelector(".cx-modal-foot").textContent, /alpha's own note/);
w.close();
});
test("opening again while already open jumps to the requested section instead of stacking a second modal", () => {
const w = createSettingsWindow({ sections: [fakeSection("a", "Alpha"), fakeSection("b", "Beta")] });
w.open("a");
w.open("b");
assert.equal(document.querySelectorAll(".cx-modal").length, 1);
assert.match(document.querySelector(".cx-modal").textContent, /Beta content/);
w.close();
});
test("the search box narrows the list to sections matching the title", () => {
const w = createSettingsWindow({ sections: [fakeSection("a", "Alpha"), fakeSection("b", "Beta")] });
w.open();
const search = document.querySelector(".cx-search");
search.value = "Beta";
search.dispatchEvent(new window.Event("input", { bubbles: true }));
const labels = [...document.querySelectorAll(".cx-filter-label")].map((n) => n.textContent);
assert.deepEqual(labels, ["Beta"]);
w.close();
});
test("the search box also matches a section's keywords, not only its visible title and subtitle", () => {
// The bug this guards: an earlier version only searched `label` + `hint`,
// so a section findable by a word that appears ONLY in its keywords list
// (e.g. "loaders" for a section titled "Models and pipeline") silently
// vanished from the filtered list instead of matching.
const w = createSettingsWindow({
sections: [fakeSection("a", "Alpha", { keywords: "xyzzy" }), fakeSection("b", "Beta")],
});
w.open();
const search = document.querySelector(".cx-search");
search.value = "xyzzy";
search.dispatchEvent(new window.Event("input", { bubbles: true }));
const labels = [...document.querySelectorAll(".cx-filter-label")].map((n) => n.textContent);
assert.deepEqual(labels, ["Alpha"]);
w.close();
});
test("a gitStatus rejection degrades to unknown rather than throwing or hanging on ellipsis", async () => {
const w = createSettingsWindow({ sections: [], gitStatus: () => Promise.reject(new Error("offline")) });
w.open("about");
await new Promise((r) => setTimeout(r, 0));
assert.match(document.querySelector(".cx-modal").textContent, /unknown/);
w.close();
});
test("a systemInfo rejection leaves the identity facts showing instead of losing them too", async () => {
const w = createSettingsWindow({
sections: [],
gitStatus: () => Promise.resolve({ ok: true, version: "5.0.0", branch: "v5" }),
systemInfo: () => Promise.reject(new Error("offline")),
});
w.open("about");
await new Promise((r) => setTimeout(r, 0));
assert.match(document.querySelector(".cx-modal").textContent, /5\.0\.0/);
assert.match(document.querySelector(".cx-modal").textContent, /v5/);
w.close();
});
test("system facts arrive independently of git status -- one being slow does not hold the other back", async () => {
let resolveGit;
const w = createSettingsWindow({
sections: [],
gitStatus: () => new Promise((r) => { resolveGit = r; }),
systemInfo: () => Promise.resolve({ cpu: { threads: 8 }, python: "3.12.0", gpus: [] }),
});
w.open("about");
await new Promise((r) => setTimeout(r, 0));
assert.match(document.querySelector(".cx-modal").textContent, /3\.12\.0/, "system facts waited on git");
resolveGit({ ok: true, version: "5.0.0", branch: "v5" });
w.close();
});
test("a click outside the window does not close it -- a half-finished edit in any section is not a click away from gone", () => {
// The bug this guards: pipeline_window.js's own standalone modal has
// ALWAYS refused to close on an outside click (a group edit's draft is
// the only copy of it), but Settings' own modal was built without
// carrying that over, so hosting Models and pipeline in here silently
// lost the protection its own file was written to guarantee.
const alpha = fakeSection("a", "Alpha");
const w = createSettingsWindow({ sections: [alpha] });
w.open("a");
fire(document.body, "pointerdown");
assert.ok(document.querySelector(".cx-modal"), "an outside click closed the window");
w.close();
});
test("closing tears down the active section and a later open starts fresh", () => {
const alpha = fakeSection("a", "Alpha");
const w = createSettingsWindow({ sections: [alpha] });
w.open("a");
w.close();
assert.equal(document.querySelector(".cx-modal"), null);
assert.deepEqual(alpha.calls, ["destroyed"]);
});
test("two independent windows do not share which section is open", () => {
// The bug this guards: `current` used to live at module scope, so a second
// createSettingsWindow() instance's open() would redraw the FIRST
// instance's already-open modal instead of opening its own.
const w1 = createSettingsWindow({ sections: [fakeSection("a", "Alpha")] });
const w2 = createSettingsWindow({ sections: [fakeSection("z", "Zeta")] });
w1.open("a");
w2.open("z");
assert.equal(document.querySelectorAll(".cx-modal").length, 2);
const texts = [...document.querySelectorAll(".cx-modal")].map((m) => m.textContent);
assert.ok(texts.some((t) => t.includes("Alpha content")), "the first window lost its own section");
assert.ok(texts.some((t) => t.includes("Zeta content")), "the second window did not open its own section");
w1.close();
w2.close();
});