Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/content/__tests__/loadJsonFile.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>("/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");
});
});
59 changes: 59 additions & 0 deletions src/content/__tests__/resolveTemplatesDir.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
34 changes: 34 additions & 0 deletions src/content/loadJsonFile.ts
Original file line number Diff line number Diff line change
@@ -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<T>(filePath: string, label: string): Promise<T | null> {
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;
}
}
56 changes: 40 additions & 16 deletions src/content/loadTemplate.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<TemplateData> {
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}`);
Comment on lines +39 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "loadTemplate.ts" | head -5

Repository: recoupable/tasks

Length of output: 89


🏁 Script executed:

cat -n ./src/content/loadTemplate.ts

Repository: recoupable/tasks

Length of output: 7053


🏁 Script executed:

find . -type f -name "loadJsonFile.ts" -o -name "loadJsonFile.js"

Repository: recoupable/tasks

Length of output: 89


🏁 Script executed:

cat -n ./src/content/loadJsonFile.ts

Repository: recoupable/tasks

Length of output: 1213


Mirror the ENOENT-only error handling pattern already implemented in loadJsonFile().

The fs.access() catch block at lines 39–42 masks all errors with a generic "Template directory not found" message. Permission errors (EACCES/EPERM), I/O failures, and other non-ENOENT errors should bubble up unchanged so that deployment issues surface with their actual root cause, matching the error-handling discipline already established in loadJsonFile().

Proposed fix
   try {
     await fs.access(templateDir);
-  } catch {
-    throw new Error(`Template directory not found: ${templateDir}`);
+  } catch (err: unknown) {
+    if (
+      err instanceof Error &&
+      "code" in err &&
+      (err as NodeJS.ErrnoException).code === "ENOENT"
+    ) {
+      throw new Error(`Template directory not found: ${templateDir}`);
+    }
+    throw err;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await fs.access(templateDir);
} catch {
throw new Error(`Template directory not found: ${templateDir}`);
try {
await fs.access(templateDir);
} catch (err: unknown) {
if (
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT"
) {
throw new Error(`Template directory not found: ${templateDir}`);
}
throw err;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/loadTemplate.ts` around lines 39 - 42, The catch in loadTemplate
that wraps any fs.access failure into "Template directory not found" should only
do that for ENOENT like loadJsonFile does; update the catch after
fs.access(templateDir) to inspect the caught error (from the loadTemplate
function) and if error.code === 'ENOENT' throw the current descriptive
Error(`Template directory not found: ${templateDir}`), otherwise rethrow the
original error so permission or I/O errors from fs.access bubble up unchanged.

}

const styleGuide = await loadJsonFile<Record<string, unknown>>(
path.join(templateDir, "style-guide.json"),
"style-guide.json",
);
const captionGuide = await loadJsonFile<Record<string, unknown>>(
path.join(templateDir, "caption-guide.json"),
"caption-guide.json",
);
const captionExamples = await loadJsonFile<string[]>(
path.join(templateDir, "references", "captions", "examples.json"),
"references/captions/examples.json",
) ?? [];
const videoMoods = await loadJsonFile<string[]>(
path.join(templateDir, "video-moods.json"),
"video-moods.json",
) ?? [];
const videoMovements = await loadJsonFile<string[]>(
path.join(templateDir, "video-movements.json"),
"video-movements.json",
) ?? [];

// Discover reference images
Expand All @@ -52,16 +72,30 @@ export async function loadTemplate(templateName: string): Promise<TemplateData>
.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<string, unknown> | 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,
Expand Down Expand Up @@ -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<T>(filePath: string): Promise<T | null> {
try {
const raw = await fs.readFile(filePath, "utf-8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
40 changes: 40 additions & 0 deletions src/content/resolveTemplatesDir.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const candidates = [
path.resolve(dirname, "../content/templates"),
path.resolve(process.cwd(), "src/content/templates"),
];
Comment on lines +15 to +18
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need candidates? Shouldn't this value stay consistent?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two candidates exist because __dirname resolves differently depending on environment: in dev (tsx) it points to the source directory, but in production (esbuild bundle) it points to the build output. additionalFiles in trigger.config.ts copies templates to process.cwd()-relative paths. We need both so it works in both environments.


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(", ")}`,
);
}
Loading