diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx
new file mode 100644
index 00000000..0e985e47
--- /dev/null
+++ b/src/components/__tests__/Header.test.tsx
@@ -0,0 +1,413 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { Header } from "@/components/Header";
+
+// Mock the workflow store
+const mockSetWorkflowMetadata = vi.fn();
+const mockSaveToFile = vi.fn();
+const mockLoadWorkflow = vi.fn();
+const mockUseWorkflowStore = vi.fn();
+
+vi.mock("@/store/workflowStore", () => ({
+ useWorkflowStore: (selector?: (state: unknown) => unknown) => {
+ if (selector) {
+ return mockUseWorkflowStore(selector);
+ }
+ return mockUseWorkflowStore((s: unknown) => s);
+ },
+}));
+
+// Mock ProjectSetupModal
+vi.mock("@/components/ProjectSetupModal", () => ({
+ ProjectSetupModal: ({ isOpen, mode }: { isOpen: boolean; mode: string }) => (
+ isOpen ?
Project Setup Modal
: null
+ ),
+}));
+
+// Mock CostIndicator
+vi.mock("@/components/CostIndicator", () => ({
+ CostIndicator: () => $0.00
,
+}));
+
+// Default store state factory
+const createDefaultState = (overrides = {}) => ({
+ workflowName: "",
+ workflowId: "",
+ saveDirectoryPath: "",
+ hasUnsavedChanges: false,
+ lastSavedAt: null,
+ isSaving: false,
+ setWorkflowMetadata: mockSetWorkflowMetadata,
+ saveToFile: mockSaveToFile,
+ loadWorkflow: mockLoadWorkflow,
+ ...overrides,
+});
+
+describe("Header", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Default mock implementation - unconfigured project
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState());
+ });
+ });
+
+ describe("Basic Rendering", () => {
+ it("should render the app title", () => {
+ render();
+ expect(screen.getByText("Node Banana")).toBeInTheDocument();
+ });
+
+ it("should render the banana icon", () => {
+ render();
+ const icon = screen.getByAltText("Banana");
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveAttribute("src", "/banana_icon.png");
+ });
+
+ it("should render 'Made by Willie' link", () => {
+ render();
+ const link = screen.getByText("Made by Willie");
+ expect(link).toHaveAttribute("href", "https://x.com/ReflctWillie");
+ });
+
+ it("should render Discord support link", () => {
+ render();
+ const link = screen.getByTitle("Support");
+ expect(link).toHaveAttribute("href", "https://discord.com/invite/89Nr6EKkTf");
+ });
+ });
+
+ describe("Unconfigured Project State", () => {
+ it("should show 'Untitled' when no project name is set", () => {
+ render();
+ expect(screen.getByText("Untitled")).toBeInTheDocument();
+ });
+
+ it("should show 'Not saved' status when project is not configured", () => {
+ render();
+ expect(screen.getByText("Not saved")).toBeInTheDocument();
+ });
+
+ it("should show save button with unsaved indicator (red dot)", () => {
+ const { container } = render();
+ const redDot = container.querySelector(".bg-red-500.rounded-full");
+ expect(redDot).toBeInTheDocument();
+ });
+
+ it("should not render CostIndicator when project is not configured", () => {
+ render();
+ expect(screen.queryByTestId("cost-indicator")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Configured Project State", () => {
+ beforeEach(() => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ }));
+ });
+ });
+
+ it("should show project name when configured", () => {
+ render();
+ expect(screen.getByText("My Project")).toBeInTheDocument();
+ });
+
+ it("should render CostIndicator when project is configured", () => {
+ render();
+ expect(screen.getByTestId("cost-indicator")).toBeInTheDocument();
+ });
+
+ it("should show 'Not saved' when no lastSavedAt timestamp", () => {
+ render();
+ expect(screen.getByText("Not saved")).toBeInTheDocument();
+ });
+
+ it("should render Open Project Folder button when saveDirectoryPath is set", () => {
+ render();
+ const folderButton = screen.getByTitle("Open Project Folder");
+ expect(folderButton).toBeInTheDocument();
+ });
+ });
+
+ describe("Save State Display", () => {
+ it("should show 'Saving...' when isSaving is true", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ isSaving: true,
+ }));
+ });
+
+ render();
+ expect(screen.getByText("Saving...")).toBeInTheDocument();
+ });
+
+ it("should show formatted save time when lastSavedAt is set", () => {
+ const timestamp = new Date("2024-01-15T14:30:00").getTime();
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ lastSavedAt: timestamp,
+ }));
+ });
+
+ render();
+ // The exact format depends on locale, but should contain "Saved"
+ expect(screen.getByText(/Saved/)).toBeInTheDocument();
+ });
+ });
+
+ describe("Unsaved Changes Indicator", () => {
+ it("should show red dot when hasUnsavedChanges is true", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ hasUnsavedChanges: true,
+ }));
+ });
+
+ const { container } = render();
+ const redDot = container.querySelector(".bg-red-500.rounded-full");
+ expect(redDot).toBeInTheDocument();
+ });
+
+ it("should not show red dot when hasUnsavedChanges is false and project is configured", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ hasUnsavedChanges: false,
+ }));
+ });
+
+ const { container } = render();
+ // Find the save button area and check there's no red dot inside it
+ const saveButton = screen.getByTitle("Save project");
+ const redDotInSaveButton = saveButton.querySelector(".bg-red-500.rounded-full");
+ expect(redDotInSaveButton).not.toBeInTheDocument();
+ });
+
+ it("should not show red dot when isSaving is true even if hasUnsavedChanges", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ hasUnsavedChanges: true,
+ isSaving: true,
+ }));
+ });
+
+ const { container } = render();
+ const saveButton = screen.getByTitle("Saving...");
+ const redDotInSaveButton = saveButton.querySelector(".bg-red-500.rounded-full");
+ expect(redDotInSaveButton).not.toBeInTheDocument();
+ });
+ });
+
+ describe("New Project Button", () => {
+ it("should open ProjectSetupModal in 'new' mode when save button clicked (unconfigured)", () => {
+ render();
+
+ const saveButton = screen.getByTitle("Save project");
+ fireEvent.click(saveButton);
+
+ const modal = screen.getByTestId("project-setup-modal");
+ expect(modal).toBeInTheDocument();
+ expect(modal).toHaveAttribute("data-mode", "new");
+ });
+ });
+
+ describe("Open File Button", () => {
+ it("should render open file button when project is not configured", () => {
+ render();
+ const openButton = screen.getByTitle("Open project");
+ expect(openButton).toBeInTheDocument();
+ });
+
+ it("should have hidden file input for loading workflows", () => {
+ 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 click when open button is clicked", () => {
+ const { container } = render();
+ const openButton = screen.getByTitle("Open project");
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ // Mock click on file input
+ const clickSpy = vi.spyOn(fileInput, "click");
+ fireEvent.click(openButton);
+
+ expect(clickSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("Save Button", () => {
+ beforeEach(() => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ }));
+ });
+ });
+
+ it("should call saveToFile when clicked on configured project", () => {
+ render();
+ const saveButton = screen.getByTitle("Save project");
+ fireEvent.click(saveButton);
+
+ expect(mockSaveToFile).toHaveBeenCalled();
+ });
+
+ it("should be disabled when isSaving is true", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ isSaving: true,
+ }));
+ });
+
+ render();
+ const saveButton = screen.getByTitle("Saving...");
+ expect(saveButton).toBeDisabled();
+ });
+
+ it("should open settings modal when project name is set but saveDirectoryPath is empty", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "",
+ saveDirectoryPath: "",
+ }));
+ });
+
+ render();
+ const saveButton = screen.getByTitle("Configure save location");
+ fireEvent.click(saveButton);
+
+ const modal = screen.getByTestId("project-setup-modal");
+ expect(modal).toBeInTheDocument();
+ expect(modal).toHaveAttribute("data-mode", "settings");
+ });
+ });
+
+ describe("Settings Button", () => {
+ it("should open ProjectSetupModal in 'settings' mode when settings button clicked", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ }));
+ });
+
+ render();
+ const settingsButton = screen.getByTitle("Project settings");
+ fireEvent.click(settingsButton);
+
+ const modal = screen.getByTestId("project-setup-modal");
+ expect(modal).toBeInTheDocument();
+ expect(modal).toHaveAttribute("data-mode", "settings");
+ });
+
+ it("should be visible for unconfigured projects", () => {
+ render();
+ const settingsButton = screen.getByTitle("Project settings");
+ expect(settingsButton).toBeInTheDocument();
+ });
+ });
+
+ describe("Open Project Folder Button", () => {
+ it("should not be visible when saveDirectoryPath is not set", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "",
+ }));
+ });
+
+ render();
+ expect(screen.queryByTitle("Open Project Folder")).not.toBeInTheDocument();
+ });
+
+ it("should be visible when saveDirectoryPath is set", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ }));
+ });
+
+ render();
+ expect(screen.getByTitle("Open Project Folder")).toBeInTheDocument();
+ });
+
+ it("should call fetch to open-directory API when clicked", async () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ workflowName: "My Project",
+ workflowId: "project-123",
+ saveDirectoryPath: "/path/to/project",
+ }));
+ });
+
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ success: true }),
+ });
+ global.fetch = mockFetch;
+
+ render();
+ const folderButton = screen.getByTitle("Open Project Folder");
+ fireEvent.click(folderButton);
+
+ expect(mockFetch).toHaveBeenCalledWith("/api/open-directory", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "/path/to/project" }),
+ });
+ });
+ });
+
+ describe("File Loading", () => {
+ it("should not call loadWorkflow when no file is selected", () => {
+ const { container } = render();
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ // Trigger file change with empty files
+ fireEvent.change(fileInput, { target: { files: [] } });
+
+ expect(mockLoadWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("should reset file input value after file selection to allow re-selecting same file", () => {
+ const { container } = render();
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ // File input should accept .json files
+ expect(fileInput).toHaveAttribute("accept", ".json");
+ });
+ });
+});