diff --git a/src/components/__tests__/QuickstartInitialView.test.tsx b/src/components/__tests__/QuickstartInitialView.test.tsx new file mode 100644 index 00000000..98e13193 --- /dev/null +++ b/src/components/__tests__/QuickstartInitialView.test.tsx @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QuickstartInitialView } from "@/components/quickstart/QuickstartInitialView"; + +describe("QuickstartInitialView", () => { + const mockOnSelectBlankCanvas = vi.fn(); + const mockOnSelectTemplates = vi.fn(); + const mockOnSelectVibe = vi.fn(); + const mockOnSelectLoad = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Basic Rendering", () => { + it("should render the Node Banana title and logo", () => { + render( + + ); + + expect(screen.getByText("Node Banana")).toBeInTheDocument(); + expect(screen.getByAltText("")).toBeInTheDocument(); // Logo image + }); + + it("should render the description text", () => { + render( + + ); + + expect( + screen.getByText(/node based workflow editor for AI image generation/i) + ).toBeInTheDocument(); + }); + + it("should render all four option buttons", () => { + render( + + ); + + expect(screen.getByText("Blank canvas")).toBeInTheDocument(); + expect(screen.getByText("Load workflow")).toBeInTheDocument(); + expect(screen.getByText("Templates")).toBeInTheDocument(); + expect(screen.getByText("Prompt a workflow")).toBeInTheDocument(); + }); + + it("should render option descriptions", () => { + render( + + ); + + expect(screen.getByText("Start from scratch")).toBeInTheDocument(); + expect(screen.getByText("Open existing file")).toBeInTheDocument(); + expect(screen.getByText("Pre-built workflows")).toBeInTheDocument(); + expect(screen.getByText("Describe what you want")).toBeInTheDocument(); + }); + }); + + describe("Blank Canvas Option", () => { + it("should call onSelectBlankCanvas when clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Blank canvas")); + + expect(mockOnSelectBlankCanvas).toHaveBeenCalledTimes(1); + }); + + it("should display correct description for blank canvas", () => { + render( + + ); + + expect(screen.getByText("Start from scratch")).toBeInTheDocument(); + }); + }); + + describe("Load Workflow Option", () => { + it("should call onSelectLoad when clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Load workflow")); + + expect(mockOnSelectLoad).toHaveBeenCalledTimes(1); + }); + }); + + describe("Templates Option", () => { + it("should call onSelectTemplates when clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Templates")); + + expect(mockOnSelectTemplates).toHaveBeenCalledTimes(1); + }); + }); + + describe("Prompt a Workflow Option", () => { + it("should call onSelectVibe when clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Prompt a workflow")); + + expect(mockOnSelectVibe).toHaveBeenCalledTimes(1); + }); + + it("should display Beta badge on prompt option", () => { + render( + + ); + + expect(screen.getByText("Beta")).toBeInTheDocument(); + }); + }); + + describe("External Links", () => { + it("should render Discord link with correct URL", () => { + render( + + ); + + const discordLink = screen.getByText("Discord").closest("a"); + expect(discordLink).toHaveAttribute( + "href", + "https://discord.com/invite/89Nr6EKkTf" + ); + expect(discordLink).toHaveAttribute("target", "_blank"); + expect(discordLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render Twitter/X link with correct URL", () => { + render( + + ); + + const twitterLink = screen.getByText("Willie").closest("a"); + expect(twitterLink).toHaveAttribute("href", "https://x.com/ReflctWillie"); + expect(twitterLink).toHaveAttribute("target", "_blank"); + expect(twitterLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render docs coming soon text (not as a link)", () => { + render( + + ); + + expect(screen.getByText("Docs (coming soon)")).toBeInTheDocument(); + expect(screen.getByText("Docs (coming soon)").closest("a")).toBeNull(); + }); + }); + + describe("Accessibility", () => { + it("should have all buttons as interactive button elements", () => { + render( + + ); + + const buttons = screen.getAllByRole("button"); + // Should have 4 option buttons + expect(buttons.length).toBe(4); + }); + }); +}); diff --git a/src/components/__tests__/WelcomeModal.test.tsx b/src/components/__tests__/WelcomeModal.test.tsx new file mode 100644 index 00000000..fbd76a80 --- /dev/null +++ b/src/components/__tests__/WelcomeModal.test.tsx @@ -0,0 +1,395 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { WelcomeModal } from "@/components/quickstart/WelcomeModal"; +import { WorkflowFile } from "@/store/workflowStore"; + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Mock templates +vi.mock("@/lib/quickstart/templates", () => ({ + getAllPresets: () => [ + { + id: "product-shot", + name: "Product Shot", + description: "Place product in a new scene or environment", + icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", + }, + ], +})); + +describe("WelcomeModal", () => { + const mockOnWorkflowGenerated = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + // Setup default fetch mock for community workflows + mockFetch.mockImplementation((url: string) => { + if (url === "/api/community-workflows") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, workflows: [] }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Basic Rendering", () => { + it("should render welcome modal with initial view by default", () => { + render( + + ); + + expect(screen.getByText("Node Banana")).toBeInTheDocument(); + expect(screen.getByText("Blank canvas")).toBeInTheDocument(); + expect(screen.getByText("Templates")).toBeInTheDocument(); + expect(screen.getByText("Prompt a workflow")).toBeInTheDocument(); + }); + + it("should render modal overlay with backdrop", () => { + const { container } = render( + + ); + + const backdrop = container.querySelector(".bg-black\\/50"); + expect(backdrop).toBeInTheDocument(); + }); + }); + + describe("Initial View Navigation", () => { + it("should call onClose when 'Blank canvas' is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Blank canvas")); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("should navigate to templates view when 'Templates' is clicked", async () => { + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByText("Templates")); + }); + + await waitFor(() => { + expect(screen.getByText("Workflow Templates")).toBeInTheDocument(); + expect(screen.getByText("Quick Start")).toBeInTheDocument(); + }); + }); + + it("should navigate to vibe view when 'Prompt a workflow' is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByText("Prompt a workflow")); + + expect(screen.getByText("Prompt a Workflow")).toBeInTheDocument(); + expect(screen.getByText("Describe your workflow")).toBeInTheDocument(); + }); + }); + + describe("View Transitions", () => { + it("should navigate back to initial view from templates view", async () => { + render( + + ); + + // Navigate to templates + await act(async () => { + fireEvent.click(screen.getByText("Templates")); + }); + + await waitFor(() => { + expect(screen.getByText("Workflow Templates")).toBeInTheDocument(); + }); + + // Click back + await act(async () => { + fireEvent.click(screen.getByText("Back")); + }); + + expect(screen.getByText("Node Banana")).toBeInTheDocument(); + expect(screen.getByText("Blank canvas")).toBeInTheDocument(); + }); + + it("should navigate back to initial view from prompt view", () => { + render( + + ); + + // Navigate to prompt view + fireEvent.click(screen.getByText("Prompt a workflow")); + expect(screen.getByText("Prompt a Workflow")).toBeInTheDocument(); + + // Click back + fireEvent.click(screen.getByText("Back")); + + expect(screen.getByText("Node Banana")).toBeInTheDocument(); + }); + }); + + describe("File Loading", () => { + it("should render hidden file input for workflow loading", () => { + const { container } = render( + + ); + + const fileInput = container.querySelector('input[type="file"]'); + expect(fileInput).toBeInTheDocument(); + expect(fileInput).toHaveAttribute("accept", ".json"); + expect(fileInput).toHaveClass("hidden"); + }); + + it("should trigger file input when 'Load workflow' is clicked", () => { + const { container } = render( + + ); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, "click"); + + fireEvent.click(screen.getByText("Load workflow")); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it("should call onWorkflowGenerated when valid workflow file is loaded", async () => { + const validWorkflow: WorkflowFile = { + id: "test-id", + version: 1, + name: "Test Workflow", + edgeStyle: "curved", + nodes: [], + edges: [], + }; + + // Create a mock FileReader class + const mockFileReader = { + readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent) => void) | null; result: string }) { + setTimeout(() => { + this.onload?.({ target: { result: this.result } } as ProgressEvent); + }, 0); + }), + onload: null as ((e: ProgressEvent) => void) | null, + onerror: null, + result: JSON.stringify(validWorkflow), + }; + + vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { + Object.assign(this, mockFileReader); + this.readAsText = mockFileReader.readAsText.bind(this); + return this; + }); + + const { container } = render( + + ); + + const file = new File([JSON.stringify(validWorkflow)], "test.json", { + type: "application/json", + }); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + await waitFor(() => { + expect(mockOnWorkflowGenerated).toHaveBeenCalledWith(validWorkflow); + }); + + vi.unstubAllGlobals(); + }); + + it("should show alert for invalid workflow file format", async () => { + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); + + const invalidWorkflow = { foo: "bar" }; // Missing required fields + + const mockFileReader = { + readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent) => void) | null; result: string }) { + setTimeout(() => { + this.onload?.({ target: { result: this.result } } as ProgressEvent); + }, 0); + }), + onload: null as ((e: ProgressEvent) => void) | null, + onerror: null, + result: JSON.stringify(invalidWorkflow), + }; + + vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { + Object.assign(this, mockFileReader); + this.readAsText = mockFileReader.readAsText.bind(this); + return this; + }); + + const { container } = render( + + ); + + const file = new File([JSON.stringify(invalidWorkflow)], "test.json", { + type: "application/json", + }); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith("Invalid workflow file format"); + }); + expect(mockOnWorkflowGenerated).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it("should show alert when file parsing fails", async () => { + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); + + const mockFileReader = { + readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent) => void) | null; result: string }) { + setTimeout(() => { + this.onload?.({ target: { result: this.result } } as ProgressEvent); + }, 0); + }), + onload: null as ((e: ProgressEvent) => void) | null, + onerror: null, + result: "invalid json", + }; + + vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { + Object.assign(this, mockFileReader); + this.readAsText = mockFileReader.readAsText.bind(this); + return this; + }); + + const { container } = render( + + ); + + const file = new File(["invalid json"], "test.json", { + type: "application/json", + }); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith("Failed to parse workflow file"); + }); + + vi.unstubAllGlobals(); + }); + + it("should not process if no file is selected", () => { + const { container } = render( + + ); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + fireEvent.change(fileInput, { target: { files: [] } }); + + expect(mockOnWorkflowGenerated).not.toHaveBeenCalled(); + }); + }); + + describe("Workflow Selection from Child Views", () => { + it("should call onWorkflowGenerated when workflow is generated from templates view", async () => { + render( + + ); + + // Navigate to templates + await act(async () => { + fireEvent.click(screen.getByText("Templates")); + }); + + await waitFor(() => { + expect(screen.getByText("Workflow Templates")).toBeInTheDocument(); + }); + + // Verify templates view is showing - the actual workflow selection is tested in QuickstartTemplatesView tests + expect(screen.getByText("Quick Start")).toBeInTheDocument(); + }); + + it("should show prompt view when navigating to vibe", () => { + render( + + ); + + // Navigate to vibe/prompt view + fireEvent.click(screen.getByText("Prompt a workflow")); + + expect(screen.getByText("Prompt a Workflow")).toBeInTheDocument(); + expect(screen.getByText("Generate Workflow")).toBeInTheDocument(); + }); + }); +});