234 lines
6.1 KiB
TypeScript
234 lines
6.1 KiB
TypeScript
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
|
// Copyright (C) 2026 Artificial Sweetener and contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
/** Provide focused ordered-media node fixtures shared across capability tests. */
|
|
|
|
import { vi } from "vitest";
|
|
|
|
import { configureOrderedMediaNode } from "../../src/orderedMediaNode";
|
|
import type {
|
|
ComfyApi,
|
|
ComfyApp,
|
|
ComfyImageResult
|
|
} from "../../src/types";
|
|
|
|
export 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)
|
|
})
|
|
};
|
|
|
|
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;
|
|
};
|
|
}
|
|
|
|
const activeNodes: Array<{ onRemoved: () => unknown }> = [];
|
|
|
|
export function resetOrderedMediaFixtures(): void {
|
|
for (const node of activeNodes.splice(0)) node.onRemoved();
|
|
document.body.replaceChildren();
|
|
}
|
|
|
|
export function configured(value: unknown, reactive = false) {
|
|
const fixture = createFixture(value, reactive);
|
|
configureOrderedMediaNode(fixture.node, fixture.app, fixture.api, CONFIG);
|
|
return fixture;
|
|
}
|
|
|
|
export 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
|
|
};
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export function visiblePreviewFiles(root: HTMLElement): string[] {
|
|
return Array.from(root.querySelectorAll<HTMLImageElement>("img"))
|
|
.filter((image) => image.style.display !== "none")
|
|
.map((image) => new URL(image.src).searchParams.get("filename") ?? "");
|
|
}
|
|
|
|
export function imageFiles(images: HTMLImageElement[] | undefined): string[] {
|
|
return (images ?? []).map(
|
|
(image) => new URL(image.src).searchParams.get("filename") ?? ""
|
|
);
|
|
}
|
|
|
|
export function mockRect(
|
|
element: Element,
|
|
left: number,
|
|
top: number,
|
|
width: number,
|
|
height: number
|
|
): void {
|
|
element.getBoundingClientRect = () => new DOMRect(left, top, width, height);
|
|
}
|
|
|
|
export function widget(
|
|
fixture: ReturnType<typeof createFixture>,
|
|
name: string
|
|
): TestWidget {
|
|
const found = fixture.node.widgets.find((candidate) => candidate.name === name);
|
|
if (!found) throw new Error(`Missing test widget ${name}.`);
|
|
return found;
|
|
}
|
|
|
|
export function selectFiles(
|
|
fixture: ReturnType<typeof createFixture>,
|
|
files: string[]
|
|
): void {
|
|
fixture.imageWidget.value = files;
|
|
fixture.imageWidget.callback?.(files);
|
|
}
|
|
|
|
export function references(...filenames: string[]): ComfyImageResult[] {
|
|
return filenames.map((filename) => ({
|
|
filename,
|
|
subfolder: "",
|
|
type: "input"
|
|
}));
|
|
}
|
|
|
|
export function deferred<T>(): {
|
|
readonly promise: Promise<T>;
|
|
readonly resolve: (value: T) => void;
|
|
} {
|
|
let resolvePromise: ((value: T) => void) | undefined;
|
|
const promise = new Promise<T>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
return {
|
|
promise,
|
|
resolve: (value) => {
|
|
if (!resolvePromise) throw new Error("Deferred promise is unavailable.");
|
|
resolvePromise(value);
|
|
}
|
|
};
|
|
}
|