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.
 
 

608 lines
16 KiB

/**
* Integration tests for undo/redo through the Zustand store.
*
* Tests that undo/redo correctly captures and restores workflow state
* for all undoable actions (add/remove nodes, connect edges, etc.).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act } from "@testing-library/react";
import { useWorkflowStore } from "../workflowStore";
// Mock the Toast hook
vi.mock("@/components/Toast", () => ({
useToast: {
getState: () => ({
show: vi.fn(),
}),
},
}));
// Mock the logger
vi.mock("@/utils/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
startSession: vi.fn().mockResolvedValue(undefined),
endSession: vi.fn().mockResolvedValue(undefined),
getCurrentSession: vi.fn().mockReturnValue(null),
},
}));
// Mock localStorage
const mockLocalStorage: Record<string, string> = {};
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => mockLocalStorage[key] || null),
setItem: vi.fn((key: string, value: string) => {
mockLocalStorage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete mockLocalStorage[key];
}),
clear: vi.fn(() => {
Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]);
}),
});
function resetStore() {
const store = useWorkflowStore.getState();
store.clearWorkflow();
}
describe("Undo/Redo integration", () => {
beforeEach(() => {
vi.useFakeTimers();
resetStore();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts with canUndo and canRedo both false", () => {
const { canUndo, canRedo } = useWorkflowStore.getState();
expect(canUndo).toBe(false);
expect(canRedo).toBe(false);
});
describe("addNode + undo", () => {
it("undoes adding a node", () => {
let store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(0);
act(() => {
store.addNode("prompt", { x: 100, y: 100 });
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
expect(store.canUndo).toBe(true);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(0);
expect(store.canUndo).toBe(false);
expect(store.canRedo).toBe(true);
});
it("redoes after undoing addNode", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 100, y: 100 });
});
store = useWorkflowStore.getState();
const nodeId = store.nodes[0].id;
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(0);
act(() => {
store.redo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
expect(store.nodes[0].id).toBe(nodeId);
expect(store.canRedo).toBe(false);
});
});
describe("removeNode + undo", () => {
it("undoes removing a node", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
store = useWorkflowStore.getState();
const nodeId = store.nodes[0].id;
act(() => {
store.removeNode(nodeId);
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(0);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
expect(store.nodes[0].id).toBe(nodeId);
});
});
describe("onConnect + undo", () => {
it("undoes connecting an edge", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
store.addNode("nanoBanana", { x: 300, y: 0 });
});
store = useWorkflowStore.getState();
const promptId = store.nodes[0].id;
const genId = store.nodes[1].id;
const edgesBefore = store.edges.length;
act(() => {
store.onConnect({
source: promptId,
target: genId,
sourceHandle: "text",
targetHandle: "text",
});
});
store = useWorkflowStore.getState();
expect(store.edges.length).toBe(edgesBefore + 1);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.edges.length).toBe(edgesBefore);
});
});
describe("removeEdge + undo", () => {
it("undoes removing an edge", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
store.addNode("nanoBanana", { x: 300, y: 0 });
});
store = useWorkflowStore.getState();
const promptId = store.nodes[0].id;
const genId = store.nodes[1].id;
act(() => {
store.onConnect({
source: promptId,
target: genId,
sourceHandle: "text",
targetHandle: "text",
});
});
store = useWorkflowStore.getState();
const edgeId = store.edges[store.edges.length - 1].id;
act(() => {
store.removeEdge(edgeId);
});
store = useWorkflowStore.getState();
expect(store.edges.find((e) => e.id === edgeId)).toBeUndefined();
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.edges.find((e) => e.id === edgeId)).toBeDefined();
});
});
describe("delete node via onNodesChange + onEdgesChange restores connections", () => {
it("single undo restores both node and its connected edges", async () => {
let store = useWorkflowStore.getState();
// Create two nodes and connect them
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
store = useWorkflowStore.getState();
act(() => {
store.addNode("nanoBanana", { x: 300, y: 0 });
});
store = useWorkflowStore.getState();
const promptId = store.nodes[0].id;
const genId = store.nodes[1].id;
act(() => {
store.onConnect({
source: promptId,
target: genId,
sourceHandle: "text",
targetHandle: "text",
});
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(2);
expect(store.edges.length).toBe(1);
// Simulate pressing Delete: React Flow fires onNodesChange(remove) then
// onEdgesChange(remove) synchronously in the same cycle
act(() => {
store.onNodesChange([{ type: "remove", id: promptId }]);
store.onEdgesChange([{ type: "remove", id: store.edges[0].id }]);
});
// Wait for microtask to clear nodeRemoveCheckpointActive
await act(async () => {
await Promise.resolve();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
expect(store.edges.length).toBe(0);
// Single undo should restore both the node AND the edge
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(2);
expect(store.edges.length).toBe(1);
expect(store.nodes.find((n) => n.id === promptId)).toBeDefined();
// The delete should have been exactly one undo entry — undoing again
// should go back to before the edge was connected, not to some
// intermediate state caused by clearStaleInputImages side effects
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
// Previous checkpoint was onConnect, so edges should be gone
expect(store.edges.length).toBe(0);
expect(store.nodes.length).toBe(2);
});
it("single undo restores image-source node and does not create extra entries from clearStaleInputImages", async () => {
let store = useWorkflowStore.getState();
// Create imageInput -> nanoBanana (image connection triggers clearStaleInputImages on delete)
act(() => {
store.addNode("imageInput", { x: 0, y: 0 });
});
store = useWorkflowStore.getState();
act(() => {
store.addNode("nanoBanana", { x: 300, y: 0 });
});
store = useWorkflowStore.getState();
const imageInputId = store.nodes[0].id;
const genId = store.nodes[1].id;
act(() => {
store.onConnect({
source: imageInputId,
target: genId,
sourceHandle: "image",
targetHandle: "image",
});
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(2);
expect(store.edges.length).toBe(1);
// Delete the imageInput node — this triggers clearStaleInputImages
// which calls updateNodeData on the nanoBanana target
act(() => {
store.onNodesChange([{ type: "remove", id: imageInputId }]);
store.onEdgesChange([{ type: "remove", id: store.edges[0].id }]);
});
// Wait for microtask to clear nodeRemoveCheckpointActive
await act(async () => {
await Promise.resolve();
});
// Also advance past the debounced data-change timer (500ms)
await act(async () => {
vi.advanceTimersByTime(600);
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
expect(store.edges.length).toBe(0);
// Single undo should restore the node and edge
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(2);
expect(store.edges.length).toBe(1);
expect(store.nodes.find((n) => n.id === imageInputId)).toBeDefined();
// Verify no extra undo entries from clearStaleInputImages:
// Next undo should go back to before onConnect (2 nodes, 0 edges)
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.edges.length).toBe(0);
expect(store.nodes.length).toBe(2);
});
});
describe("new action clears redo stack", () => {
it("clears redo stack when a new undoable action is performed", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
act(() => {
store = useWorkflowStore.getState();
store.undo();
});
store = useWorkflowStore.getState();
expect(store.canRedo).toBe(true);
// Perform a new action — should clear redo stack
act(() => {
store.addNode("imageInput", { x: 100, y: 100 });
});
store = useWorkflowStore.getState();
expect(store.canRedo).toBe(false);
});
});
describe("multiple undo/redo", () => {
it("supports multiple undo/redo in sequence", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
act(() => {
store = useWorkflowStore.getState();
store.addNode("imageInput", { x: 100, y: 0 });
});
act(() => {
store = useWorkflowStore.getState();
store.addNode("nanoBanana", { x: 200, y: 0 });
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(3);
// Undo 3 times
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(2);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(0);
expect(store.canUndo).toBe(false);
// Redo 3 times
act(() => {
store.redo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(1);
act(() => {
store.redo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(2);
act(() => {
store.redo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(3);
expect(store.canRedo).toBe(false);
});
});
describe("undo on empty stack is no-op", () => {
it("does nothing when undo stack is empty", () => {
let store = useWorkflowStore.getState();
const before = store.nodes.length;
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(before);
expect(store.canUndo).toBe(false);
});
});
describe("redo on empty stack is no-op", () => {
it("does nothing when redo stack is empty", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
store = useWorkflowStore.getState();
const nodeCount = store.nodes.length;
act(() => {
store.redo();
});
store = useWorkflowStore.getState();
expect(store.nodes.length).toBe(nodeCount);
});
});
describe("clearWorkflow resets undo history", () => {
it("clears undo and redo stacks on clearWorkflow", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
store.addNode("imageInput", { x: 100, y: 0 });
});
store = useWorkflowStore.getState();
expect(store.canUndo).toBe(true);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.canRedo).toBe(true);
act(() => {
store.clearWorkflow();
});
store = useWorkflowStore.getState();
expect(store.canUndo).toBe(false);
expect(store.canRedo).toBe(false);
});
});
describe("setEdgeStyle + undo", () => {
it("undoes edge style change", () => {
let store = useWorkflowStore.getState();
const originalStyle = store.edgeStyle;
act(() => {
store.setEdgeStyle("angular");
});
store = useWorkflowStore.getState();
expect(store.edgeStyle).toBe("angular");
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.edgeStyle).toBe(originalStyle);
});
});
describe("group operations + undo", () => {
it("undoes creating a group", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
store.addNode("imageInput", { x: 100, y: 0 });
});
store = useWorkflowStore.getState();
const nodeIds = store.nodes.map((n) => n.id);
act(() => {
store.createGroup(nodeIds);
});
store = useWorkflowStore.getState();
expect(Object.keys(store.groups).length).toBe(1);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(Object.keys(store.groups).length).toBe(0);
});
it("undoes deleting a group", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
store = useWorkflowStore.getState();
const nodeIds = store.nodes.map((n) => n.id);
let groupId: string;
act(() => {
groupId = store.createGroup(nodeIds);
});
store = useWorkflowStore.getState();
expect(Object.keys(store.groups).length).toBe(1);
act(() => {
store.deleteGroup(groupId!);
});
store = useWorkflowStore.getState();
expect(Object.keys(store.groups).length).toBe(0);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(Object.keys(store.groups).length).toBe(1);
});
});
describe("hasUnsavedChanges is set on undo/redo", () => {
it("marks as unsaved after undo", () => {
let store = useWorkflowStore.getState();
act(() => {
store.addNode("prompt", { x: 0, y: 0 });
});
// Simulate that the workflow was "saved"
act(() => {
useWorkflowStore.setState({ hasUnsavedChanges: false });
});
store = useWorkflowStore.getState();
expect(store.hasUnsavedChanges).toBe(false);
act(() => {
store.undo();
});
store = useWorkflowStore.getState();
expect(store.hasUnsavedChanges).toBe(true);
});
});
});