Browse Source

Checkpoint: project setup - save creates project-named folder - verified

handoff-20260429-1057
Marquis Boyd 5 months ago
parent
commit
75ecc2c733
  1. 84
      src/app/api/workflow-images/__tests__/route.test.ts
  2. 23
      src/app/api/workflow-images/route.ts
  3. 12
      src/app/api/workflow/__tests__/route.test.ts
  4. 23
      src/app/api/workflow/route.ts
  5. 55
      src/components/ProjectSetupModal.tsx
  6. 64
      src/components/__tests__/ProjectSetupModal.test.tsx

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

23
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

12
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 () => {

23
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

55
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(

64
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(
<ProjectSetupModal
isOpen={true}
onClose={vi.fn()}
onSave={vi.fn()}
mode="new"
/>
);
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({

Loading…
Cancel
Save