From 75ecc2c73382a7e301c5bfe7931f4bec26f82424 Mon Sep 17 00:00:00 2001 From: Marquis Boyd Date: Tue, 17 Feb 2026 09:50:33 -0500 Subject: [PATCH 1/2] Checkpoint: project setup - save creates project-named folder - verified --- .../workflow-images/__tests__/route.test.ts | 84 +++++++++++++++++++ src/app/api/workflow-images/route.ts | 23 +++-- src/app/api/workflow/__tests__/route.test.ts | 12 ++- src/app/api/workflow/route.ts | 23 +++-- src/components/ProjectSetupModal.tsx | 55 +++++++++--- .../__tests__/ProjectSetupModal.test.tsx | 64 ++++++++++++-- 6 files changed, 223 insertions(+), 38 deletions(-) create mode 100644 src/app/api/workflow-images/__tests__/route.test.ts diff --git a/src/app/api/workflow-images/__tests__/route.test.ts b/src/app/api/workflow-images/__tests__/route.test.ts new file mode 100644 index 00000000..a5e34b37 --- /dev/null +++ b/src/app/api/workflow-images/__tests__/route.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockStat = vi.fn(); +const mockMkdir = vi.fn(); +const mockWriteFile = vi.fn(); + +vi.mock("fs/promises", () => ({ + stat: (...args: unknown[]) => mockStat(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), +})); + +vi.mock("@/utils/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { POST } from "../route"; + +function createMockPostRequest(body: unknown): NextRequest { + return { + json: vi.fn().mockResolvedValue(body), + } as unknown as NextRequest; +} + +describe("/api/workflow-images route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("POST - Save workflow image", () => { + it("should save image when workflow directory exists", async () => { + mockStat.mockResolvedValue({ + isDirectory: () => true, + }); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + const request = createMockPostRequest({ + workflowPath: "/test/workflow", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.imageId).toBe("img_123"); + expect(data.filePath).toBe("/test/workflow/inputs/img_123.png"); + expect(mockMkdir).toHaveBeenCalledWith("/test/workflow/inputs", { recursive: true }); + expect(mockWriteFile).toHaveBeenCalled(); + }); + + it("should create missing workflow directory and save image", async () => { + mockStat.mockRejectedValue(new Error("ENOENT")); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + const request = createMockPostRequest({ + workflowPath: "/test/new-workflow", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow", { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow/inputs", { recursive: true }); + }); + }); +}); diff --git a/src/app/api/workflow-images/route.ts b/src/app/api/workflow-images/route.ts index 6d65d6eb..8e3179c5 100644 --- a/src/app/api/workflow-images/route.ts +++ b/src/app/api/workflow-images/route.ts @@ -44,7 +44,7 @@ export async function POST(request: NextRequest) { ); } - // Validate workflow directory exists + // Validate workflow directory exists, or create it if missing try { const stats = await fs.stat(workflowPath); if (!stats.isDirectory()) { @@ -57,13 +57,20 @@ export async function POST(request: NextRequest) { ); } } catch (dirError) { - logger.warn('file.error', 'Workflow image save failed: directory does not exist', { - workflowPath, - }); - return NextResponse.json( - { success: false, error: "Workflow directory does not exist" }, - { status: 400 } - ); + try { + await fs.mkdir(workflowPath, { recursive: true }); + logger.info('file.save', 'Created workflow directory for image save', { + workflowPath, + }); + } catch (mkdirError) { + logger.error('file.error', 'Failed to create workflow directory', { + workflowPath, + }, mkdirError instanceof Error ? mkdirError : undefined); + return NextResponse.json( + { success: false, error: "Failed to create workflow directory" }, + { status: 500 } + ); + } } // Create target folder if it doesn't exist diff --git a/src/app/api/workflow/__tests__/route.test.ts b/src/app/api/workflow/__tests__/route.test.ts index c4b5ec5e..e1d10f6d 100644 --- a/src/app/api/workflow/__tests__/route.test.ts +++ b/src/app/api/workflow/__tests__/route.test.ts @@ -183,8 +183,10 @@ describe("/api/workflow route", () => { expect(data.error).toBe("Path is not a directory"); }); - it("should reject non-existent directory", async () => { + it("should create non-existent directory and save", async () => { mockStat.mockRejectedValue(new Error("ENOENT")); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); const request = createMockPostRequest({ directoryPath: "/nonexistent/dir", @@ -195,9 +197,11 @@ describe("/api/workflow route", () => { const response = await POST(request); const data = await response.json(); - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.error).toBe("Directory does not exist"); + expect(data.success).toBe(true); + expect(data.filePath).toBe("/nonexistent/dir/workflow.json"); + expect(mockMkdir).toHaveBeenCalledWith("/nonexistent/dir", { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith("/nonexistent/dir/inputs", { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith("/nonexistent/dir/generations", { recursive: true }); }); it("should continue saving even if subfolder creation fails", async () => { diff --git a/src/app/api/workflow/route.ts b/src/app/api/workflow/route.ts index cb8ed2d6..72d7d904 100644 --- a/src/app/api/workflow/route.ts +++ b/src/app/api/workflow/route.ts @@ -35,7 +35,7 @@ export async function POST(request: NextRequest) { ); } - // Validate directory exists + // Validate directory exists, or create it if missing try { const stats = await fs.stat(directoryPath); if (!stats.isDirectory()) { @@ -48,13 +48,20 @@ export async function POST(request: NextRequest) { ); } } catch (dirError) { - logger.warn('file.error', 'Workflow save failed: directory does not exist', { - directoryPath, - }); - return NextResponse.json( - { success: false, error: "Directory does not exist" }, - { status: 400 } - ); + try { + await fs.mkdir(directoryPath, { recursive: true }); + logger.info('file.save', 'Created workflow directory', { + directoryPath, + }); + } catch (mkdirError) { + logger.error('file.error', 'Failed to create workflow directory', { + directoryPath, + }, mkdirError instanceof Error ? mkdirError : undefined); + return NextResponse.json( + { success: false, error: "Failed to create directory" }, + { status: 500 } + ); + } } // Auto-create subfolders for inputs and generations diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 4f12c64a..5885465a 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -85,6 +85,40 @@ export function ProjectSetupModal({ onSave, mode, }: ProjectSetupModalProps) { + const sanitizeProjectFolderName = (projectName: string): string => { + return projectName + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\.+$/g, "") + .trim(); + }; + + const joinPathForPlatform = (basePath: string, folderName: string): string => { + const trimmedBase = basePath.trim(); + const separator = /^[A-Za-z]:[\\\/]/.test(trimmedBase) || trimmedBase.startsWith("\\\\") ? "\\" : "/"; + const endsWithSeparator = trimmedBase.endsWith("/") || trimmedBase.endsWith("\\"); + return `${trimmedBase}${endsWithSeparator ? "" : separator}${folderName}`; + }; + + const getPathBasename = (fullPath: string): string => { + const withoutTrailingSeparator = fullPath.trim().replace(/[\\/]+$/, ""); + const parts = withoutTrailingSeparator.split(/[\\/]/); + return parts[parts.length - 1] || ""; + }; + + const ensureProjectSubfolderPath = (basePath: string, projectName: string): string => { + const trimmedBase = basePath.trim(); + const sanitizedFolder = sanitizeProjectFolderName(projectName); + if (!sanitizedFolder) return trimmedBase; + + const basename = getPathBasename(trimmedBase); + if (basename.toLowerCase() === sanitizedFolder.toLowerCase()) { + return trimmedBase; + } + + return joinPathForPlatform(trimmedBase, sanitizedFolder); + }; + const { workflowName, saveDirectoryPath, @@ -204,7 +238,7 @@ export function ProjectSetupModal({ } if (result.path) { - setDirectoryPath(result.path); + setDirectoryPath(ensureProjectSubfolderPath(result.path, name)); } } catch (err) { setError( @@ -226,8 +260,9 @@ export function ProjectSetupModal({ return; } - const trimmedPath = directoryPath.trim(); - if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(trimmedPath) || trimmedPath.startsWith("\\\\"))) { + const fullProjectPath = ensureProjectSubfolderPath(directoryPath, name); + + if (!(fullProjectPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(fullProjectPath) || fullProjectPath.startsWith("\\\\"))) { setError("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)"); return; } @@ -236,19 +271,13 @@ export function ProjectSetupModal({ setError(null); try { - // Validate project directory exists + // Validate path shape when it already exists const response = await fetch( - `/api/workflow?path=${encodeURIComponent(directoryPath.trim())}` + `/api/workflow?path=${encodeURIComponent(fullProjectPath)}` ); const result = await response.json(); - if (!result.exists) { - setError("Project directory does not exist"); - setIsValidating(false); - return; - } - - if (!result.isDirectory) { + if (result.exists && !result.isDirectory) { setError("Project path is not a directory"); setIsValidating(false); return; @@ -257,7 +286,7 @@ export function ProjectSetupModal({ const id = mode === "new" ? generateWorkflowId() : useWorkflowStore.getState().workflowId || generateWorkflowId(); // Update external storage setting setUseExternalImageStorage(externalStorage); - onSave(id, name.trim(), directoryPath.trim()); + onSave(id, name.trim(), fullProjectPath); setIsValidating(false); } catch (err) { setError( diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index 1d58a6ac..8a1a9b9b 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -27,6 +27,20 @@ global.fetch = mockFetch; const mockConfirm = vi.fn(() => true); global.confirm = mockConfirm; +// Ensure localStorage is always available in this test environment +const localStorageMock = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0, +}; +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + configurable: true, +}); + // Default provider settings const defaultProviderSettings: ProviderSettings = { providers: { @@ -339,7 +353,7 @@ describe("ProjectSetupModal", () => { expect(onSave).not.toHaveBeenCalled(); }); - it("should show error when directory does not exist", async () => { + it("should allow save when directory does not exist yet", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ @@ -379,9 +393,12 @@ describe("ProjectSetupModal", () => { fireEvent.click(screen.getByText("Create")); await waitFor(() => { - expect(screen.getByText("Project directory does not exist")).toBeInTheDocument(); + expect(onSave).toHaveBeenCalledWith( + "mock-workflow-id", + "My Project", + "/nonexistent/path/My Project" + ); }); - expect(onSave).not.toHaveBeenCalled(); }); it("should show error when path is not absolute", async () => { @@ -509,7 +526,7 @@ describe("ProjectSetupModal", () => { expect(onSave).toHaveBeenCalledWith( "mock-workflow-id", "My New Project", - "/path/to/project" + "/path/to/project/My New Project" ); }); }); @@ -641,7 +658,44 @@ describe("ProjectSetupModal", () => { }); }); - it("should update directory input when path is selected", async () => { + it("should append project name to selected path in new mode", async () => { + mockFetch.mockImplementation((url: string) => { + if (url === "/api/env-status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), + }); + } + if (url === "/api/browse-directory") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, path: "/selected/path" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); + }); + + render( + + ); + + fireEvent.change(screen.getByPlaceholderText("my-project"), { + target: { value: "My Project" }, + }); + fireEvent.click(screen.getByText("Browse")); + + await waitFor(() => { + const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement; + expect(directoryInput.value).toBe("/selected/path/My Project"); + }); + }); + + it("should keep selected path when project name is empty", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ From cf36c3b13febec25d3fd56e49668300fac4d0a79 Mon Sep 17 00:00:00 2001 From: Marquis Boyd Date: Tue, 17 Feb 2026 12:56:16 -0500 Subject: [PATCH 2/2] fix: save-as behavior and project path handling --- src/components/Header.tsx | 45 +++++++++++++ src/components/ProjectSetupModal.tsx | 2 +- src/components/__tests__/Header.test.tsx | 40 +++++++++++ .../__tests__/ProjectSetupModal.test.tsx | 67 +++++++++++++++++-- src/store/workflowStore.ts | 23 +++++++ 5 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index aeb28f80..3e03b062 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -67,6 +67,7 @@ export function Header() { isSaving, setWorkflowMetadata, saveToFile, + saveAsFile, loadWorkflow, previousWorkflowSnapshot, revertToSnapshot, @@ -162,6 +163,32 @@ export function Header() { } }; + const handleSaveAs = async () => { + if (!workflowName || !saveDirectoryPath) { + handleOpenSettings(); + return; + } + + const value = window.prompt("Save project as", workflowName); + if (value === null) return; + + const newName = value.trim(); + if (!newName) { + alert("Project name cannot be empty."); + return; + } + + try { + const success = await saveAsFile(newName); + if (!success) { + alert("Save As failed. Please try again."); + } + } catch (error) { + console.error("Failed to save project as:", error); + alert("Save As failed. Please try again."); + } + }; + const handleRevertAIChanges = useCallback(() => { const confirmed = window.confirm( "Are you sure? This will restore your previous workflow." @@ -254,6 +281,24 @@ export function Header() { )} + {canSave && ( + + )} {saveDirectoryPath && (