You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

229 lines
7.8 KiB

/**
* Loop Edge Integration Tests
*
* Tests the runtime behavior of loop edges during workflow execution.
* These tests validate that loops execute N times with correct data flow.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useWorkflowStore } from "../workflowStore";
import { WorkflowNode, WorkflowEdge, WorkflowNodeData } from "@/types";
// Test helpers
function makeNode(id: string, type: string, data?: Partial<WorkflowNodeData>): WorkflowNode {
return {
id,
type,
position: { x: 0, y: 0 },
data: data || {},
} as WorkflowNode;
}
function makeEdge(
source: string,
target: string,
opts?: { isLoop?: boolean; loopCount?: number; hasPause?: boolean }
): WorkflowEdge {
return {
id: `${source}-${target}`,
source,
target,
data: opts || {},
} as WorkflowEdge;
}
function setupStore(nodes: WorkflowNode[], edges: WorkflowEdge[]): void {
useWorkflowStore.setState({ nodes, edges });
}
describe("setLoopCount", () => {
beforeEach(() => {
// Reset store to clean state
useWorkflowStore.setState({
nodes: [],
edges: [],
groups: {},
isRunning: false,
hasUnsavedChanges: false,
});
});
it("updates edge data loopCount", () => {
const edge = makeEdge("a", "b", { isLoop: true, loopCount: 3 });
setupStore([], [edge]);
useWorkflowStore.getState().setLoopCount(edge.id, 5);
const updatedEdge = useWorkflowStore.getState().edges.find((e) => e.id === edge.id);
expect(updatedEdge?.data?.loopCount).toBe(5);
});
it("clamps loop count to minimum 1", () => {
const edge = makeEdge("a", "b", { isLoop: true, loopCount: 3 });
setupStore([], [edge]);
useWorkflowStore.getState().setLoopCount(edge.id, 0);
const updatedEdge = useWorkflowStore.getState().edges.find((e) => e.id === edge.id);
expect(updatedEdge?.data?.loopCount).toBe(1);
});
it("clamps loop count to maximum 100", () => {
const edge = makeEdge("a", "b", { isLoop: true, loopCount: 3 });
setupStore([], [edge]);
useWorkflowStore.getState().setLoopCount(edge.id, 150);
const updatedEdge = useWorkflowStore.getState().edges.find((e) => e.id === edge.id);
expect(updatedEdge?.data?.loopCount).toBe(100);
});
});
describe("executeWorkflow with loop edges", () => {
beforeEach(() => {
// Reset store to clean state
useWorkflowStore.setState({
nodes: [],
edges: [],
groups: {},
isRunning: false,
hasUnsavedChanges: false,
});
});
it("executes loop subgraph exactly N times", async () => {
// Create A(imageInput) → B(prompt) → C(output) with loop edge C→A (loopCount: 3)
const nodeA = makeNode("a", "imageInput", { image: "data:image/png;base64,test" });
const nodeB = makeNode("b", "prompt", { prompt: "test prompt" });
const nodeC = makeNode("c", "output");
const edges = [
makeEdge("a", "b"),
makeEdge("b", "c"),
makeEdge("c", "a", { isLoop: true, loopCount: 3 }),
];
setupStore([nodeA, nodeB, nodeC], edges);
// Spy on copyLoopOutput to count iterations
const executionUtils = await import("../utils/executionUtils");
const copyLoopOutputSpy = vi.spyOn(executionUtils, "copyLoopOutput");
copyLoopOutputSpy.mockClear(); // Clear any previous calls
await useWorkflowStore.getState().executeWorkflow();
// copyLoopOutput should be called (loopCount - 1) times
// because first iteration uses original input
expect(copyLoopOutputSpy).toHaveBeenCalledTimes(2); // 3 iterations - 1 = 2 calls
copyLoopOutputSpy.mockRestore();
});
it("executes prefix nodes once and downstream nodes each iteration", async () => {
// Create PREFIX(imageInput) → A(prompt) → B(prompt) → DOWNSTREAM(output)
// with loop edge B→A (loopCount: 3)
// PREFIX is not downstream of the loop body — it only feeds INTO A
// DOWNSTREAM is connected from B (loop body) — it should iterate with the loop
const prefix = makeNode("prefix", "imageInput", { image: "data:image/png;base64,test" });
const nodeA = makeNode("a", "prompt", { prompt: "loop start" });
const nodeB = makeNode("b", "prompt", { prompt: "loop end" });
const downstream = makeNode("downstream", "output");
const edges = [
makeEdge("prefix", "a"),
makeEdge("a", "b"),
makeEdge("b", "downstream"),
makeEdge("b", "a", { isLoop: true, loopCount: 3 }),
];
setupStore([prefix, nodeA, nodeB, downstream], edges);
// Track which nodes are executed by subscribing to currentNodeIds changes.
// Only push when the reference changes (Zustand preserves refs for unchanged fields).
const executedNodeIds: string[] = [];
let prevRef: string[] = [];
const unsubscribe = useWorkflowStore.subscribe((state) => {
if (state.currentNodeIds !== prevRef && state.currentNodeIds.length > 0) {
executedNodeIds.push(...state.currentNodeIds);
prevRef = state.currentNodeIds;
}
});
await useWorkflowStore.getState().executeWorkflow();
unsubscribe();
const countExecutions = (id: string) => executedNodeIds.filter((nid) => nid === id).length;
// Prefix feeds into the loop but is not downstream — executes once
expect(countExecutions("prefix")).toBe(1);
// Loop body nodes execute 3 times (loopCount: 3)
expect(countExecutions("a")).toBe(3);
expect(countExecutions("b")).toBe(3);
// Downstream is connected from loop body node B — iterates with the loop
expect(countExecutions("downstream")).toBe(3);
});
it("does not affect workflows with no loop edges", async () => {
// Standard A → B → C workflow
const nodeA = makeNode("a", "imageInput", { image: "data:image/png;base64,test" });
const nodeB = makeNode("b", "prompt", { prompt: "test" });
const nodeC = makeNode("c", "output");
const edges = [makeEdge("a", "b"), makeEdge("b", "c")];
setupStore([nodeA, nodeB, nodeC], edges);
// Should execute without errors
await useWorkflowStore.getState().executeWorkflow();
// Verify workflow completed
expect(useWorkflowStore.getState().isRunning).toBe(false);
});
it("treats loopCount:1 same as no loop", async () => {
// Create A(imageInput) → B(prompt) with loop edge B→A (loopCount: 1)
const nodeA = makeNode("a", "imageInput", { image: "data:image/png;base64,test" });
const nodeB = makeNode("b", "prompt", { prompt: "test" });
const edges = [makeEdge("a", "b"), makeEdge("b", "a", { isLoop: true, loopCount: 1 })];
setupStore([nodeA, nodeB], edges);
// Spy on copyLoopOutput with a fresh mock
const executionUtils = await import("../utils/executionUtils");
const copyLoopOutputSpy = vi.spyOn(executionUtils, "copyLoopOutput");
copyLoopOutputSpy.mockClear(); // Clear any previous calls
await useWorkflowStore.getState().executeWorkflow();
// With loopCount=1, copyLoopOutput should never be called (no iterations to feedback)
expect(copyLoopOutputSpy).toHaveBeenCalledTimes(0);
copyLoopOutputSpy.mockRestore();
});
it("stops loop mid-iteration when aborted", async () => {
// Create loop with loopCount: 10
const nodeA = makeNode("a", "imageInput", { image: "data:image/png;base64,test" });
const nodeB = makeNode("b", "prompt", { prompt: "test" });
const edges = [makeEdge("a", "b"), makeEdge("b", "a", { isLoop: true, loopCount: 10 })];
setupStore([nodeA, nodeB], edges);
// Start execution (don't await)
const executionPromise = useWorkflowStore.getState().executeWorkflow();
// Poll until execution has actually started before stopping
await vi.waitFor(() => {
expect(useWorkflowStore.getState().isRunning).toBe(true);
}, { timeout: 1000 });
useWorkflowStore.getState().stopWorkflow();
await executionPromise;
// Verify execution was stopped
expect(useWorkflowStore.getState().isRunning).toBe(false);
});
});