import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; beforeEach(() => { vi.resetModules(); vi.useFakeTimers(); globalThis.window = { location: { href: "http://localhost/" }, }; }); afterEach(() => { vi.useRealTimers(); delete globalThis.HTMLCanvasElement; }); describe("NodeStreamController", () => { it("streams the selected node canvas preview", async () => { const onOutput = vi.fn(); const node = { id: 42, comfyClass: "PreviewImage", imgs: [{ src: "/view?filename=should_not_emit.png&subfolder=&type=output" }], }; const app = { canvas: { selected_nodes: { 42: node } }, graph: { getNodeById(id) { return Number(id) === 42 ? node : null; }, }, }; const mod = await import("../features/viewer/nodeStream/NodeStreamController.js"); mod.initNodeStream({ app, onOutput }); mod.setNodeStreamActive(true); await vi.advanceTimersByTimeAsync(250); expect(mod.getNodeStreamActive()).toBe(true); expect(mod.getSelectedNodeId()).toBe("42"); expect(mod.getPinnedNodeId()).toBeNull(); expect(onOutput).toHaveBeenCalledWith( expect.objectContaining({ filename: "should_not_emit.png", type: "output", kind: "image", _nodeId: "42", _source: "canvas", }), ); mod.teardownNodeStream(app); }); it("ignores execution-driven output updates in selection-only mode", async () => { const onOutput = vi.fn(); const app = { canvas: { selected_nodes: {} }, graph: { getNodeById() { return null; }, }, }; const mod = await import("../features/viewer/nodeStream/NodeStreamController.js"); mod.initNodeStream({ app, onOutput }); mod.onNodeOutputs( new Map([["99", { images: [{ filename: "still_ignored.png", type: "output" }] }]]), { app }, ); mod.setNodeStreamActive(true); await vi.advanceTimersByTimeAsync(100); expect(mod.getNodeStreamActive()).toBe(true); expect(onOutput).not.toHaveBeenCalled(); mod.teardownNodeStream(app); }); it("resolves downstream previews when the graph is exposed through canvas.graph", async () => { const onOutput = vi.fn(); const selectedNode = { id: 1, comfyClass: "CustomProcessor", outputs: [{ links: [77] }], }; const previewNode = { id: 2, comfyClass: "PreviewImage", inputs: [{ link: 77 }], imgs: [{ src: "/view?filename=downstream.png&subfolder=previews&type=output" }], }; const graph = { nodes: [selectedNode, previewNode], links: { 77: [77, 1, 0, 2, 0, "IMAGE"] }, getNodeById(id) { return [selectedNode, previewNode].find((node) => Number(node.id) === Number(id)) || null; }, }; const app = { canvas: { graph, selected_nodes: { 1: selectedNode }, }, }; const mod = await import("../features/viewer/nodeStream/NodeStreamController.js"); mod.initNodeStream({ app, onOutput }); mod.setNodeStreamActive(true); await vi.advanceTimersByTimeAsync(250); expect(onOutput).toHaveBeenCalledWith( expect.objectContaining({ filename: "downstream.png", subfolder: "previews", type: "output", kind: "image", _nodeId: "1", _previewNodeId: "2", _source: "graph-downstream", }), ); mod.teardownNodeStream(app); }); it("streams the ImageOps preview canvas as a cached data URL and re-emits only on signature change", async () => { class MockCanvas {} globalThis.HTMLCanvasElement = MockCanvas; const canvas = new MockCanvas(); canvas.width = 320; canvas.height = 180; canvas.toDataURL = vi.fn(() => "data:image/png;base64,preview"); const onOutput = vi.fn(); const node = { id: 7, comfyClass: "ImageOpsPreview", __imageops_state: { canvas, lastKey: "render-a", lastRenderTick: 1, nativeDirty: false, }, }; const app = { canvas: { selected_nodes: { 7: node } }, graph: { getNodeById(id) { return Number(id) === 7 ? node : null; }, }, }; const mod = await import("../features/viewer/nodeStream/NodeStreamController.js"); mod.initNodeStream({ app, onOutput }); mod.setNodeStreamActive(true); await vi.advanceTimersByTimeAsync(10); expect(onOutput).toHaveBeenCalledWith( expect.objectContaining({ filename: expect.stringMatching(/^imageops_7_[a-f0-9]+\.png$/), url: "data:image/png;base64,preview", kind: "image", width: 320, height: 180, _nodeId: "7", _classType: "ImageOpsPreview", _source: "imageops-live-preview", }), ); expect(canvas.toDataURL).toHaveBeenCalledTimes(1); // Same signature → no re-emit and no re-encode. onOutput.mockClear(); await vi.advanceTimersByTimeAsync(250); expect(onOutput).not.toHaveBeenCalled(); expect(canvas.toDataURL).toHaveBeenCalledTimes(1); // Signature changes → emit + encode again. node.__imageops_state.lastKey = "render-b"; await vi.advanceTimersByTimeAsync(250); expect(onOutput).toHaveBeenCalledTimes(1); expect(canvas.toDataURL).toHaveBeenCalledTimes(2); mod.teardownNodeStream(app); }); it("normalizes unsupported watch mode values back to selected", async () => { const app = { canvas: { selected_nodes: {} }, graph: { getNodeById() { return null; }, }, }; const mod = await import("../features/viewer/nodeStream/NodeStreamController.js"); mod.initNodeStream({ app, onOutput: vi.fn() }); mod.setWatchMode("all"); expect(mod.getWatchMode()).toBe("selected"); mod.teardownNodeStream(app); }); it("emits an empty status when the selection is cleared", async () => { const onStatus = vi.fn(); const node = { id: 42, comfyClass: "PreviewImage", imgs: [{ src: "/view?filename=selected.png&subfolder=&type=output" }], }; const app = { canvas: { selected_nodes: { 42: node } }, graph: { getNodeById(id) { return Number(id) === 42 ? node : null; }, }, }; const mod = await import("../features/viewer/nodeStream/NodeStreamController.js"); mod.initNodeStream({ app, onOutput: vi.fn(), onStatus }); mod.setNodeStreamActive(true); await vi.advanceTimersByTimeAsync(200); app.canvas.selected_nodes = {}; await vi.advanceTimersByTimeAsync(200); expect(onStatus).toHaveBeenLastCalledWith("", ""); mod.teardownNodeStream(app); }); });