Browse Source
Move groupNodesByLevel, chunk, revokeBlobUrl, clearNodeImageRefs, and concurrency settings into executionUtils.ts with 21 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
3 changed files with 349 additions and 115 deletions
@ -0,0 +1,203 @@ |
|||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"; |
||||
|
import { |
||||
|
groupNodesByLevel, |
||||
|
chunk, |
||||
|
revokeBlobUrl, |
||||
|
clearNodeImageRefs, |
||||
|
loadConcurrencySetting, |
||||
|
saveConcurrencySetting, |
||||
|
DEFAULT_MAX_CONCURRENT_CALLS, |
||||
|
CONCURRENCY_SETTINGS_KEY, |
||||
|
} from "../executionUtils"; |
||||
|
import type { WorkflowNode, WorkflowEdge } from "@/types"; |
||||
|
|
||||
|
function makeNode(id: string, type = "prompt"): WorkflowNode { |
||||
|
return { |
||||
|
id, |
||||
|
type, |
||||
|
position: { x: 0, y: 0 }, |
||||
|
data: {}, |
||||
|
} as WorkflowNode; |
||||
|
} |
||||
|
|
||||
|
function makeEdge(source: string, target: string): WorkflowEdge { |
||||
|
return { |
||||
|
id: `${source}-${target}`, |
||||
|
source, |
||||
|
target, |
||||
|
sourceHandle: "text", |
||||
|
targetHandle: "text", |
||||
|
} as WorkflowEdge; |
||||
|
} |
||||
|
|
||||
|
describe("groupNodesByLevel", () => { |
||||
|
it("should put all nodes at level 0 when there are no edges", () => { |
||||
|
const nodes = [makeNode("a"), makeNode("b"), makeNode("c")]; |
||||
|
const result = groupNodesByLevel(nodes, []); |
||||
|
expect(result).toHaveLength(1); |
||||
|
expect(result[0].level).toBe(0); |
||||
|
expect(result[0].nodeIds.sort()).toEqual(["a", "b", "c"]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle a linear chain", () => { |
||||
|
const nodes = [makeNode("a"), makeNode("b"), makeNode("c")]; |
||||
|
const edges = [makeEdge("a", "b"), makeEdge("b", "c")]; |
||||
|
const result = groupNodesByLevel(nodes, edges); |
||||
|
expect(result).toHaveLength(3); |
||||
|
expect(result[0].nodeIds).toEqual(["a"]); |
||||
|
expect(result[1].nodeIds).toEqual(["b"]); |
||||
|
expect(result[2].nodeIds).toEqual(["c"]); |
||||
|
}); |
||||
|
|
||||
|
it("should group parallel nodes at the same level", () => { |
||||
|
// a -> b, a -> c (b and c are parallel)
|
||||
|
const nodes = [makeNode("a"), makeNode("b"), makeNode("c")]; |
||||
|
const edges = [makeEdge("a", "b"), makeEdge("a", "c")]; |
||||
|
const result = groupNodesByLevel(nodes, edges); |
||||
|
expect(result).toHaveLength(2); |
||||
|
expect(result[0].nodeIds).toEqual(["a"]); |
||||
|
expect(result[1].nodeIds.sort()).toEqual(["b", "c"]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle diamond dependencies", () => { |
||||
|
// a -> b, a -> c, b -> d, c -> d
|
||||
|
const nodes = [makeNode("a"), makeNode("b"), makeNode("c"), makeNode("d")]; |
||||
|
const edges = [ |
||||
|
makeEdge("a", "b"), |
||||
|
makeEdge("a", "c"), |
||||
|
makeEdge("b", "d"), |
||||
|
makeEdge("c", "d"), |
||||
|
]; |
||||
|
const result = groupNodesByLevel(nodes, edges); |
||||
|
expect(result).toHaveLength(3); |
||||
|
expect(result[0].nodeIds).toEqual(["a"]); |
||||
|
expect(result[1].nodeIds.sort()).toEqual(["b", "c"]); |
||||
|
expect(result[2].nodeIds).toEqual(["d"]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle empty inputs", () => { |
||||
|
const result = groupNodesByLevel([], []); |
||||
|
expect(result).toEqual([]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("chunk", () => { |
||||
|
it("should split array into chunks of specified size", () => { |
||||
|
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle array smaller than chunk size", () => { |
||||
|
expect(chunk([1, 2], 5)).toEqual([[1, 2]]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle empty array", () => { |
||||
|
expect(chunk([], 3)).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle chunk size of 1", () => { |
||||
|
expect(chunk([1, 2, 3], 1)).toEqual([[1], [2], [3]]); |
||||
|
}); |
||||
|
|
||||
|
it("should handle exact multiples", () => { |
||||
|
expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("revokeBlobUrl", () => { |
||||
|
it("should revoke blob URLs", () => { |
||||
|
const spy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); |
||||
|
revokeBlobUrl("blob:http://localhost/abc"); |
||||
|
expect(spy).toHaveBeenCalledWith("blob:http://localhost/abc"); |
||||
|
spy.mockRestore(); |
||||
|
}); |
||||
|
|
||||
|
it("should not revoke non-blob URLs", () => { |
||||
|
const spy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); |
||||
|
revokeBlobUrl("http://example.com/image.png"); |
||||
|
expect(spy).not.toHaveBeenCalled(); |
||||
|
spy.mockRestore(); |
||||
|
}); |
||||
|
|
||||
|
it("should handle null", () => { |
||||
|
expect(() => revokeBlobUrl(null)).not.toThrow(); |
||||
|
}); |
||||
|
|
||||
|
it("should handle undefined", () => { |
||||
|
expect(() => revokeBlobUrl(undefined)).not.toThrow(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("clearNodeImageRefs", () => { |
||||
|
it("should clear imageRef fields", () => { |
||||
|
const nodes = [ |
||||
|
{ |
||||
|
...makeNode("a"), |
||||
|
data: { |
||||
|
imageRef: "some-ref", |
||||
|
sourceImageRef: "src-ref", |
||||
|
outputImageRef: "out-ref", |
||||
|
inputImageRefs: ["ref1"], |
||||
|
prompt: "keep this", |
||||
|
}, |
||||
|
}, |
||||
|
] as unknown as WorkflowNode[]; |
||||
|
|
||||
|
const result = clearNodeImageRefs(nodes); |
||||
|
const data = result[0].data as Record<string, unknown>; |
||||
|
expect(data.imageRef).toBeUndefined(); |
||||
|
expect(data.sourceImageRef).toBeUndefined(); |
||||
|
expect(data.outputImageRef).toBeUndefined(); |
||||
|
expect(data.inputImageRefs).toBeUndefined(); |
||||
|
expect(data.prompt).toBe("keep this"); |
||||
|
}); |
||||
|
|
||||
|
it("should not mutate original nodes", () => { |
||||
|
const original = [ |
||||
|
{ |
||||
|
...makeNode("a"), |
||||
|
data: { imageRef: "ref" }, |
||||
|
}, |
||||
|
] as unknown as WorkflowNode[]; |
||||
|
|
||||
|
clearNodeImageRefs(original); |
||||
|
expect((original[0].data as Record<string, unknown>).imageRef).toBe("ref"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("concurrency settings", () => { |
||||
|
let mockStorage: Record<string, string>; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockStorage = {}; |
||||
|
vi.stubGlobal("localStorage", { |
||||
|
getItem: (key: string) => mockStorage[key] ?? null, |
||||
|
setItem: (key: string, value: string) => { mockStorage[key] = value; }, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should return default when no setting stored", () => { |
||||
|
expect(loadConcurrencySetting()).toBe(DEFAULT_MAX_CONCURRENT_CALLS); |
||||
|
}); |
||||
|
|
||||
|
it("should load stored setting", () => { |
||||
|
mockStorage[CONCURRENCY_SETTINGS_KEY] = "5"; |
||||
|
expect(loadConcurrencySetting()).toBe(5); |
||||
|
}); |
||||
|
|
||||
|
it("should reject out-of-range values", () => { |
||||
|
mockStorage[CONCURRENCY_SETTINGS_KEY] = "0"; |
||||
|
expect(loadConcurrencySetting()).toBe(DEFAULT_MAX_CONCURRENT_CALLS); |
||||
|
mockStorage[CONCURRENCY_SETTINGS_KEY] = "11"; |
||||
|
expect(loadConcurrencySetting()).toBe(DEFAULT_MAX_CONCURRENT_CALLS); |
||||
|
}); |
||||
|
|
||||
|
it("should reject invalid values", () => { |
||||
|
mockStorage[CONCURRENCY_SETTINGS_KEY] = "abc"; |
||||
|
expect(loadConcurrencySetting()).toBe(DEFAULT_MAX_CONCURRENT_CALLS); |
||||
|
}); |
||||
|
|
||||
|
it("should save setting", () => { |
||||
|
saveConcurrencySetting(7); |
||||
|
expect(mockStorage[CONCURRENCY_SETTINGS_KEY]).toBe("7"); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,135 @@ |
|||||
|
/** |
||||
|
* Execution Utilities |
||||
|
* |
||||
|
* Pure utility functions used by the workflow execution engine. |
||||
|
* Extracted from workflowStore.ts for testability and reuse. |
||||
|
*/ |
||||
|
|
||||
|
import { WorkflowNode, WorkflowEdge, WorkflowNodeData } from "@/types"; |
||||
|
|
||||
|
// Concurrency settings
|
||||
|
export const CONCURRENCY_SETTINGS_KEY = "node-banana-concurrency-limit"; |
||||
|
export const DEFAULT_MAX_CONCURRENT_CALLS = 3; |
||||
|
|
||||
|
/** |
||||
|
* Load concurrency setting from localStorage |
||||
|
*/ |
||||
|
export const loadConcurrencySetting = (): number => { |
||||
|
if (typeof window === "undefined") return DEFAULT_MAX_CONCURRENT_CALLS; |
||||
|
const stored = localStorage.getItem(CONCURRENCY_SETTINGS_KEY); |
||||
|
if (stored) { |
||||
|
const parsed = parseInt(stored, 10); |
||||
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= 10) { |
||||
|
return parsed; |
||||
|
} |
||||
|
} |
||||
|
return DEFAULT_MAX_CONCURRENT_CALLS; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* Save concurrency setting to localStorage |
||||
|
*/ |
||||
|
export const saveConcurrencySetting = (value: number): void => { |
||||
|
if (typeof window === "undefined") return; |
||||
|
localStorage.setItem(CONCURRENCY_SETTINGS_KEY, String(value)); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* Level grouping for parallel execution |
||||
|
*/ |
||||
|
export interface LevelGroup { |
||||
|
level: number; |
||||
|
nodeIds: string[]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Groups nodes by dependency level using Kahn's algorithm variant. |
||||
|
* Nodes at the same level can be executed in parallel. |
||||
|
* Level 0 = nodes with no incoming edges (roots) |
||||
|
* Level N = nodes whose dependencies are all at levels < N |
||||
|
*/ |
||||
|
export function groupNodesByLevel( |
||||
|
nodes: WorkflowNode[], |
||||
|
edges: WorkflowEdge[] |
||||
|
): LevelGroup[] { |
||||
|
// Calculate in-degree for each node
|
||||
|
const inDegree = new Map<string, number>(); |
||||
|
const adjList = new Map<string, string[]>(); |
||||
|
|
||||
|
nodes.forEach((n) => { |
||||
|
inDegree.set(n.id, 0); |
||||
|
adjList.set(n.id, []); |
||||
|
}); |
||||
|
|
||||
|
edges.forEach((e) => { |
||||
|
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1); |
||||
|
adjList.get(e.source)?.push(e.target); |
||||
|
}); |
||||
|
|
||||
|
// BFS with level tracking (Kahn's algorithm variant)
|
||||
|
const levels: LevelGroup[] = []; |
||||
|
let currentLevel = nodes |
||||
|
.filter((n) => inDegree.get(n.id) === 0) |
||||
|
.map((n) => n.id); |
||||
|
|
||||
|
let levelNum = 0; |
||||
|
while (currentLevel.length > 0) { |
||||
|
levels.push({ level: levelNum, nodeIds: [...currentLevel] }); |
||||
|
|
||||
|
const nextLevel: string[] = []; |
||||
|
for (const nodeId of currentLevel) { |
||||
|
for (const child of adjList.get(nodeId) || []) { |
||||
|
const newDegree = (inDegree.get(child) || 1) - 1; |
||||
|
inDegree.set(child, newDegree); |
||||
|
if (newDegree === 0) { |
||||
|
nextLevel.push(child); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
currentLevel = nextLevel; |
||||
|
levelNum++; |
||||
|
} |
||||
|
|
||||
|
return levels; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Chunk an array into smaller arrays of specified size |
||||
|
*/ |
||||
|
export function chunk<T>(array: T[], size: number): T[][] { |
||||
|
const chunks: T[][] = []; |
||||
|
for (let i = 0; i < array.length; i += size) { |
||||
|
chunks.push(array.slice(i, i + size)); |
||||
|
} |
||||
|
return chunks; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Revoke a blob URL if the value is one, to free the underlying memory. |
||||
|
*/ |
||||
|
export function revokeBlobUrl(url: string | null | undefined): void { |
||||
|
if (url && url.startsWith('blob:')) { |
||||
|
try { URL.revokeObjectURL(url); } catch { /* ignore */ } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Clear all imageRefs from nodes (used when saving to a different directory) |
||||
|
*/ |
||||
|
export function clearNodeImageRefs(nodes: WorkflowNode[]): WorkflowNode[] { |
||||
|
return nodes.map(node => { |
||||
|
const data = { ...node.data } as Record<string, unknown>; |
||||
|
|
||||
|
// Revoke blob URLs for video outputs before clearing
|
||||
|
revokeBlobUrl(data.outputVideo as string | undefined); |
||||
|
|
||||
|
// Clear all ref fields regardless of node type
|
||||
|
delete data.imageRef; |
||||
|
delete data.sourceImageRef; |
||||
|
delete data.outputImageRef; |
||||
|
delete data.inputImageRefs; |
||||
|
|
||||
|
return { ...node, data: data as WorkflowNodeData } as WorkflowNode; |
||||
|
}); |
||||
|
} |
||||
Loading…
Reference in new issue