Browse Source
Captures snapshots before all undoable mutations (add/remove nodes, connect/remove edges, paste, group operations, edge style, drag start). Data edits are debounced (500ms). Execution outputs are not undoable. History depth: 50 entries. Cleared on load/clear workflow. Keyboard shortcuts added to WorkflowCanvas and shortcuts dialog. 15 integration tests covering all undo/redo scenarios through the store. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
4 changed files with 652 additions and 1 deletions
@ -0,0 +1,466 @@ |
|||||
|
/** |
||||
|
* 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 } 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(() => { |
||||
|
resetStore(); |
||||
|
}); |
||||
|
|
||||
|
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("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); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue