Browse Source

Add comprehensive test suite for AI Quickstart feature

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
Tom Spencer 6 months ago
parent
commit
2ffe66b87c
  1. 3054
      package-lock.json
  2. 13
      package.json
  3. 169
      src/lib/quickstart/__tests__/prompts.test.ts
  4. 296
      src/lib/quickstart/__tests__/templates.test.ts
  5. 522
      src/lib/quickstart/__tests__/validation.test.ts
  6. 21
      vitest.config.ts

3054
package-lock.json

File diff suppressed because it is too large

13
package.json

@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@google/genai": "^1.30.0",
@ -23,9 +26,15 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"typescript": "^5.9.3"
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16",
"jsdom": "^27.0.1",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
}
}

169
src/lib/quickstart/__tests__/prompts.test.ts

@ -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");
});
});
});

296
src/lib/quickstart/__tests__/templates.test.ts

@ -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);
});
});
});
});

522
src/lib/quickstart/__tests__/validation.test.ts

@ -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" });
});
});
});

21
vitest.config.ts

@ -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…
Cancel
Save