From 3f3d5cfabe558eb708e1ea5bf31d45c082ec67af Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 19:04:33 -0500 Subject: [PATCH 1/4] fix: resolve template directory path after esbuild bundling Refactored per review: - SRP: extract resolveTemplatesDir to its own file - DRY: use shared logStep instead of logger.log - OCP: extract logTemplateContents to its own file Template files were silently failing in production because __dirname points to esbuild output, not where additionalFiles places templates. resolveTemplatesDir tries __dirname first, falls back to process.cwd(). 209/209 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/loadTemplate.ts | 73 +++++++++++++++++++++++++----- src/content/logTemplateContents.ts | 25 ++++++++++ src/content/resolveTemplatesDir.ts | 37 +++++++++++++++ 3 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 src/content/logTemplateContents.ts create mode 100644 src/content/resolveTemplatesDir.ts diff --git a/src/content/loadTemplate.ts b/src/content/loadTemplate.ts index c01bc3b..fe329b5 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 { logTemplateContents } from "./logTemplateContents"; /** * Template data loaded from the bundled templates directory. @@ -18,29 +21,48 @@ 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}`); + } + + await logTemplateContents(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 +74,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, @@ -132,12 +168,27 @@ 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 { +/** + * 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. + */ +async function loadJsonFile(filePath: string, label: string): Promise { try { const raw = await fs.readFile(filePath, "utf-8"); - return JSON.parse(raw) as T; - } catch { - return null; + 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/logTemplateContents.ts b/src/content/logTemplateContents.ts new file mode 100644 index 0000000..2f8d54b --- /dev/null +++ b/src/content/logTemplateContents.ts @@ -0,0 +1,25 @@ +import fs from "node:fs/promises"; +import { logStep } from "../sandboxes/logStep"; + +/** + * Logs the contents of a template directory for diagnostic purposes. + * + * @param templateDir - Path to the template directory + */ +export async function logTemplateContents(templateDir: string): Promise { + const MAX_SAMPLE_FILES = 10; + try { + const entries = await fs.readdir(templateDir, { recursive: true }); + logStep("loadTemplate: directory contents", false, { + templateDir, + totalFiles: entries.length, + sampleFiles: entries.slice(0, MAX_SAMPLE_FILES), + ...(entries.length > MAX_SAMPLE_FILES && { hasMore: true }), + }); + } catch (err) { + logStep("loadTemplate: failed to list directory", true, { + templateDir, + error: String(err), + }); + } +} diff --git a/src/content/resolveTemplatesDir.ts b/src/content/resolveTemplatesDir.ts new file mode 100644 index 0000000..5b32412 --- /dev/null +++ b/src/content/resolveTemplatesDir.ts @@ -0,0 +1,37 @@ +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 { + // not found, try next + } + } + + logStep("Template directory not found", true, { + dirname, + cwd: process.cwd(), + candidates, + }); + throw new Error( + `Templates directory not found. Tried: ${candidates.join(", ")}`, + ); +} From d5139abedcb90b45d48bde569722af1b90bb8339 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 19:06:07 -0500 Subject: [PATCH 2/4] test: add unit tests for resolveTemplatesDir and logTemplateContents - 3 tests for resolveTemplatesDir (dirname path, cwd fallback, throws) - 2 tests for logTemplateContents (success logging, error logging) 214/214 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/logTemplateContents.test.ts | 43 +++++++++++++++++ .../__tests__/resolveTemplatesDir.test.ts | 46 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/content/__tests__/logTemplateContents.test.ts create mode 100644 src/content/__tests__/resolveTemplatesDir.test.ts diff --git a/src/content/__tests__/logTemplateContents.test.ts b/src/content/__tests__/logTemplateContents.test.ts new file mode 100644 index 0000000..c0ed9b1 --- /dev/null +++ b/src/content/__tests__/logTemplateContents.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { logTemplateContents } from "../logTemplateContents"; + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + default: { readdir: vi.fn() }, +})); + +const fs = (await import("node:fs/promises")).default; +const { logStep } = await import("../../sandboxes/logStep"); + +describe("logTemplateContents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs directory contents with file count", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["style-guide.json", "caption-guide.json"] as never); + + await logTemplateContents("/templates/bedroom"); + + expect(logStep).toHaveBeenCalledWith( + "loadTemplate: directory contents", + false, + expect.objectContaining({ totalFiles: 2 }), + ); + }); + + it("logs error when directory read fails", async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error("ENOENT")); + + await logTemplateContents("/templates/missing"); + + expect(logStep).toHaveBeenCalledWith( + "loadTemplate: failed to list directory", + true, + expect.objectContaining({ error: expect.stringContaining("ENOENT") }), + ); + }); +}); diff --git a/src/content/__tests__/resolveTemplatesDir.test.ts b/src/content/__tests__/resolveTemplatesDir.test.ts new file mode 100644 index 0000000..593ab03 --- /dev/null +++ b/src/content/__tests__/resolveTemplatesDir.test.ts @@ -0,0 +1,46 @@ +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 () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")); + 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("throws when no candidate directory exists", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); + + await expect(resolveTemplatesDir("/wrong/path")).rejects.toThrow( + "Templates directory not found", + ); + }); +}); From 0b3b707f48e1b3828aa40191f22196931d8fb868 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 19:12:54 -0500 Subject: [PATCH 3/4] refactor: extract loadJsonFile, remove logTemplateContents Address review: - SRP: loadJsonFile extracted to src/content/loadJsonFile.ts with 3 tests - Remove logTemplateContents (debug-only, not needed for the fix) 215/215 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/__tests__/loadJsonFile.test.ts | 48 +++++++++++++++++++ .../__tests__/logTemplateContents.test.ts | 43 ----------------- src/content/loadJsonFile.ts | 34 +++++++++++++ src/content/loadTemplate.ts | 29 +---------- src/content/logTemplateContents.ts | 25 ---------- 5 files changed, 83 insertions(+), 96 deletions(-) create mode 100644 src/content/__tests__/loadJsonFile.test.ts delete mode 100644 src/content/__tests__/logTemplateContents.test.ts create mode 100644 src/content/loadJsonFile.ts delete mode 100644 src/content/logTemplateContents.ts 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__/logTemplateContents.test.ts b/src/content/__tests__/logTemplateContents.test.ts deleted file mode 100644 index c0ed9b1..0000000 --- a/src/content/__tests__/logTemplateContents.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { logTemplateContents } from "../logTemplateContents"; - -vi.mock("../../sandboxes/logStep", () => ({ - logStep: vi.fn(), -})); - -vi.mock("node:fs/promises", () => ({ - default: { readdir: vi.fn() }, -})); - -const fs = (await import("node:fs/promises")).default; -const { logStep } = await import("../../sandboxes/logStep"); - -describe("logTemplateContents", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("logs directory contents with file count", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["style-guide.json", "caption-guide.json"] as never); - - await logTemplateContents("/templates/bedroom"); - - expect(logStep).toHaveBeenCalledWith( - "loadTemplate: directory contents", - false, - expect.objectContaining({ totalFiles: 2 }), - ); - }); - - it("logs error when directory read fails", async () => { - vi.mocked(fs.readdir).mockRejectedValue(new Error("ENOENT")); - - await logTemplateContents("/templates/missing"); - - expect(logStep).toHaveBeenCalledWith( - "loadTemplate: failed to list directory", - true, - expect.objectContaining({ error: expect.stringContaining("ENOENT") }), - ); - }); -}); 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 fe329b5..c9bd437 100644 --- a/src/content/loadTemplate.ts +++ b/src/content/loadTemplate.ts @@ -2,7 +2,7 @@ import path from "node:path"; import fs from "node:fs/promises"; import { logStep } from "../sandboxes/logStep"; import { resolveTemplatesDir } from "./resolveTemplatesDir"; -import { logTemplateContents } from "./logTemplateContents"; +import { loadJsonFile } from "./loadJsonFile"; /** * Template data loaded from the bundled templates directory. @@ -42,8 +42,6 @@ export async function loadTemplate(templateName: string): Promise throw new Error(`Template directory not found: ${templateDir}`); } - await logTemplateContents(templateDir); - const styleGuide = await loadJsonFile>( path.join(templateDir, "style-guide.json"), "style-guide.json", @@ -167,28 +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. Returns null if the file doesn't exist. - * Rethrows parse errors and unexpected I/O failures so callers surface real bugs. - */ -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/logTemplateContents.ts b/src/content/logTemplateContents.ts deleted file mode 100644 index 2f8d54b..0000000 --- a/src/content/logTemplateContents.ts +++ /dev/null @@ -1,25 +0,0 @@ -import fs from "node:fs/promises"; -import { logStep } from "../sandboxes/logStep"; - -/** - * Logs the contents of a template directory for diagnostic purposes. - * - * @param templateDir - Path to the template directory - */ -export async function logTemplateContents(templateDir: string): Promise { - const MAX_SAMPLE_FILES = 10; - try { - const entries = await fs.readdir(templateDir, { recursive: true }); - logStep("loadTemplate: directory contents", false, { - templateDir, - totalFiles: entries.length, - sampleFiles: entries.slice(0, MAX_SAMPLE_FILES), - ...(entries.length > MAX_SAMPLE_FILES && { hasMore: true }), - }); - } catch (err) { - logStep("loadTemplate: failed to list directory", true, { - templateDir, - error: String(err), - }); - } -} From 6ca72b8937690894b32eab0a90316484c3c63c64 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 19:23:15 -0500 Subject: [PATCH 4/4] fix: only catch ENOENT in resolveTemplatesDir, rethrow permission errors CodeRabbit feedback: bare catch swallowed EACCES/EPERM, masking real issues. Now only ENOENT falls through to the next candidate. 216/216 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/resolveTemplatesDir.test.ts | 17 +++++++++++++++-- src/content/resolveTemplatesDir.ts | 7 +++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/content/__tests__/resolveTemplatesDir.test.ts b/src/content/__tests__/resolveTemplatesDir.test.ts index 593ab03..fa2daa7 100644 --- a/src/content/__tests__/resolveTemplatesDir.test.ts +++ b/src/content/__tests__/resolveTemplatesDir.test.ts @@ -27,7 +27,9 @@ describe("resolveTemplatesDir", () => { }); it("falls back to cwd-relative path when __dirname path missing", async () => { - vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")); + 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"); @@ -36,8 +38,19 @@ describe("resolveTemplatesDir", () => { 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 () => { - vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); + 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/resolveTemplatesDir.ts b/src/content/resolveTemplatesDir.ts index 5b32412..a856e17 100644 --- a/src/content/resolveTemplatesDir.ts +++ b/src/content/resolveTemplatesDir.ts @@ -21,8 +21,11 @@ export async function resolveTemplatesDir(dirname: string): Promise { try { await fs.access(dir); return dir; - } catch { - // not found, try next + } catch (err: unknown) { + if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw err; } }