Files
ttulttul-ComfyUI-Modal/tests/progressive_preview_frontend.test.mjs
T
Ken SimpsonandClaude Opus 5.5 600d2e9963 Progressive preview: final grid display, image viewer, copy and save (#10)
* Make the progressive grid the preview's final display, with an image viewer

The slot grid now stays up after the run: the final executed images fill
it and hideOutputImages stops ComfyUI from drawing a second native preview
below. Clicking a slot enlarges it to fill the node, with prev/next
buttons, wrap-around arrow-key navigation, and Esc or click to return.
Outputs restored without an executed event are shown in the grid too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let users open, copy, and save images from the progressive preview

Expose the slot images through node.imgs, with imageIndex following the
enlarged or last-hovered slot, so ComfyUI's own Open/Copy/Save Image menu
entries work as they do on PreviewImage. Replace this node's
onDrawBackground so the classic canvas does not redraw the native preview,
and add open, save, and copy buttons to the enlarged viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:56:13 -07:00

325 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runInThisContext } from "node:vm";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.dataset = {};
this.style = {};
this.hidden = false;
this.className = "";
this.textContent = "";
this.title = "";
this.src = "";
this.listeners = new Map();
this.focused = false;
}
appendChild(child) {
this.children.push(child);
return child;
}
replaceChildren(...children) {
this.children = [...children];
}
addEventListener(name, handler) {
const handlers = this.listeners.get(name) ?? [];
handlers.push(handler);
this.listeners.set(name, handlers);
}
dispatch(name, event = {}) {
let stopped = false;
let prevented = false;
const fullEvent = {
target: this,
stopPropagation: () => {
stopped = true;
},
preventDefault: () => {
prevented = true;
},
...event,
};
for (const handler of this.listeners.get(name) ?? []) {
handler(fullEvent);
}
return { stopped, prevented };
}
focus() {
this.focused = true;
}
}
const createdElements = [];
globalThis.document = {
head: new FakeElement("head"),
body: new FakeElement("body"),
createElement: (tagName) => {
const element = new FakeElement(tagName);
createdElements.push(element);
element.click = () => {
element.clicked = true;
};
element.remove = () => {
element.removed = true;
};
return element;
},
getElementById: () => null,
};
globalThis.Image = class Image {
constructor() {
this.src = "";
}
};
const openedUrls = [];
globalThis.window = { open: (url) => openedUrls.push(url) };
const clipboardWrites = [];
globalThis.ClipboardItem = class ClipboardItem {
constructor(items) {
this.items = items;
}
};
Object.defineProperty(globalThis, "navigator", {
value: { clipboard: { write: async (items) => clipboardWrites.push(items) } },
configurable: true,
});
globalThis.fetch = async (url) => ({ blob: async () => ({ type: "image/png", url }) });
const listeners = new Map();
const extensions = [];
const graphNodes = new Map();
globalThis.__progressiveAppStub = {
registerExtension: (extension) => extensions.push(extension),
rootGraph: { getNodeById: (id) => graphNodes.get(String(id)) ?? null },
graph: { setDirtyCanvas() {} },
nodeOutputs: {},
};
globalThis.__progressiveApiStub = {
addEventListener: (name, handler) => listeners.set(name, handler),
apiURL: (route) => `/api${route}`,
};
const sourcePath = path.join(repoRoot, "web", "progressive_preview.js");
const originalSource = await readFile(sourcePath, "utf8");
runInThisContext(
[
"const app = globalThis.__progressiveAppStub;",
"const api = globalThis.__progressiveApiStub;",
originalSource.replace(/^import .*?;\n/gm, ""),
"globalThis.__progressiveExports = {",
" applyProgressivePreviewEvent, applyFinalPreviewImages, createSlotState, stepSlotIndex,",
" slotGridColumns, filledSlotCount, progressivePreviewHeaderText, progressivePreviewImageUrl,",
" resolveProgressivePreviewNode, handleProgressivePreviewEvent, finishProgressivePreview,",
" restoreStoredProgressivePreview, progressivePreviewPanels, runProgressivePreviewAction,",
"};",
].join("\n"),
{ filename: sourcePath },
);
const preview = globalThis.__progressiveExports;
const viewUrl = (name) => `/api/view?filename=${name}&subfolder=&type=temp`;
// Slot state: start lays out empty slots, slots fill out of order, a new prompt resets.
const state = preview.createSlotState();
assert.equal(preview.applyProgressivePreviewEvent(state, { phase: "start", prompt_id: "p1", slot_count: 4 }), true);
assert.equal(state.slotCount, 4);
assert.equal(preview.progressivePreviewHeaderText(state), "Remote preview · 0/4 received");
preview.applyProgressivePreviewEvent(state, {
phase: "slot", prompt_id: "p1", slot_index: 2, slot_count: 4, images: [{ filename: "c.png" }],
});
assert.equal(preview.filledSlotCount(state), 1);
preview.applyProgressivePreviewEvent(state, {
phase: "slot", prompt_id: "p2", slot_index: 0, images: [{ filename: "a.png" }],
});
assert.equal(state.promptId, "p2");
assert.equal(state.slotCount, 1);
assert.equal(preview.progressivePreviewHeaderText(state), "Remote preview · all 1 received");
assert.equal(preview.applyProgressivePreviewEvent(state, { phase: "bogus", prompt_id: "p2" }), false);
// Final images replace the slots one image per slot; empty finals are ignored.
assert.equal(preview.applyFinalPreviewImages(state, []), false);
assert.equal(preview.applyFinalPreviewImages(state, [{ filename: "x.png" }, { filename: "y.png" }]), true);
assert.equal(state.slotCount, 2);
assert.equal(state.slots.get(1)[0].filename, "y.png");
// Navigation wraps in both directions.
assert.equal(preview.stepSlotIndex({ slotCount: 3 }, 2, 1), 0);
assert.equal(preview.stepSlotIndex({ slotCount: 3 }, 0, -1), 2);
// Layout and URLs.
assert.equal(preview.slotGridColumns(0), 1);
assert.equal(preview.slotGridColumns(4), 2);
assert.equal(preview.slotGridColumns(5), 3);
assert.equal(
preview.progressivePreviewImageUrl({ filename: "a b.png", subfolder: "", type: "temp" }),
"/api/view?filename=a+b.png&subfolder=&type=temp",
);
// Subgraph node ids resolve through each subgraph node.
const innerNode = { id: 18, comfyClass: "RemoteProgressivePreview" };
graphNodes.set("12", { id: 12, subgraph: { getNodeById: (id) => (String(id) === "18" ? innerNode : null) } });
assert.equal(preview.resolveProgressivePreviewNode("12:18"), innerNode);
assert.equal(preview.resolveProgressivePreviewNode("99"), null);
// End to end: events mount a panel with placeholders and fill it.
const widgets = [];
const makeNode = (id) => ({
id,
comfyClass: "RemoteProgressivePreview",
size: [100, 100],
imgs: ["stale"],
addDOMWidget: (name, type, element, options) => {
const widget = { name, type, element, options };
widgets.push(widget);
return widget;
},
setSize(size) {
this.size = size;
},
setDirtyCanvas() {},
});
const node = makeNode(7);
graphNodes.set("7", node);
for (const extension of extensions) {
extension.setup?.();
}
const handler = listeners.get("modal_progressive_preview");
assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "7", slot_count: 3 } }), true);
const panel = preview.progressivePreviewPanels.get(node);
assert.equal(node.hideOutputImages, true);
assert.equal(node.imgs, undefined);
assert.equal(node.imageIndex, null);
assert.equal(typeof node.onDrawBackground, "function");
assert.equal(node.onDrawBackground(), undefined);
assert.equal(panel.visible, true);
assert.equal(widgets[0].options.getMinHeight() > 0, true);
assert.equal(panel.grid.children.length, 3);
assert.equal(panel.grid.children.every((cell) => cell.className.includes("is-empty")), true);
handler({
detail: {
phase: "slot", prompt_id: "p3", node: "7", slot_index: 1, slot_count: 3,
images: [{ filename: "b.png", type: "temp" }, { filename: "b2.png", type: "temp" }],
},
});
const filledCell = panel.grid.children[1];
assert.equal(filledCell.tagName, "button");
assert.equal(filledCell.children[0].src, viewUrl("b.png"));
assert.equal(filledCell.children[1].textContent, "×2");
assert.equal(panel.header.textContent, "Remote preview · 1/3 received");
// ComfyUI's Open/Copy/Save Image menu reads node.imgs[node.imageIndex].
assert.equal(node.imgs.length, 1);
assert.equal(node.imgs[0].src, viewUrl("b.png"));
assert.equal(node.imageIndex, 0);
assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "99", slot_count: 3 } }), false);
// Clicking a slot enlarges it; buttons and keys navigate with wrap-around; Esc returns to the grid.
assert.equal(panel.viewer.root.style.display, "none");
filledCell.dispatch("click");
assert.equal(panel.viewerIndex, 1);
assert.equal(panel.viewer.root.style.display, "");
assert.equal(panel.viewer.image.src, viewUrl("b.png"));
assert.equal(panel.viewer.counter.textContent, "2 / 3");
assert.equal(panel.root.focused, true);
const rightKey = panel.root.dispatch("keydown", { key: "ArrowRight" });
assert.deepEqual(rightKey, { stopped: true, prevented: true });
assert.equal(panel.viewerIndex, 2);
assert.equal(panel.viewer.image.style.display, "none");
assert.equal(panel.viewer.waiting.textContent, "Waiting for item 3…");
panel.root.dispatch("keydown", { key: "ArrowRight" });
assert.equal(panel.viewerIndex, 0);
panel.root.dispatch("keydown", { key: "ArrowLeft" });
assert.equal(panel.viewerIndex, 2);
const prevButton = panel.viewer.root.children.find((child) => child.className.includes("is-prev"));
prevButton.dispatch("click");
assert.equal(panel.viewerIndex, 1);
assert.equal(panel.root.dispatch("keydown", { key: "a" }).stopped, false);
// A slot that fills while it is enlarged appears without reopening.
panel.root.dispatch("keydown", { key: "ArrowRight" });
handler({ detail: { phase: "slot", prompt_id: "p3", node: "7", slot_index: 2, slot_count: 3, images: [{ filename: "c.png" }] } });
assert.equal(panel.viewer.image.src, viewUrl("c.png"));
assert.equal(panel.viewer.image.style.display, "");
// The context menu targets the enlarged image.
assert.equal(node.imgs[node.imageIndex].src, viewUrl("c.png"));
// Viewer toolbar: open, save, and copy act on the enlarged image.
const actions = panel.viewer.actionButtons;
assert.equal(actions.save.disabled, false);
assert.equal(await preview.runProgressivePreviewAction(panel, "open", actions.open), true);
assert.deepEqual(openedUrls, [viewUrl("c.png")]);
assert.equal(await preview.runProgressivePreviewAction(panel, "save", actions.save), true);
const downloadLink = createdElements.filter((element) => element.tagName === "a").at(-1);
assert.equal(downloadLink.href, viewUrl("c.png"));
assert.equal(downloadLink.download, "c.png");
assert.equal(downloadLink.clicked, true);
assert.equal(await preview.runProgressivePreviewAction(panel, "copy", actions.copy), true);
assert.equal(clipboardWrites.length, 1);
assert.equal(clipboardWrites[0][0].items["image/png"].url, viewUrl("c.png"));
assert.equal(actions.copy.textContent, "✓");
panel.root.dispatch("keydown", { key: "ArrowLeft" });
assert.equal(actions.save.disabled, false);
panel.root.dispatch("keydown", { key: "ArrowLeft" });
assert.equal(actions.save.disabled, true);
assert.equal(await preview.runProgressivePreviewAction(panel, "save", actions.save), false);
panel.root.dispatch("keydown", { key: "Escape" });
assert.equal(panel.viewerIndex, null);
// Hovering a grid slot makes it the context-menu target.
panel.grid.children[2].dispatch("pointerenter");
assert.equal(node.imgs[node.imageIndex].src, viewUrl("c.png"));
panel.grid.children[1].dispatch("pointerenter");
assert.equal(node.imgs[node.imageIndex].src, viewUrl("b.png"));
assert.equal(panel.viewer.root.style.display, "none");
assert.equal(panel.root.dispatch("keydown", { key: "ArrowRight" }).stopped, false);
// Pointer gestures on interactive controls do not drag the node.
assert.equal(panel.root.dispatch("pointerdown", { target: { closest: () => filledCell } }).stopped, true);
assert.equal(panel.root.dispatch("pointerdown", { target: { closest: () => null } }).stopped, false);
// The final executed event fills the grid, which stays the only display.
const nodeType = { prototype: {} };
let originalCalled = false;
nodeType.prototype.onExecuted = () => {
originalCalled = true;
};
await extensions[0].beforeRegisterNodeDef(nodeType, { name: "RemoteProgressivePreview" });
node.imgs = ["native"];
nodeType.prototype.onExecuted.call(node, {
images: [{ filename: "f1.png", type: "temp" }, { filename: "f2.png", type: "temp" }, { filename: "f3.png", type: "temp" }],
});
assert.equal(originalCalled, true);
assert.equal(node.imgs.length, 3);
assert.equal(node.imgs[2].src, viewUrl("f3.png"));
assert.equal(panel.visible, true);
assert.equal(panel.root.style.display, "");
assert.equal(panel.header.textContent, "Remote preview · all 3 received");
assert.equal(panel.grid.children[0].children[0].src, viewUrl("f1.png"));
// Replayed events for the finished prompt (browser refocus) must not overwrite the final images.
assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "7", slot_count: 3 } }), false);
assert.equal(panel.header.textContent, "Remote preview · all 3 received");
// The next prompt lays out fresh slots and closes any open viewer.
panel.grid.children[0].dispatch("click");
assert.equal(handler({ detail: { phase: "start", prompt_id: "p4", node: "7", slot_count: 2 } }), true);
assert.equal(panel.viewerIndex, null);
assert.equal(panel.grid.children.length, 2);
// Outputs restored without an executed event (page reload) still render in the panel.
const reloadedNode = makeNode(8);
graphNodes.set("8", reloadedNode);
globalThis.__progressiveAppStub.nodeOutputs["8"] = { images: [{ filename: "r.png", type: "temp" }] };
await extensions[0].nodeCreated(reloadedNode);
preview.restoreStoredProgressivePreview(reloadedNode);
const reloadedPanel = preview.progressivePreviewPanels.get(reloadedNode);
assert.equal(reloadedPanel.visible, true);
assert.equal(reloadedPanel.grid.children[0].children[0].src, viewUrl("r.png"));
console.log("progressive preview frontend checks passed");