Browse Source
Test Setup: - Install vitest and @vitest/coverage-v8 - Configure vitest with path aliases and coverage reporting - Add npm scripts: test, test:run, test:coverage Test Coverage (108 tests total): - templates.test.ts (33 tests): Preset templates, content levels, workflows - validation.test.ts (51 tests): JSON validation, repair, parsing - prompts.test.ts (24 tests): Prompt generation for all content levels Coverage Results: - Overall: 90.2% statements, 91% lines - prompts.ts: 100% - templates.ts: 96% - validation.ts: 89% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
6 changed files with 3921 additions and 154 deletions
File diff suppressed because it is too large
@ -0,0 +1,169 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { buildQuickstartPrompt, buildSimplePrompt } from "../prompts"; |
|||
import { ContentLevel } from "../templates"; |
|||
|
|||
describe("prompts", () => { |
|||
describe("buildQuickstartPrompt", () => { |
|||
it("should include the user description", () => { |
|||
const description = "Create a portrait editing workflow"; |
|||
const prompt = buildQuickstartPrompt(description, "minimal"); |
|||
expect(prompt).toContain(description); |
|||
}); |
|||
|
|||
it("should include content level instructions", () => { |
|||
const emptyPrompt = buildQuickstartPrompt("test", "empty"); |
|||
const minimalPrompt = buildQuickstartPrompt("test", "minimal"); |
|||
const fullPrompt = buildQuickstartPrompt("test", "full"); |
|||
|
|||
expect(emptyPrompt).toContain("Content Level: EMPTY"); |
|||
expect(emptyPrompt).toContain("Leave ALL prompt fields completely empty"); |
|||
|
|||
expect(minimalPrompt).toContain("Content Level: MINIMAL"); |
|||
expect(minimalPrompt).toContain("placeholder prompts"); |
|||
|
|||
expect(fullPrompt).toContain("Content Level: FULL"); |
|||
expect(fullPrompt).toContain("complete, detailed example prompts"); |
|||
}); |
|||
|
|||
it("should include all node type descriptions", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("imageInput"); |
|||
expect(prompt).toContain("prompt"); |
|||
expect(prompt).toContain("nanoBanana"); |
|||
expect(prompt).toContain("llmGenerate"); |
|||
expect(prompt).toContain("annotation"); |
|||
expect(prompt).toContain("output"); |
|||
}); |
|||
|
|||
it("should include connection rules", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("Connection Rules"); |
|||
expect(prompt).toContain("image\" handles can ONLY connect to \"image\" handles"); |
|||
expect(prompt).toContain("text\" handles can ONLY connect to \"text\" handles"); |
|||
}); |
|||
|
|||
it("should include node layout guidelines", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("Node Layout Guidelines"); |
|||
expect(prompt).toContain("400px"); |
|||
expect(prompt).toContain("left to right"); |
|||
}); |
|||
|
|||
it("should include required JSON structure", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("Required JSON Structure"); |
|||
expect(prompt).toContain('"version": 1'); |
|||
expect(prompt).toContain('"nodes"'); |
|||
expect(prompt).toContain('"edges"'); |
|||
expect(prompt).toContain('"edgeStyle": "curved"'); |
|||
}); |
|||
|
|||
it("should include node dimension specifications", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("imageInput: { width: 300, height: 280 }"); |
|||
expect(prompt).toContain("prompt: { width: 320, height: 220 }"); |
|||
expect(prompt).toContain("nanoBanana: { width: 300, height: 300 }"); |
|||
}); |
|||
|
|||
it("should include node ID format instructions", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("Node ID Format"); |
|||
expect(prompt).toContain('"{type}-{number}"'); |
|||
expect(prompt).toContain("imageInput-1"); |
|||
}); |
|||
|
|||
it("should include edge ID format instructions", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("Edge ID Format"); |
|||
expect(prompt).toContain("edge-{source}-{target}"); |
|||
}); |
|||
|
|||
it("should include a timestamp in the workflow ID", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toMatch(/wf_\d+_quickstart/); |
|||
}); |
|||
|
|||
it("should emphasize nanoBanana requirements", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("nanoBanana REQUIRES both an image input AND a text input"); |
|||
}); |
|||
|
|||
it("should instruct to output only JSON", () => { |
|||
const prompt = buildQuickstartPrompt("test", "minimal"); |
|||
expect(prompt).toContain("output ONLY valid JSON"); |
|||
expect(prompt).toContain("No explanations, no markdown, no code blocks"); |
|||
}); |
|||
|
|||
describe("content level specific prompts", () => { |
|||
const contentLevels: ContentLevel[] = ["empty", "minimal", "full"]; |
|||
|
|||
contentLevels.forEach((level) => { |
|||
it(`should generate valid prompt for ${level} level`, () => { |
|||
const prompt = buildQuickstartPrompt("test workflow", level); |
|||
expect(prompt).toBeTruthy(); |
|||
expect(prompt.length).toBeGreaterThan(1000); |
|||
expect(prompt).toContain(`Content Level: ${level.toUpperCase()}`); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("buildSimplePrompt", () => { |
|||
it("should include the user description", () => { |
|||
const description = "Create a simple editing workflow"; |
|||
const prompt = buildSimplePrompt(description); |
|||
expect(prompt).toContain(description); |
|||
}); |
|||
|
|||
it("should list all node types", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("imageInput"); |
|||
expect(prompt).toContain("prompt"); |
|||
expect(prompt).toContain("nanoBanana"); |
|||
expect(prompt).toContain("llmGenerate"); |
|||
expect(prompt).toContain("annotation"); |
|||
expect(prompt).toContain("output"); |
|||
}); |
|||
|
|||
it("should include connection type rules", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("image handles connect to image"); |
|||
expect(prompt).toContain("text to text"); |
|||
}); |
|||
|
|||
it("should include nanoBanana requirements", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("nanoBanana NEEDS both image and text inputs"); |
|||
}); |
|||
|
|||
it("should include node ID format", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("type-number"); |
|||
expect(prompt).toContain("imageInput-1"); |
|||
}); |
|||
|
|||
it("should include edge ID format", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("edge-source-target-sourceHandle-targetHandle"); |
|||
}); |
|||
|
|||
it("should specify required JSON structure elements", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("version:1"); |
|||
expect(prompt).toContain("nodes[]"); |
|||
expect(prompt).toContain("edges[]"); |
|||
expect(prompt).toContain('edgeStyle:"curved"'); |
|||
}); |
|||
|
|||
it("should be shorter than the full prompt", () => { |
|||
const simplePrompt = buildSimplePrompt("test"); |
|||
const fullPrompt = buildQuickstartPrompt("test", "full"); |
|||
expect(simplePrompt.length).toBeLessThan(fullPrompt.length); |
|||
}); |
|||
|
|||
it("should request only valid JSON output", () => { |
|||
const prompt = buildSimplePrompt("test"); |
|||
expect(prompt).toContain("Return ONLY valid JSON"); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,296 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { |
|||
getPresetTemplate, |
|||
getAllPresets, |
|||
getTemplateContent, |
|||
PRESET_TEMPLATES, |
|||
SAMPLE_IMAGES, |
|||
ContentLevel, |
|||
} from "../templates"; |
|||
|
|||
describe("templates", () => { |
|||
describe("SAMPLE_IMAGES", () => { |
|||
it("should have all expected product images", () => { |
|||
expect(SAMPLE_IMAGES.appleWatch).toBe("/sample-images/apple-watch.jpg"); |
|||
expect(SAMPLE_IMAGES.nikeShoe).toBe("/sample-images/nike-shoe.jpg"); |
|||
expect(SAMPLE_IMAGES.rayban).toBe("/sample-images/rayban.jpg"); |
|||
}); |
|||
|
|||
it("should have all expected model images", () => { |
|||
expect(SAMPLE_IMAGES.model).toBe("/sample-images/model.jpg"); |
|||
expect(SAMPLE_IMAGES.model2).toBe("/sample-images/model-2.jpg"); |
|||
expect(SAMPLE_IMAGES.model3).toBe("/sample-images/model-3.jpg"); |
|||
}); |
|||
|
|||
it("should have all expected scene images", () => { |
|||
expect(SAMPLE_IMAGES.desert).toBe("/sample-images/desert.jpg"); |
|||
expect(SAMPLE_IMAGES.streetScene).toBe("/sample-images/street-scene.jpg"); |
|||
expect(SAMPLE_IMAGES.nyStreet).toBe("/sample-images/ny-street.jpg"); |
|||
}); |
|||
}); |
|||
|
|||
describe("PRESET_TEMPLATES", () => { |
|||
it("should have 6 preset templates", () => { |
|||
expect(PRESET_TEMPLATES).toHaveLength(6); |
|||
}); |
|||
|
|||
it("should have all required template IDs", () => { |
|||
const templateIds = PRESET_TEMPLATES.map((t) => t.id); |
|||
expect(templateIds).toContain("product-shot"); |
|||
expect(templateIds).toContain("model-product"); |
|||
expect(templateIds).toContain("color-variations"); |
|||
expect(templateIds).toContain("background-swap"); |
|||
expect(templateIds).toContain("style-transfer"); |
|||
expect(templateIds).toContain("scene-composite"); |
|||
}); |
|||
|
|||
it("each template should have required properties", () => { |
|||
PRESET_TEMPLATES.forEach((template) => { |
|||
expect(template).toHaveProperty("id"); |
|||
expect(template).toHaveProperty("name"); |
|||
expect(template).toHaveProperty("description"); |
|||
expect(template).toHaveProperty("icon"); |
|||
expect(template).toHaveProperty("workflow"); |
|||
}); |
|||
}); |
|||
|
|||
it("each template workflow should have valid structure", () => { |
|||
PRESET_TEMPLATES.forEach((template) => { |
|||
const { workflow } = template; |
|||
expect(workflow.version).toBe(1); |
|||
expect(workflow.name).toBeTruthy(); |
|||
expect(workflow.edgeStyle).toBe("curved"); |
|||
expect(Array.isArray(workflow.nodes)).toBe(true); |
|||
expect(Array.isArray(workflow.edges)).toBe(true); |
|||
expect(workflow.nodes.length).toBeGreaterThan(0); |
|||
}); |
|||
}); |
|||
|
|||
it("each template should have at least one nanoBanana node", () => { |
|||
PRESET_TEMPLATES.forEach((template) => { |
|||
const nanoBananaNodes = template.workflow.nodes.filter( |
|||
(n) => n.type === "nanoBanana" |
|||
); |
|||
expect(nanoBananaNodes.length).toBeGreaterThanOrEqual(1); |
|||
}); |
|||
}); |
|||
|
|||
it("each template should have at least one output node", () => { |
|||
PRESET_TEMPLATES.forEach((template) => { |
|||
const outputNodes = template.workflow.nodes.filter( |
|||
(n) => n.type === "output" |
|||
); |
|||
expect(outputNodes.length).toBeGreaterThanOrEqual(1); |
|||
}); |
|||
}); |
|||
|
|||
it("edges should reference valid node IDs", () => { |
|||
PRESET_TEMPLATES.forEach((template) => { |
|||
const nodeIds = new Set(template.workflow.nodes.map((n) => n.id)); |
|||
template.workflow.edges.forEach((edge) => { |
|||
expect(nodeIds.has(edge.source)).toBe(true); |
|||
expect(nodeIds.has(edge.target)).toBe(true); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("getAllPresets", () => { |
|||
it("should return all 6 presets", () => { |
|||
const presets = getAllPresets(); |
|||
expect(presets).toHaveLength(6); |
|||
}); |
|||
|
|||
it("should return only display properties", () => { |
|||
const presets = getAllPresets(); |
|||
presets.forEach((preset) => { |
|||
expect(Object.keys(preset)).toEqual(["id", "name", "description", "icon"]); |
|||
}); |
|||
}); |
|||
|
|||
it("should not include workflow data", () => { |
|||
const presets = getAllPresets(); |
|||
presets.forEach((preset) => { |
|||
expect(preset).not.toHaveProperty("workflow"); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("getPresetTemplate", () => { |
|||
const contentLevels: ContentLevel[] = ["empty", "minimal", "full"]; |
|||
|
|||
it("should throw error for invalid template ID", () => { |
|||
expect(() => getPresetTemplate("invalid-id", "minimal")).toThrow( |
|||
"Template not found: invalid-id" |
|||
); |
|||
}); |
|||
|
|||
it("should return workflow with generated ID", () => { |
|||
const workflow = getPresetTemplate("product-shot", "empty"); |
|||
expect(workflow.id).toMatch(/^wf_\d+_product-shot$/); |
|||
}); |
|||
|
|||
describe("empty content level", () => { |
|||
it("should have empty prompts", () => { |
|||
const workflow = getPresetTemplate("product-shot", "empty"); |
|||
const promptNodes = workflow.nodes.filter((n) => n.type === "prompt"); |
|||
promptNodes.forEach((node) => { |
|||
expect((node.data as { prompt: string }).prompt).toBe(""); |
|||
}); |
|||
}); |
|||
|
|||
it("should have no pre-loaded images", () => { |
|||
const workflow = getPresetTemplate("product-shot", "empty"); |
|||
const imageNodes = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
imageNodes.forEach((node) => { |
|||
expect((node.data as { image: string | null }).image).toBeNull(); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("minimal content level", () => { |
|||
it("should have instructional prompts", () => { |
|||
const workflow = getPresetTemplate("product-shot", "minimal"); |
|||
const promptNodes = workflow.nodes.filter((n) => n.type === "prompt"); |
|||
promptNodes.forEach((node) => { |
|||
const prompt = (node.data as { prompt: string }).prompt; |
|||
expect(prompt.length).toBeGreaterThan(0); |
|||
expect(prompt).toContain("Consider:"); |
|||
}); |
|||
}); |
|||
|
|||
it("should have no pre-loaded images", () => { |
|||
const workflow = getPresetTemplate("product-shot", "minimal"); |
|||
const imageNodes = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
imageNodes.forEach((node) => { |
|||
expect((node.data as { image: string | null }).image).toBeNull(); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("full content level", () => { |
|||
it("should have complete prompts", () => { |
|||
const workflow = getPresetTemplate("product-shot", "full"); |
|||
const promptNodes = workflow.nodes.filter((n) => n.type === "prompt"); |
|||
promptNodes.forEach((node) => { |
|||
const prompt = (node.data as { prompt: string }).prompt; |
|||
expect(prompt.length).toBeGreaterThan(50); |
|||
}); |
|||
}); |
|||
|
|||
it("should have pre-loaded images with local paths", () => { |
|||
const workflow = getPresetTemplate("product-shot", "full"); |
|||
const imageNodes = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
const nodesWithImages = imageNodes.filter( |
|||
(node) => (node.data as { image: string | null }).image !== null |
|||
); |
|||
expect(nodesWithImages.length).toBeGreaterThan(0); |
|||
nodesWithImages.forEach((node) => { |
|||
const image = (node.data as { image: string }).image; |
|||
expect(image).toMatch(/^\/sample-images\/.+\.jpg$/); |
|||
}); |
|||
}); |
|||
|
|||
it("should have filenames for images", () => { |
|||
const workflow = getPresetTemplate("product-shot", "full"); |
|||
const imageNodes = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
const nodesWithImages = imageNodes.filter( |
|||
(node) => (node.data as { image: string | null }).image !== null |
|||
); |
|||
nodesWithImages.forEach((node) => { |
|||
const filename = (node.data as { filename: string }).filename; |
|||
expect(filename).toMatch(/\.jpg$/); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
it("should work for all templates at all content levels", () => { |
|||
const templateIds = PRESET_TEMPLATES.map((t) => t.id); |
|||
templateIds.forEach((templateId) => { |
|||
contentLevels.forEach((level) => { |
|||
expect(() => getPresetTemplate(templateId, level)).not.toThrow(); |
|||
const workflow = getPresetTemplate(templateId, level); |
|||
expect(workflow.nodes.length).toBeGreaterThan(0); |
|||
expect(workflow.edges.length).toBeGreaterThan(0); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("getTemplateContent", () => { |
|||
it("should return content for valid template and level", () => { |
|||
const content = getTemplateContent("product-shot", "full"); |
|||
expect(content).not.toBeNull(); |
|||
expect(content).toHaveProperty("prompts"); |
|||
expect(content).toHaveProperty("images"); |
|||
}); |
|||
|
|||
it("should return null for invalid template", () => { |
|||
const content = getTemplateContent("invalid-id", "full"); |
|||
expect(content).toBeNull(); |
|||
}); |
|||
|
|||
it("should have prompts object", () => { |
|||
const content = getTemplateContent("product-shot", "minimal"); |
|||
expect(content?.prompts).toBeDefined(); |
|||
expect(typeof content?.prompts).toBe("object"); |
|||
}); |
|||
|
|||
it("should have images object for full level", () => { |
|||
const content = getTemplateContent("product-shot", "full"); |
|||
expect(content?.images).toBeDefined(); |
|||
expect(Object.keys(content?.images || {}).length).toBeGreaterThan(0); |
|||
}); |
|||
|
|||
it("should have empty images object for empty/minimal levels", () => { |
|||
const emptyContent = getTemplateContent("product-shot", "empty"); |
|||
const minimalContent = getTemplateContent("product-shot", "minimal"); |
|||
expect(Object.keys(emptyContent?.images || {}).length).toBe(0); |
|||
expect(Object.keys(minimalContent?.images || {}).length).toBe(0); |
|||
}); |
|||
}); |
|||
|
|||
describe("template-specific tests", () => { |
|||
describe("product-shot template", () => { |
|||
it("should have 2 image inputs (product and scene)", () => { |
|||
const workflow = getPresetTemplate("product-shot", "empty"); |
|||
const imageInputs = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
expect(imageInputs.length).toBe(2); |
|||
}); |
|||
|
|||
it("full level should have nike shoe and desert images", () => { |
|||
const workflow = getPresetTemplate("product-shot", "full"); |
|||
const images = workflow.nodes |
|||
.filter((n) => n.type === "imageInput") |
|||
.map((n) => (n.data as { image: string | null }).image) |
|||
.filter(Boolean); |
|||
expect(images).toContain(SAMPLE_IMAGES.nikeShoe); |
|||
expect(images).toContain(SAMPLE_IMAGES.desert); |
|||
}); |
|||
}); |
|||
|
|||
describe("model-product template", () => { |
|||
it("should have 3 image inputs (model, product, scene)", () => { |
|||
const workflow = getPresetTemplate("model-product", "empty"); |
|||
const imageInputs = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
expect(imageInputs.length).toBe(3); |
|||
}); |
|||
}); |
|||
|
|||
describe("color-variations template", () => { |
|||
it("should have 3 image inputs (product and color refs)", () => { |
|||
const workflow = getPresetTemplate("color-variations", "empty"); |
|||
const imageInputs = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
expect(imageInputs.length).toBe(3); |
|||
}); |
|||
}); |
|||
|
|||
describe("style-transfer template", () => { |
|||
it("should have 2 image inputs (style and content)", () => { |
|||
const workflow = getPresetTemplate("style-transfer", "empty"); |
|||
const imageInputs = workflow.nodes.filter((n) => n.type === "imageInput"); |
|||
expect(imageInputs.length).toBe(2); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,522 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { |
|||
validateWorkflowJSON, |
|||
repairWorkflowJSON, |
|||
parseJSONFromResponse, |
|||
} from "../validation"; |
|||
|
|||
describe("validation", () => { |
|||
describe("validateWorkflowJSON", () => { |
|||
describe("root validation", () => { |
|||
it("should reject null input", () => { |
|||
const result = validateWorkflowJSON(null); |
|||
expect(result.valid).toBe(false); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "", |
|||
message: "Workflow must be an object", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject undefined input", () => { |
|||
const result = validateWorkflowJSON(undefined); |
|||
expect(result.valid).toBe(false); |
|||
}); |
|||
|
|||
it("should reject primitive types", () => { |
|||
expect(validateWorkflowJSON("string").valid).toBe(false); |
|||
expect(validateWorkflowJSON(123).valid).toBe(false); |
|||
expect(validateWorkflowJSON(true).valid).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe("version validation", () => { |
|||
it("should reject missing version", () => { |
|||
const result = validateWorkflowJSON({ nodes: [], edges: [] }); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "version", |
|||
message: "Version must be 1", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject invalid version", () => { |
|||
const result = validateWorkflowJSON({ version: 2, nodes: [], edges: [] }); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "version", |
|||
message: "Version must be 1", |
|||
}); |
|||
}); |
|||
|
|||
it("should accept version 1", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors.filter((e) => e.path === "version")).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
describe("name validation", () => { |
|||
it("should reject missing name", () => { |
|||
const result = validateWorkflowJSON({ version: 1, nodes: [], edges: [] }); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "name", |
|||
message: "Name must be a non-empty string", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject empty name", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "", |
|||
nodes: [], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "name", |
|||
message: "Name must be a non-empty string", |
|||
}); |
|||
}); |
|||
|
|||
it("should accept valid name", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test Workflow", |
|||
nodes: [], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors.filter((e) => e.path === "name")).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
describe("nodes validation", () => { |
|||
it("should reject missing nodes array", () => { |
|||
const result = validateWorkflowJSON({ version: 1, name: "Test", edges: [] }); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes", |
|||
message: "Nodes must be an array", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject non-array nodes", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: "not an array", |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes", |
|||
message: "Nodes must be an array", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject node without id", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ type: "prompt", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes[0].id", |
|||
message: "Node must have a string id", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject duplicate node ids", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [ |
|||
{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }, |
|||
{ id: "node-1", type: "prompt", position: { x: 100, y: 0 }, data: {} }, |
|||
], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes[1].id", |
|||
message: "Duplicate node id: node-1", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject invalid node type", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ id: "node-1", type: "invalid", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors.some((e) => e.path === "nodes[0].type")).toBe(true); |
|||
}); |
|||
|
|||
it("should accept all valid node types", () => { |
|||
const validTypes = [ |
|||
"imageInput", |
|||
"annotation", |
|||
"prompt", |
|||
"nanoBanana", |
|||
"llmGenerate", |
|||
"splitGrid", |
|||
"output", |
|||
]; |
|||
validTypes.forEach((type) => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ id: "node-1", type, position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect( |
|||
result.errors.filter((e) => e.path === "nodes[0].type") |
|||
).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
it("should reject node without position", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ id: "node-1", type: "prompt", data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes[0].position", |
|||
message: "Node must have a position object", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject node with invalid position", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ id: "node-1", type: "prompt", position: { x: "0", y: "0" }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes[0].position", |
|||
message: "Position must have numeric x and y values", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject node without data", () => { |
|||
const result = validateWorkflowJSON({ |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ id: "node-1", type: "prompt", position: { x: 0, y: 0 } }], |
|||
edges: [], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "nodes[0].data", |
|||
message: "Node must have a data object", |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("edges validation", () => { |
|||
const validWorkflow = { |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [ |
|||
{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }, |
|||
{ id: "node-2", type: "nanoBanana", position: { x: 100, y: 0 }, data: {} }, |
|||
], |
|||
}; |
|||
|
|||
it("should reject missing edges array", () => { |
|||
const result = validateWorkflowJSON(validWorkflow); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "edges", |
|||
message: "Edges must be an array", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject edge with non-existent source", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [{ source: "invalid", target: "node-2" }], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "edges[0].source", |
|||
message: "Source node not found: invalid", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject edge with non-existent target", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [{ source: "node-1", target: "invalid" }], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "edges[0].target", |
|||
message: "Target node not found: invalid", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject invalid sourceHandle", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [{ source: "node-1", target: "node-2", sourceHandle: "invalid" }], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "edges[0].sourceHandle", |
|||
message: "Invalid sourceHandle: invalid", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject invalid targetHandle", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [{ source: "node-1", target: "node-2", targetHandle: "invalid" }], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "edges[0].targetHandle", |
|||
message: "Invalid targetHandle: invalid", |
|||
}); |
|||
}); |
|||
|
|||
it("should reject mismatched handle types", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [ |
|||
{ source: "node-1", target: "node-2", sourceHandle: "text", targetHandle: "image" }, |
|||
], |
|||
}); |
|||
expect(result.errors).toContainEqual({ |
|||
path: "edges[0]", |
|||
message: "Handle type mismatch: text → image", |
|||
}); |
|||
}); |
|||
|
|||
it("should accept matching handle types", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [ |
|||
{ source: "node-1", target: "node-2", sourceHandle: "text", targetHandle: "text" }, |
|||
], |
|||
}); |
|||
expect(result.errors.filter((e) => e.message.includes("mismatch"))).toHaveLength(0); |
|||
}); |
|||
|
|||
it("should accept reference handle type connections", () => { |
|||
const result = validateWorkflowJSON({ |
|||
...validWorkflow, |
|||
edges: [ |
|||
{ source: "node-1", target: "node-2", sourceHandle: "reference", targetHandle: "image" }, |
|||
], |
|||
}); |
|||
expect(result.errors.filter((e) => e.message.includes("mismatch"))).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
describe("valid workflow", () => { |
|||
it("should validate a complete valid workflow", () => { |
|||
const workflow = { |
|||
version: 1, |
|||
name: "Test Workflow", |
|||
nodes: [ |
|||
{ id: "prompt-1", type: "prompt", position: { x: 0, y: 0 }, data: { prompt: "test" } }, |
|||
{ |
|||
id: "nanoBanana-1", |
|||
type: "nanoBanana", |
|||
position: { x: 400, y: 0 }, |
|||
data: {}, |
|||
}, |
|||
{ id: "output-1", type: "output", position: { x: 800, y: 0 }, data: {} }, |
|||
], |
|||
edges: [ |
|||
{ source: "prompt-1", target: "nanoBanana-1", sourceHandle: "text", targetHandle: "text" }, |
|||
{ |
|||
source: "nanoBanana-1", |
|||
target: "output-1", |
|||
sourceHandle: "image", |
|||
targetHandle: "image", |
|||
}, |
|||
], |
|||
}; |
|||
const result = validateWorkflowJSON(workflow); |
|||
expect(result.valid).toBe(true); |
|||
expect(result.errors).toHaveLength(0); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("repairWorkflowJSON", () => { |
|||
it("should handle null input", () => { |
|||
const result = repairWorkflowJSON(null); |
|||
expect(result.version).toBe(1); |
|||
expect(result.name).toBe("Generated Workflow"); |
|||
expect(result.nodes).toEqual([]); |
|||
expect(result.edges).toEqual([]); |
|||
}); |
|||
|
|||
it("should add missing version", () => { |
|||
const result = repairWorkflowJSON({ name: "Test", nodes: [], edges: [] }); |
|||
expect(result.version).toBe(1); |
|||
}); |
|||
|
|||
it("should add missing name", () => { |
|||
const result = repairWorkflowJSON({ version: 1, nodes: [], edges: [] }); |
|||
expect(result.name).toBe("Generated Workflow"); |
|||
}); |
|||
|
|||
it("should preserve existing name", () => { |
|||
const result = repairWorkflowJSON({ name: "My Workflow", nodes: [], edges: [] }); |
|||
expect(result.name).toBe("My Workflow"); |
|||
}); |
|||
|
|||
it("should generate id if missing", () => { |
|||
const result = repairWorkflowJSON({ nodes: [], edges: [] }); |
|||
expect(result.id).toMatch(/^wf_\d+_repaired$/); |
|||
}); |
|||
|
|||
it("should set default edgeStyle", () => { |
|||
const result = repairWorkflowJSON({ nodes: [], edges: [] }); |
|||
expect(result.edgeStyle).toBe("curved"); |
|||
}); |
|||
|
|||
it("should repair node with missing id", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ type: "prompt", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.nodes[0].id).toBe("prompt-1"); |
|||
}); |
|||
|
|||
it("should repair node with invalid type", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ id: "node-1", type: "invalid", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.nodes[0].type).toBe("prompt"); |
|||
}); |
|||
|
|||
it("should repair node with missing position", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ id: "node-1", type: "prompt", data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.nodes[0].position).toEqual({ x: 50, y: 100 }); |
|||
}); |
|||
|
|||
it("should add default dimensions to nodes", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.nodes[0].style).toEqual({ width: 320, height: 220 }); |
|||
}); |
|||
|
|||
it("should fill in default node data", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [], |
|||
}); |
|||
expect(result.nodes[0].data).toHaveProperty("prompt"); |
|||
}); |
|||
|
|||
it("should remove edges with non-existent source", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [{ source: "invalid", target: "node-1" }], |
|||
}); |
|||
expect(result.edges).toHaveLength(0); |
|||
}); |
|||
|
|||
it("should remove edges with non-existent target", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }], |
|||
edges: [{ source: "node-1", target: "invalid" }], |
|||
}); |
|||
expect(result.edges).toHaveLength(0); |
|||
}); |
|||
|
|||
it("should remove edges with mismatched handle types", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [ |
|||
{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }, |
|||
{ id: "node-2", type: "nanoBanana", position: { x: 100, y: 0 }, data: {} }, |
|||
], |
|||
edges: [{ source: "node-1", target: "node-2", sourceHandle: "text", targetHandle: "image" }], |
|||
}); |
|||
expect(result.edges).toHaveLength(0); |
|||
}); |
|||
|
|||
it("should keep valid edges", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [ |
|||
{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }, |
|||
{ id: "node-2", type: "nanoBanana", position: { x: 100, y: 0 }, data: {} }, |
|||
], |
|||
edges: [{ source: "node-1", target: "node-2", sourceHandle: "text", targetHandle: "text" }], |
|||
}); |
|||
expect(result.edges).toHaveLength(1); |
|||
}); |
|||
|
|||
it("should generate edge id if missing", () => { |
|||
const result = repairWorkflowJSON({ |
|||
nodes: [ |
|||
{ id: "node-1", type: "prompt", position: { x: 0, y: 0 }, data: {} }, |
|||
{ id: "node-2", type: "nanoBanana", position: { x: 100, y: 0 }, data: {} }, |
|||
], |
|||
edges: [{ source: "node-1", target: "node-2", sourceHandle: "text", targetHandle: "text" }], |
|||
}); |
|||
expect(result.edges[0].id).toBe("edge-node-1-node-2-text-text"); |
|||
}); |
|||
}); |
|||
|
|||
describe("parseJSONFromResponse", () => { |
|||
it("should parse valid JSON directly", () => { |
|||
const result = parseJSONFromResponse('{"key": "value"}'); |
|||
expect(result).toEqual({ key: "value" }); |
|||
}); |
|||
|
|||
it("should parse JSON from markdown code block", () => { |
|||
const text = 'Here is the workflow:\n```json\n{"key": "value"}\n```'; |
|||
const result = parseJSONFromResponse(text); |
|||
expect(result).toEqual({ key: "value" }); |
|||
}); |
|||
|
|||
it("should parse JSON from code block without language", () => { |
|||
const text = "Here is the workflow:\n```\n{\"key\": \"value\"}\n```"; |
|||
const result = parseJSONFromResponse(text); |
|||
expect(result).toEqual({ key: "value" }); |
|||
}); |
|||
|
|||
it("should extract JSON object from text", () => { |
|||
const text = 'Some text before {"key": "value"} and after'; |
|||
const result = parseJSONFromResponse(text); |
|||
expect(result).toEqual({ key: "value" }); |
|||
}); |
|||
|
|||
it("should parse complex nested JSON", () => { |
|||
const json = { |
|||
version: 1, |
|||
name: "Test", |
|||
nodes: [{ id: "node-1", type: "prompt" }], |
|||
edges: [], |
|||
}; |
|||
const result = parseJSONFromResponse(JSON.stringify(json)); |
|||
expect(result).toEqual(json); |
|||
}); |
|||
|
|||
it("should throw error for invalid JSON", () => { |
|||
expect(() => parseJSONFromResponse("not json")).toThrow( |
|||
"Could not parse JSON from response" |
|||
); |
|||
}); |
|||
|
|||
it("should throw error for empty string", () => { |
|||
expect(() => parseJSONFromResponse("")).toThrow( |
|||
"Could not parse JSON from response" |
|||
); |
|||
}); |
|||
|
|||
it("should handle JSON with whitespace", () => { |
|||
const text = " \n\n {\"key\": \"value\"} \n\n "; |
|||
const result = parseJSONFromResponse(text); |
|||
expect(result).toEqual({ key: "value" }); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,21 @@ |
|||
import { defineConfig } from "vitest/config"; |
|||
import path from "path"; |
|||
|
|||
export default defineConfig({ |
|||
test: { |
|||
environment: "node", |
|||
globals: true, |
|||
include: ["src/**/*.{test,spec}.{ts,tsx}"], |
|||
coverage: { |
|||
provider: "v8", |
|||
reporter: ["text", "json", "html"], |
|||
include: ["src/lib/quickstart/**"], |
|||
exclude: ["node_modules", "src/__tests__"], |
|||
}, |
|||
}, |
|||
resolve: { |
|||
alias: { |
|||
"@": path.resolve(__dirname, "./src"), |
|||
}, |
|||
}, |
|||
}); |
|||
Loading…
Reference in new issue