diff --git a/src/content/__tests__/loadJsonFile.test.ts b/src/content/__tests__/loadJsonFile.test.ts new file mode 100644 index 0000000..2a279c8 --- /dev/null +++ b/src/content/__tests__/loadJsonFile.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loadJsonFile } from "../loadJsonFile"; + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + default: { readFile: vi.fn() }, +})); + +const fs = (await import("node:fs/promises")).default; +const { logStep } = await import("../../sandboxes/logStep"); + +describe("loadJsonFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns parsed JSON on success", async () => { + vi.mocked(fs.readFile).mockResolvedValue('{"key": "value"}'); + + const result = await loadJsonFile>("/path/to/file.json", "file.json"); + + expect(result).toEqual({ key: "value" }); + expect(logStep).toHaveBeenCalledWith( + "loadTemplate: loaded file.json", + false, + expect.objectContaining({ sizeBytes: 16 }), + ); + }); + + it("returns null when file does not exist", async () => { + const err = new Error("ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + vi.mocked(fs.readFile).mockRejectedValue(err); + + const result = await loadJsonFile("/path/to/missing.json", "missing.json"); + + expect(result).toBeNull(); + }); + + it("rethrows non-ENOENT errors", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("EPERM")); + + await expect(loadJsonFile("/path/to/bad.json", "bad.json")).rejects.toThrow("EPERM"); + }); +}); diff --git a/src/content/__tests__/resolveTemplatesDir.test.ts b/src/content/__tests__/resolveTemplatesDir.test.ts new file mode 100644 index 0000000..fa2daa7 --- /dev/null +++ b/src/content/__tests__/resolveTemplatesDir.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import { resolveTemplatesDir } from "../resolveTemplatesDir"; + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + default: { access: vi.fn() }, +})); + +const fs = (await import("node:fs/promises")).default; + +describe("resolveTemplatesDir", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns __dirname-relative path when it exists", async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + + const result = await resolveTemplatesDir("/app/src/content"); + + expect(result).toBe(path.resolve("/app/src/content", "../content/templates")); + expect(fs.access).toHaveBeenCalledTimes(1); + }); + + it("falls back to cwd-relative path when __dirname path missing", async () => { + const err = new Error("ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + vi.mocked(fs.access).mockRejectedValueOnce(err); + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + + const result = await resolveTemplatesDir("/wrong/path"); + + expect(result).toBe(path.resolve(process.cwd(), "src/content/templates")); + expect(fs.access).toHaveBeenCalledTimes(2); + }); + + it("rethrows permission errors instead of falling through", async () => { + const err = new Error("EACCES") as NodeJS.ErrnoException; + err.code = "EACCES"; + vi.mocked(fs.access).mockRejectedValueOnce(err); + + await expect(resolveTemplatesDir("/app/src/content")).rejects.toThrow("EACCES"); + expect(fs.access).toHaveBeenCalledTimes(1); + }); + + it("throws when no candidate directory exists", async () => { + const err = new Error("ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + vi.mocked(fs.access).mockRejectedValue(err); + + await expect(resolveTemplatesDir("/wrong/path")).rejects.toThrow( + "Templates directory not found", + ); + }); +}); diff --git a/src/content/loadJsonFile.ts b/src/content/loadJsonFile.ts new file mode 100644 index 0000000..6c01ff2 --- /dev/null +++ b/src/content/loadJsonFile.ts @@ -0,0 +1,34 @@ +import fs from "node:fs/promises"; +import { logStep } from "../sandboxes/logStep"; + +/** + * Load a JSON file. Returns null if the file doesn't exist. + * Rethrows parse errors and unexpected I/O failures so callers surface real bugs. + * + * @param filePath + * @param label + */ +export async function loadJsonFile(filePath: string, label: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw) as T; + logStep(`loadTemplate: loaded ${label}`, false, { + path: filePath, + sizeBytes: raw.length, + }); + return parsed; + } catch (err: unknown) { + if ( + err instanceof Error && + "code" in err && + (err as NodeJS.ErrnoException).code === "ENOENT" + ) { + return null; + } + logStep(`loadTemplate: FAILED to load ${label}`, true, { + path: filePath, + error: String(err), + }); + throw err; + } +} diff --git a/src/content/loadTemplate.ts b/src/content/loadTemplate.ts index c01bc3b..c9bd437 100644 --- a/src/content/loadTemplate.ts +++ b/src/content/loadTemplate.ts @@ -1,5 +1,8 @@ import path from "node:path"; import fs from "node:fs/promises"; +import { logStep } from "../sandboxes/logStep"; +import { resolveTemplatesDir } from "./resolveTemplatesDir"; +import { loadJsonFile } from "./loadJsonFile"; /** * Template data loaded from the bundled templates directory. @@ -18,29 +21,46 @@ export interface TemplateData { referenceImagePaths: string[]; } -/** Base path to the bundled templates directory. */ -const TEMPLATES_DIR = path.resolve(__dirname, "../content/templates"); - /** * Load all template data (style guide, caption guide, moods, movements, reference images). */ export async function loadTemplate(templateName: string): Promise { - const templateDir = path.join(TEMPLATES_DIR, templateName); + const templatesDir = await resolveTemplatesDir(__dirname); + const templateDir = path.join(templatesDir, templateName); + + logStep("loadTemplate: resolving paths", false, { + __dirname, + cwd: process.cwd(), + templatesDir, + templateDir, + }); + + // Check the template directory exists + try { + await fs.access(templateDir); + } catch { + throw new Error(`Template directory not found: ${templateDir}`); + } const styleGuide = await loadJsonFile>( path.join(templateDir, "style-guide.json"), + "style-guide.json", ); const captionGuide = await loadJsonFile>( path.join(templateDir, "caption-guide.json"), + "caption-guide.json", ); const captionExamples = await loadJsonFile( path.join(templateDir, "references", "captions", "examples.json"), + "references/captions/examples.json", ) ?? []; const videoMoods = await loadJsonFile( path.join(templateDir, "video-moods.json"), + "video-moods.json", ) ?? []; const videoMovements = await loadJsonFile( path.join(templateDir, "video-movements.json"), + "video-movements.json", ) ?? []; // Discover reference images @@ -52,16 +72,30 @@ export async function loadTemplate(templateName: string): Promise .filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f)) .sort() .map(f => path.join(imagesDir, f)); + logStep("loadTemplate: reference images found", false, { + count: referenceImagePaths.length, + }); } catch { - // No images directory + logStep("loadTemplate: no reference images directory", false, { imagesDir }); } // Read template-level fields from the style guide const sg = styleGuide as Record | null; const imagePrompt = (sg?.imagePrompt as string) ?? ""; - // Default to true — most templates use the artist's face const usesFaceGuide = (sg?.usesFaceGuide as boolean) ?? true; + logStep("loadTemplate: result summary", false, { + template: templateName, + hasStyleGuide: styleGuide !== null, + hasCaptionGuide: captionGuide !== null, + captionExamplesCount: captionExamples.length, + videoMoodsCount: videoMoods.length, + videoMovementsCount: videoMovements.length, + referenceImagesCount: referenceImagePaths.length, + imagePromptLength: imagePrompt.length, + usesFaceGuide, + }); + return { name: templateName, imagePrompt, @@ -131,13 +165,3 @@ export function buildMotionPrompt(template: TemplateData): string { return `Completely static camera. The person stares at the camera. Movement: ${movement}.${mood ? ` Energy: ${mood}.` : ""} Shot on phone, low light, grainy.`; } - -/** Load a JSON file, returning null if it doesn't exist. */ -async function loadJsonFile(filePath: string): Promise { - try { - const raw = await fs.readFile(filePath, "utf-8"); - return JSON.parse(raw) as T; - } catch { - return null; - } -} diff --git a/src/content/resolveTemplatesDir.ts b/src/content/resolveTemplatesDir.ts new file mode 100644 index 0000000..a856e17 --- /dev/null +++ b/src/content/resolveTemplatesDir.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { logStep } from "../sandboxes/logStep"; + +/** + * Resolves the templates directory. Tries multiple strategies because + * esbuild changes __dirname at bundle time: + * 1. __dirname-relative (works locally with tsx) + * 2. process.cwd()-relative (works in Trigger.dev deployments where + * additionalFiles preserves source-root-relative paths) + * + * @param dirname - The __dirname of the calling module + */ +export async function resolveTemplatesDir(dirname: string): Promise { + const candidates = [ + path.resolve(dirname, "../content/templates"), + path.resolve(process.cwd(), "src/content/templates"), + ]; + + for (const dir of candidates) { + try { + await fs.access(dir); + return dir; + } catch (err: unknown) { + if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw err; + } + } + + logStep("Template directory not found", true, { + dirname, + cwd: process.cwd(), + candidates, + }); + throw new Error( + `Templates directory not found. Tried: ${candidates.join(", ")}`, + ); +}