Files
olivv-cs-ComfyUI-FunPack/app/tests-layout/pipeline.spec.js
T
digital-garbageandClaude Sonnet 5 6303f1312c fix: retire the stale composer layout suite, close a registry test gap
Two real findings from adversarial review of the previous commit (the v4
UI port's route change), both fixed:

- app/tests-layout/*.spec.js (20 files, 133 tests): every one of these
  navigated to /funpack/ and asserted on composer-specific DOM
  (.cx-workspace*, window.FunPack) that no longer exists there now that
  the ported v4 frontend is served at that URL instead. Left as-is they
  would silently time out on every run. Each file now carries an explicit
  test.skip(true, "...") with a comment explaining why and pointing at
  the plan's pixel-exactness step, which is what will eventually rewrite
  these against the new DOM -- declared and tracked, not quietly broken.

- core/tests/test_registry.py's fixture-based tests never exercised the
  new category field through the real announcement_of()/scan() path --
  only test_schema.py's direct validate() calls did, which bypass
  registry.py's FIELDS name-mapping entirely. A typo there (e.g.
  "Category" instead of "CATEGORY") would have passed every existing
  test. The "good module" fixture gained CATEGORY = "guidance" and
  test_the_good_module_loads now asserts on it, closing the gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:21:18 +03:00

244 lines
12 KiB
JavaScript

// The models and pipeline window, where it has a real box model.
//
// jsdom covers what the window DOES; this covers what it looks like, which is
// the half jsdom cannot see -- it has no layout, so a modal collapsed to a
// column of overlapping controls passes there and is unusable here.
import { test, expect } from "@playwright/test";
import { openPipelineWindow } from "./_menu.js";
// Skipped: / now serves the ported v4 frontend (app/legacy/), not composer --
// these specs assert on .cx-workspace*/window.FunPack, which no longer exist
// on the served page. Pending rewrite against the v4-derived DOM as part of
// the v4-UI-onto-v5-backend port's pixel-exactness verification step (see
// the plan file). Not deleted: the assertions are still the right SHAPE of
// check, just against selectors this page no longer has.
test.skip(true, "composer retired in favor of the v4 UI port -- rewrite against app/legacy's DOM");
async function openWindow(page) {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
await openPipelineWindow(page);
await expect(page.locator(".cx-modal")).toBeVisible();
}
test("the window opens on a card per group", async ({ page }) => {
await openWindow(page);
const cards = page.locator(".cx-card-title");
await expect(cards).toHaveText(["Loaders", "Preparation", "Sampling", "Render"]);
});
test("a group is a node list beside its parameters, not one on top of the other", async ({ page }) => {
await openWindow(page);
await page.locator(".cx-card", { hasText: "Loaders" }).click();
const list = page.locator('.cx-split-pane [aria-label="Nodes"]').first();
const params = page.locator('.cx-split-pane [aria-label="Parameters"]').first();
const a = await list.boundingBox();
const b = await params.boundingBox();
expect(a.width, "the node list has no width").toBeGreaterThan(100);
expect(b.x, "the parameters are not beside the list").toBeGreaterThan(a.x + a.width - 20);
});
test("every control in a group fits the pane it is in", async ({ page }) => {
// The failure this catches is the one the user reported about the frame:
// things that do not scale. A control wider than its pane is clipped, and a
// select clipped at its right edge hides the value it is showing.
await openWindow(page);
await page.locator(".cx-card", { hasText: "Loaders" }).click();
const overflowing = await page.evaluate(() => {
const pane = document.querySelector('[aria-label="Parameters"]');
const box = pane.getBoundingClientRect();
return [...pane.querySelectorAll("select, input, textarea")]
.map((c) => ({ label: c.getAttribute("aria-label") || c.type, right: c.getBoundingClientRect().right }))
.filter((c) => c.right > box.right + 1);
});
expect(overflowing, "controls run past the edge of their pane").toEqual([]);
});
test("the window still works when the window is narrow", async ({ page }) => {
// Mobile is later, but a layout that only holds at one width is not a layout.
await page.setViewportSize({ width: 420, height: 780 });
await openWindow(page);
await page.locator(".cx-card", { hasText: "Loaders" }).click();
const modal = await page.locator(".cx-modal").boundingBox();
expect(modal.width).toBeLessThanOrEqual(420);
// Stacked rather than side by side, and both still readable.
const list = await page.locator('[aria-label="Nodes"]').first().boundingBox();
const params = await page.locator('[aria-label="Parameters"]').first().boundingBox();
expect(list.width).toBeGreaterThan(150);
expect(params.width).toBeGreaterThan(150);
const overflow = await page.evaluate(() => {
const el = document.querySelector(".cx-modal-body");
return el.scrollWidth - el.clientWidth;
});
expect(overflow, "the window scrolls sideways").toBeLessThanOrEqual(1);
});
test("Save and Cancel hold the bottom instead of scrolling with the settings", async ({ page }) => {
// Being visible is not enough and was not the fault. The bar was sticky
// INSIDE a padded scroller, so a strip of the settings showed through below
// it -- half a row of text under the buttons, scrolling as the pane scrolled.
await openWindow(page);
await page.locator(".cx-card", { hasText: "Preparation" }).click();
const save = page.getByRole("button", { name: "Save", exact: true });
await expect(save).toBeVisible();
const barIn = page.locator(".cx-modal-foot");
const bar = await barIn.boundingBox();
const card = await page.locator(".cx-modal").boundingBox();
expect(bar.y + bar.height,
"settings show through below the action bar").toBeGreaterThanOrEqual(
card.y + card.height - 1);
// And it stays there once the settings are scrolled.
await page.evaluate(() => {
const pane = document.querySelector('[aria-label="Parameters"]').closest(".cx-split-pane");
pane.scrollTop = pane.scrollHeight;
});
const after = await barIn.boundingBox();
expect(after.y).toBeCloseTo(bar.y, 0);
});
test("unwiring and rewiring a socket round-trips against the real server", async ({ page }) => {
// Caught exactly this way once: jsdom's fake check() forwarded every field
// by construction, so a real bug -- pipeline.js's check() silently dropping
// `input`/`from_slot`/`from_output` because they were not in its
// destructured parameter list -- passed every jsdom test and only showed up
// as a live refusal ("which input to unwire is named by a string, not a
// NoneType") on an actual click.
await openWindow(page);
await page.locator(".cx-card", { hasText: "Preparation" }).click();
await page.locator(".cx-filter-row", { hasText: "latent" }).click();
const modelRow = page.locator(".cx-settings-row")
.filter({ has: page.locator(".cx-settings-label", { hasText: /^Model$/ }) });
await expect(modelRow).toContainText("fed by model");
await modelRow.getByRole("button", { name: "Unwire" }).click();
await expect(modelRow).toContainText("nothing feeds it");
await expect(modelRow.getByRole("button", { name: "Wire…" })).toBeVisible();
await modelRow.getByRole("button", { name: "Wire…" }).click();
const picker = page.locator(".cx-modal", { hasText: "Wire Model" });
await expect(picker).toBeVisible();
// Both real MODEL producers in the default pipeline, not just the one it
// started wired to -- proves the picker is not just replaying the old link.
await expect(picker).toContainText("model · model");
await expect(picker).toContainText("modifiers · model");
await picker.getByText("model · model", { exact: false }).click();
await expect(page.locator(".cx-modal", { hasText: "Wire Model" })).toHaveCount(0);
await expect(modelRow).toContainText("fed by model");
await expect(modelRow).toContainText("FunPack Diffusion Model Loader");
});
test("a widget input -- a linked input -- can be wired to another node's output, for real", async ({ page }) => {
// Found by adversarial review: the sockets loop had a Wire button and the
// widgets loop had none at all, so "linked inputs" -- several widgets
// sharing one node's output as their value -- could not be built through
// this window despite the backend fully supporting it. Real nodes, real
// types: FunPackDiffusionModelLoader's second output is its own STRING
// status line, and "negative"'s "text" widget is a STRING too.
await openWindow(page);
await page.locator(".cx-card", { hasText: "Preparation" }).click();
await page.locator(".cx-filter-row", { hasText: "negative" }).click();
const textRow = page.locator(".cx-settings-row")
.filter({ has: page.locator(".cx-settings-label", { hasText: /^Text$/ }) });
await expect(textRow.locator("textarea, input")).toBeVisible();
await textRow.getByRole("button", { name: /^Wire Text/ }).click();
const picker = page.locator(".cx-modal", { hasText: "Wire Text" });
await expect(picker).toBeVisible();
await expect(picker).toContainText("model · status");
await picker.getByText("model · status", { exact: false }).click();
await expect(page.locator(".cx-modal", { hasText: "Wire Text" })).toHaveCount(0);
await expect(textRow).toContainText("fed by model");
await expect(textRow.locator("textarea, input")).toHaveCount(0);
await expect(textRow.getByRole("button", { name: "Unwire" })).toBeVisible();
await textRow.getByRole("button", { name: "Unwire" }).click();
await expect(textRow).not.toContainText("fed by");
await expect(textRow.locator("textarea, input")).toBeVisible();
});
test("a node's own outputs are shown on its own panel, named and consumed", async ({ page }) => {
// Wiring is always asked for by the consuming input; that does not mean an
// output should be invisible on the node that PRODUCES it. Missed by two
// review rounds, both reasoning only from the consuming side -- found
// live, by someone just looking at a node and not seeing what it makes.
await openWindow(page);
await page.locator(".cx-card", { hasText: "Loaders" }).click();
await page.locator('[aria-label="Nodes"] .cx-filter-row', { hasText: "model" }).click();
const outputsHeading = page.locator(".cx-eyebrow", { hasText: "Outputs" });
await expect(outputsHeading).toBeVisible();
const modelOutputRow = page.locator(".cx-settings-row")
.filter({ has: page.locator(".cx-settings-label", { hasText: /^model$/ }) })
.filter({ hasNot: page.locator("input, select, textarea, button") });
await expect(modelOutputRow).toContainText("MODEL");
await expect(modelOutputRow).toContainText(/feeds/);
});
test("the Add-node picker has one search box, not two disagreeing ones", async ({ page }) => {
// filterList draws its own "Filter these results" search box; this picker
// already has an outer one that re-queries the server on every keystroke.
// Typing into filterList's own box used to show its correct LOCAL "Nothing
// matches" right next to a STALE "Showing 40 of 75" hint left over from
// the outer search's last real answer, before it -- or anything -- had
// been touched. Found live: two numbers on screen describing two different
// searches, neither telling you which is which.
await openWindow(page);
await page.locator(".cx-card", { hasText: "Sampling" }).click();
await page.getByRole("button", { name: "Add node" }).click();
const picker = page.locator(".cx-modal", { hasText: "Add a node to Sampling" });
await expect(picker.getByPlaceholder("Search installed nodes")).toBeVisible();
// Hidden via CSS (display: none), not removed from the DOM -- filterList
// still builds it, this picker just does not show it. toHaveCount(0) would
// check for removal, which is not what the fix does.
await expect(picker.getByPlaceholder("Filter these results")).not.toBeVisible();
await expect(picker).toContainText(/Showing \d+ of \d+/);
await picker.getByPlaceholder("Search installed nodes").fill("zzz-nothing-real-matches-this");
await expect(picker).toContainText("Nothing matches");
await expect(picker).not.toContainText(/Showing \d+ of \d+/);
});
test("the way in is closed while the window is open", async ({ page }) => {
// Reported from a real session: two of the same modal, one behind the other.
// Two windows over one pipeline are two drafts of it -- whichever is saved
// last wins, and the other was edited against a pipeline that had moved.
//
// There are two guards and this is the outer one: with the window up, its
// backdrop covers the menu it was opened from, so a second one cannot be
// asked for by hand at all. The inner guard -- open() called twice -- is in
// the jsdom suite, where a second call can actually be made.
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
await openPipelineWindow(page);
await expect(page.locator(".cx-modal")).toHaveCount(1);
const settings = page.getByRole("button", { name: "Settings" });
await expect(settings).toBeVisible();
const blocked = await settings.evaluate((node) => {
const b = node.getBoundingClientRect();
const top = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return !node.contains(top);
});
expect(blocked, "the opener is still reachable with the window open").toBe(true);
await page.locator(".cx-modal .cx-icon-btn").click();
await expect(page.locator(".cx-modal")).toHaveCount(0);
await openPipelineWindow(page);
await expect(page.locator(".cx-modal")).toHaveCount(1);
});