// SimpleSyrup - workflow-focused ComfyUI extensions for image generation // Copyright (C) 2026 Artificial Sweetener and contributors // SPDX-License-Identifier: AGPL-3.0-or-later import { afterEach, describe, expect, it, vi } from "vitest"; import { configureOrderedMediaNode, registerOrderedMediaNode } from "../src/orderedMediaNode"; import type { ComfyApi, ComfyApp, ComfyExtension, ComfyImageResult, ComfyNodeExecutionOutput } from "../src/types"; const CONFIG = { nodeId: "SimpleSyrup.LoadImageList", labels: { singular: "image", plural: "images", replace: "Replace images...", add: "Add images..." }, preview: (files: string[]) => Promise.resolve({ images: references(...files), animated: files.map(() => false) }) }; const activeNodes: Array<{ onRemoved: () => unknown }> = []; afterEach(() => { for (const node of activeNodes.splice(0)) node.onRemoved(); document.body.replaceChildren(); }); describe("ordered-media node integration", () => { it.each(["Nodes 1.0", "Nodes 2.0"])( "publishes through Comfy's native preview under %s", async () => { const fixture = createFixture(["one.png"]); configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, CONFIG ); expect(fixture.imageWidget.hidden).toBe(true); expect(fixture.uploadWidget.hidden).toBe(true); expect(fixture.imageWidget.computeSize?.()).toEqual([0, -4]); expect( fixture.node.widgets.some((candidate) => [ "simple_syrup_selected_media", "simple_syrup_move_earlier", "simple_syrup_move_later", "simple_syrup_remove_media" ].includes(candidate.name) ) ).toBe(false); expect(widget(fixture, "simple_syrup_replace_media").label).toBe( "Replace images..." ); expect(widget(fixture, "simple_syrup_add_media").label).toBe( "Add images..." ); expect("addDOMWidget" in fixture.node).toBe(false); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png") ); }); expect(fixture.executed).toHaveBeenCalledWith( expect.objectContaining({ node: "7", display_node: "7" }) ); } ); it("appends native multi-upload results and preserves duplicates", async () => { const fixture = configured(["one.png", "same.png"]); widget(fixture, "simple_syrup_add_media").callback?.(); fixture.imageWidget.value = "two.png"; fixture.imageWidget.callback?.("two.png"); fixture.imageWidget.value = ["two.png", "same.png"]; fixture.imageWidget.callback?.(["two.png", "same.png"]); expect(fixture.nativeUpload).toHaveBeenCalledOnce(); expect(fixture.imageWidget.value).toEqual([ "one.png", "same.png", "two.png", "same.png" ]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "same.png", "two.png", "same.png") ); }); }); it("treats pasted and dropped files as append operations", () => { const fixture = configured(["existing.png"]); fixture.node.onDragDrop(); selectFiles(fixture, ["dropped-a.png", "dropped-b.png"]); expect(fixture.imageWidget.value).toEqual([ "existing.png", "dropped-a.png", "dropped-b.png" ]); fixture.node.pasteFiles(); selectFiles(fixture, ["pasted.png"]); expect(fixture.imageWidget.value).toEqual([ "existing.png", "dropped-a.png", "dropped-b.png", "pasted.png" ]); }); it("attaches actions when a multi-image native gallery mounts later", async () => { const fixture = configured(["one.png", "two.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(2); }); expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(0); const root = document.createElement("section"); root.dataset.nodeId = "7"; root.append( nativePreviewButton("one.png", 0), nativePreviewButton("two.png", 90) ); document.body.append(root); await vi.waitFor(() => { expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); }); }); it("moves and removes exact positions through thumbnail actions", async () => { const fixture = configured(["same.png", "middle.png", "same.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(3); }); const root = document.createElement("section"); root.dataset.nodeId = "7"; root.append( nativePreviewImage("same.png", 0), nativePreviewImage("middle.png", 90), nativePreviewImage("same.png", 180) ); document.body.append(root); await vi.waitFor(() => { expect(document.querySelectorAll("[data-ss-media-move-earlier]")).toHaveLength(3); }); document .querySelectorAll("[data-ss-media-move-earlier]")[2] ?.click(); expect(fixture.imageWidget.value).toEqual([ "same.png", "same.png", "middle.png" ]); expect(visiblePreviewFiles(root)).toEqual([ "same.png", "same.png", "middle.png" ]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("same.png", "same.png", "middle.png") ); }); expect(fixture.onWidgetChanged).toHaveBeenCalledWith( "image", ["same.png", "same.png", "middle.png"], ["same.png", "middle.png", "same.png"], fixture.imageWidget ); document .querySelectorAll("[data-ss-media-remove]")[1] ?.click(); expect(fixture.imageWidget.value).toEqual(["same.png", "middle.png"]); expect(visiblePreviewFiles(root)).toEqual(["same.png", "middle.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("same.png", "middle.png") ); }); }); it("keeps exactly the native cells in visual order during a slow rebuild", async () => { const delayed = deferred(); let previewCalls = 0; const config = { ...CONFIG, preview: (files: string[]) => { previewCalls += 1; return previewCalls === 1 ? Promise.resolve({ images: references(...files) }) : delayed.promise; } }; const fixture = createFixture(["one.png", "two.png", "three.png"]); configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, config ); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "two.png", "three.png") ); }); const root = document.createElement("section"); root.dataset.nodeId = "7"; root.append( nativePreviewImage("one.png", 0), nativePreviewImage("two.png", 90), nativePreviewImage("three.png", 180) ); document.body.append(root); await vi.waitFor(() => { expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(3); }); document .querySelectorAll("[data-ss-media-move-later]")[0] ?.click(); expect(fixture.imageWidget.value).toEqual([ "two.png", "one.png", "three.png" ]); expect(visiblePreviewFiles(root)).toEqual([ "two.png", "one.png", "three.png" ]); expect(root.querySelectorAll("img")).toHaveLength(3); expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "two.png", "three.png") ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(visiblePreviewFiles(root)).toEqual([ "two.png", "one.png", "three.png" ]); delayed.resolve({ images: references("two.png", "one.png", "three.png") }); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("two.png", "one.png", "three.png") ); }); expect(visiblePreviewFiles(root)).toEqual([ "two.png", "one.png", "three.png" ]); root.replaceChildren( nativePreviewImage("two.png", 0), nativePreviewImage("one.png", 90), nativePreviewImage("three.png", 180) ); expect(visiblePreviewFiles(root)).toEqual([ "two.png", "one.png", "three.png" ]); expect(root.querySelectorAll("img")).toHaveLength(3); }); it("keeps the native visual and serialized order when rebuilding fails", async () => { const logger = { warn: vi.fn() }; let previewCalls = 0; const config = { ...CONFIG, preview: (files: string[]) => { previewCalls += 1; return previewCalls === 1 ? Promise.resolve({ images: references(...files) }) : Promise.reject(new Error("preview unavailable")); } }; const fixture = createFixture(["one.png", "two.png"]); configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, config, logger ); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "two.png") ); }); const root = document.createElement("section"); root.dataset.nodeId = "7"; root.append( nativePreviewImage("one.png", 0), nativePreviewImage("two.png", 90) ); document.body.append(root); await vi.waitFor(() => { expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); }); document .querySelectorAll("[data-ss-media-move-later]")[0] ?.click(); await vi.waitFor(() => { expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("preview unavailable"), expect.any(Error) ); }); expect(fixture.imageWidget.value).toEqual(["two.png", "one.png"]); expect(visiblePreviewFiles(root)).toEqual(["two.png", "one.png"]); expect(root.querySelectorAll("img")).toHaveLength(2); expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "two.png") ); }); it("preserves native thumbnail inspection clicks in Nodes 2.0", async () => { const fixture = configured(["one.png", "two.png", "three.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(3); }); const root = document.createElement("section"); root.dataset.nodeId = "7"; const images = ["one.png", "two.png", "three.png"].map( (filename, index) => nativePreviewImage(filename, index * 90) ); root.append(...images); document.body.append(root); const clicked = vi.fn(); images[1]?.addEventListener("click", clicked); images[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(clicked).toHaveBeenCalledOnce(); }); it("moves Nodes 1.0 native canvas cells through imageRects", async () => { const fixture = createFixture(["one.png", "two.png"]); const canvas = document.createElement("canvas"); mockRect(canvas, 0, 0, 500, 500); document.body.append(canvas); fixture.app.canvas = { canvas, convertEventToCanvasOffset: (event) => [event.clientX, event.clientY], convertOffsetToCanvas: (position) => [...position] }; Object.assign(fixture.node, { pos: [10, 100], imageIndex: null, imageRects: [ [0, 50, 80, 80], [80, 50, 80, 80] ], imgs: [nativePreviewImage("one.png", 0), nativePreviewImage("two.png", 90)] }); configureOrderedMediaNode(fixture.node, fixture.app, fixture.api, CONFIG); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(2); }); await vi.waitFor(() => { expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); }); document .querySelectorAll("[data-ss-media-move-later]")[0] ?.click(); expect(fixture.imageWidget.value).toEqual(["two.png", "one.png"]); expect(imageFiles(fixture.node.imgs)).toEqual(["two.png", "one.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("two.png", "one.png") ); }); }); it("normalizes scalar workflows and survives Nodes 2.0 reactive assignments", async () => { const fixture = configured("saved.png", true); const onGraphConfigured = fixture.node.onGraphConfigured; if (!onGraphConfigured) throw new Error("Expected graph configuration callback."); onGraphConfigured(); expect(fixture.imageWidget.value).toEqual(["saved.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("saved.png") ); }); }); it("retains array-valued media when the legacy graph lifecycle clears the widget", async () => { const fixture = createFixture(["one.png", "two.png"]); fixture.node.onGraphConfigured = () => { fixture.imageWidget.value = undefined; }; configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, CONFIG ); fixture.node.onGraphConfigured(); expect(fixture.imageWidget.value).toEqual(["one.png", "two.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "two.png") ); }); }); it("restores persisted widget values when Comfy clears the live media widget", async () => { const fixture = createFixture(undefined); fixture.node.widgets_values = [ ["one.png", "two.png"], "image", "ordered_media", "ordered_media" ]; configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, CONFIG ); fixture.imageWidget.value = undefined; fixture.node.onGraphConfigured?.(); expect(fixture.imageWidget.value).toEqual(["one.png", "two.png"]); await vi.waitFor(() => { expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( references("one.png", "two.png") ); }); }); it("restores native handlers and clears preview on node removal", () => { const fixture = createFixture(["one.png"]); const paste = fixture.node.pasteFiles; const drop = fixture.node.onDragDrop; configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, CONFIG ); fixture.node.onRemoved(); expect(fixture.node.pasteFiles).toBe(paste); expect(fixture.node.onDragDrop).toBe(drop); expect(fixture.originalRemoved).toHaveBeenCalledOnce(); expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual([]); }); it("registers a guarded extension and ignores unrelated nodes", async () => { const fixture = createFixture([]); let extension: ComfyExtension | undefined; fixture.app.registerExtension = (value: ComfyExtension) => { extension = value; }; registerOrderedMediaNode( fixture.app, fixture.api, "test.extension", CONFIG ); fixture.node.constructor.comfyClass = "Other.Node"; await extension?.nodeCreated?.(fixture.node); expect(extension?.name).toBe("test.extension"); expect(fixture.node.addWidget).not.toHaveBeenCalled(); }); }); interface TestWidget { name: string; value: unknown; type?: string; label?: string; hidden?: boolean; disabled?: boolean; callback?: (value?: unknown) => void; computeSize?: (width?: number) => [number, number]; options?: { canvasOnly?: boolean; serialize?: boolean; tooltip?: string; hidden?: boolean; }; } function configured(value: unknown, reactive = false) { const fixture = createFixture(value, reactive); configureOrderedMediaNode( fixture.node, fixture.app, fixture.api, CONFIG ); return fixture; } function createFixture(value: unknown, reactive = false) { const nativeUpload = vi.fn(); const imageWidget: TestWidget = { name: "image", value, type: "combo", options: { canvasOnly: true } }; if (reactive) { let stored = value; Object.defineProperty(imageWidget, "value", { configurable: true, get: () => stored, set: (next: unknown) => { stored = next; imageWidget.callback?.(next); } }); } const uploadWidget: TestWidget = { name: "upload", value: "image", type: "button", callback: nativeUpload, options: { serialize: false, canvasOnly: true } }; const widgets = [imageWidget, uploadWidget]; const originalRemoved = vi.fn(); const onWidgetChanged = vi.fn(); const node = { constructor: { comfyClass: "SimpleSyrup.LoadImageList" }, id: 7, widgets, widgets_values: undefined as unknown[] | undefined, imgs: undefined as HTMLImageElement[] | undefined, graph: { setDirtyCanvas: vi.fn() }, pasteFiles: vi.fn(() => true) as (...args: unknown[]) => unknown, onDragDrop: vi.fn(() => true) as (...args: unknown[]) => unknown, onRemoved: originalRemoved as (...args: unknown[]) => unknown, onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined, onWidgetChanged, addWidget: vi.fn( ( type: "button" | "combo", name: string, widgetValue: string, callback: (value?: unknown) => void, options?: TestWidget["options"] ): TestWidget => { const created: TestWidget = { type, name, value: widgetValue, callback }; if (options) created.options = options; widgets.push(created); return created; } ) }; activeNodes.push(node); const app = { nodeOutputs: {}, ui: { settings: { addSetting: vi.fn() } }, registerExtension: vi.fn() } as unknown as ComfyApp; const events = new EventTarget(); const api = events as ComfyApi; const executed = vi.fn(); events.addEventListener("executed", (event: Event) => { executed((event as CustomEvent).detail); }); return { api, app, executed, imageWidget, nativeUpload, node, onWidgetChanged, originalRemoved, uploadWidget }; } function nativePreviewImage( filename: string, left: number ): HTMLImageElement { const image = document.createElement("img"); image.src = `/api/view?filename=${filename}&subfolder=&type=input&preview=webp`; Object.defineProperties(image, { complete: { configurable: true, value: true }, naturalWidth: { configurable: true, value: 80 } }); mockRect(image, left, 0, 80, 80); return image; } function nativePreviewButton(filename: string, left: number): HTMLButtonElement { const button = document.createElement("button"); button.append(nativePreviewImage(filename, left)); mockRect(button, left, 0, 80, 80); return button; } function visiblePreviewFiles(root: HTMLElement): string[] { return Array.from(root.querySelectorAll("img")) .filter((image) => image.style.display !== "none") .map((image) => new URL(image.src).searchParams.get("filename") ?? ""); } function imageFiles(images: HTMLImageElement[] | undefined): string[] { return (images ?? []).map( (image) => new URL(image.src).searchParams.get("filename") ?? "" ); } function mockRect( element: Element, left: number, top: number, width: number, height: number ): void { element.getBoundingClientRect = () => new DOMRect(left, top, width, height); } function widget( fixture: ReturnType, name: string ): TestWidget { const found = fixture.node.widgets.find((candidate) => candidate.name === name); if (!found) throw new Error(`Missing test widget ${name}.`); return found; } function selectFiles( fixture: ReturnType, files: string[] ): void { fixture.imageWidget.value = files; fixture.imageWidget.callback?.(files); } function references(...filenames: string[]): ComfyImageResult[] { return filenames.map((filename) => ({ filename, subfolder: "", type: "input" })); } function deferred(): { readonly promise: Promise; readonly resolve: (value: T) => void; } { let resolvePromise: ((value: T) => void) | undefined; const promise = new Promise((resolve) => { resolvePromise = resolve; }); return { promise, resolve: (value) => { if (!resolvePromise) throw new Error("Deferred promise is unavailable."); resolvePromise(value); } }; }