diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/core/src/studio-api/routes/files.test.ts new file mode 100644 index 000000000..8fe71cb00 --- /dev/null +++ b/packages/core/src/studio-api/routes/files.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerFileRoutes } from "./files"; +import type { StudioApiAdapter } from "../types"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createProjectDir(): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-files-test-")); + tempDirs.push(projectDir); + writeFileSync(join(projectDir, "index.html"), "
Preview"); + return projectDir; +} + +function createAdapter(projectDir: string): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: projectDir }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ + id: "job-1", + status: "rendering", + progress: 0, + outputPath: "/tmp/out.mp4", + }), + }; +} + +describe("registerFileRoutes", () => { + it("returns empty content for missing files when caller marks the read optional", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/files/missing-file.txt?optional=1", + ); + const payload = (await response.json()) as { filename?: string; content?: string }; + + expect(response.status).toBe(200); + expect(payload.filename).toBe("missing-file.txt"); + expect(payload.content).toBe(""); + }); + + it("still returns 404 for other missing files", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/missing-file.txt"); + + expect(response.status).toBe(404); + }); +}); diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index e801d5b59..732f2fd3e 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -26,7 +26,11 @@ import { removeElementFromHtml } from "../helpers/sourceMutation.js"; * Returns null (and sends an error response) if anything is invalid. */ interface RouteContext { - req: { param: (name: string) => string; path: string }; + req: { + param: (name: string) => string; + path: string; + query: (name: string) => string | undefined; + }; json: (data: unknown, status?: number) => Response; } @@ -135,9 +139,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { // ── Read ── api.get("/projects/:id/files/*", async (c) => { - const res = await resolveProjectFile(c, adapter, { mustExist: true }); + const res = await resolveProjectFile(c, adapter); if ("error" in res) return res.error; + if (!existsSync(res.absPath)) { + if (c.req.query("optional") === "1") { + return c.json({ filename: res.filePath, content: "" }); + } + return c.json({ error: "not found" }, 404); + } + const content = readFileSync(res.absPath, "utf-8"); return c.json({ filename: res.filePath, content }); }); diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index a63c5e3ad..1ea983ad1 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -106,8 +106,9 @@ export function useFileManager({ const readOptionalProjectFile = useCallback(async (path: string): Promise