Thanks to visit codestin.com
Credit goes to github.com

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
2 changes: 1 addition & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"@upstash/qstash": "^2.8.2",
"@vercel/functions": "^2.0.0",
"@vercel/otel": "^1.10.4",
"@vercel/sandbox": "^1.1.2",
"@vercel/sandbox": "^1.2.0",
"ai": "^4.3.17",
"bcrypt": "^5.1.1",
"chokidar-cli": "^3.0.0",
Expand Down
114 changes: 101 additions & 13 deletions apps/backend/src/lib/email-rendering.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { renderEmailsForTenancyBatched, type RenderEmailRequestForTenancy } from './email-rendering';
import { renderEmailsForTenancyBatched, renderEmailWithTemplate, type RenderEmailRequestForTenancy } from './email-rendering';

describe('renderEmailsForTenancyBatched', () => {
const createSimpleTemplateSource = (content: string) => `
Expand Down Expand Up @@ -313,7 +313,7 @@ describe('renderEmailsForTenancyBatched', () => {
});

describe('error handling', () => {
it('should return error for invalid template syntax', async () => {
it('bundling error: invalid syntax', async () => {
const request = createMockRequest(1, {
templateSource: 'invalid syntax {{{ not jsx',
});
Expand All @@ -326,9 +326,13 @@ describe('renderEmailsForTenancyBatched', () => {
}
});

it('should return error for invalid theme syntax', async () => {
it('bundling error: missing required export', async () => {
const request = createMockRequest(1, {
themeSource: 'export function EmailTheme( { unclosed bracket',
templateSource: `
export function WrongName() {
return <div>Wrong function name</div>;
}
`,
});
const result = await renderEmailsForTenancyBatched([request]);

Expand All @@ -338,36 +342,68 @@ describe('renderEmailsForTenancyBatched', () => {
}
});

it('should return error when template does not export EmailTemplate', async () => {
it('runtime error: component throws (returns JSON with message and stack)', async () => {
const request = createMockRequest(1, {
templateSource: `
export const variablesSchema = (v: any) => v;
export function WrongName() {
return <div>Wrong function name</div>;
export function EmailTemplate() {
throw new Error('Template render failed');
}
`,
});
const result = await renderEmailsForTenancyBatched([request]);

expect(result.status).toBe('error');
if (result.status === 'error') {
expect(result.error).toBeDefined();
expect(result.error).toContain('Template render failed');
// Verify error is JSON with stack trace
const parsed = JSON.parse(result.error);
expect(parsed.message).toContain('Template render failed');
expect(parsed.stack).toBeDefined();
}
});

it('should return error when theme does not export EmailTheme', async () => {
it('runtime error: arktype validation fails', async () => {
const request = createMockRequest(1, {
themeSource: `
export function WrongThemeName({ children }: any) {
return <div>{children}</div>;
templateSource: `
import { type } from "arktype";
export const variablesSchema = type({ requiredField: "string" });
export function EmailTemplate({ variables }: any) {
return <div>{variables.requiredField}</div>;
}
`,
input: {
user: { displayName: 'User 1' },
project: { displayName: 'Project 1' },
variables: { wrongField: 'value' },
},
});
const result = await renderEmailsForTenancyBatched([request]);

expect(result.status).toBe('error');
if (result.status === 'error') {
expect(result.error).toBeDefined();
expect(result.error).toContain('requiredField');
}
});

it('batch behavior: single failure fails entire batch', async () => {
const requests = [
createMockRequest(1),
createMockRequest(2, {
templateSource: `
export const variablesSchema = (v: any) => v;
export function EmailTemplate() {
throw new Error('Second template error');
}
`,
}),
createMockRequest(3),
];
const result = await renderEmailsForTenancyBatched(requests);

expect(result.status).toBe('error');
if (result.status === 'error') {
expect(result.error).toContain('Second template error');
}
});
});
Expand Down Expand Up @@ -464,3 +500,55 @@ describe('renderEmailsForTenancyBatched', () => {
}, 30000); // Extended timeout for large batch
});
});

describe('renderEmailWithTemplate', () => {
const simpleTemplate = `
export const variablesSchema = (v: any) => v;
export function EmailTemplate({ user, project }: any) {
return (
<div>
<span className="user">{user.displayName}</span>
<span className="project">{project.displayName}</span>
</div>
);
}
`;

const simpleTheme = `
export function EmailTheme({ children }: any) {
return <div className="theme">{children}</div>;
}
`;

it('preview mode: uses default user and project when not provided', async () => {
const result = await renderEmailWithTemplate(simpleTemplate, simpleTheme, {
previewMode: true,
});

expect(result.status).toBe('ok');
if (result.status === 'ok') {
expect(result.data.html).toContain('John Doe');
expect(result.data.html).toContain('My Project');
}
});

it('preview mode: merges PreviewVariables from template', async () => {
const templateWithPreviewVars = `
import { type } from "arktype";
export const variablesSchema = type({ greeting: "string" });
export function EmailTemplate({ variables }: any) {
return <div className="greeting">{variables.greeting}</div>;
}
EmailTemplate.PreviewVariables = { greeting: "Hello from preview!" };
`;

const result = await renderEmailWithTemplate(templateWithPreviewVars, simpleTheme, {
previewMode: true,
});

expect(result.status).toBe('ok');
if (result.status === 'ok') {
expect(result.data.html).toContain('Hello from preview!');
}
});
});
25 changes: 20 additions & 5 deletions apps/backend/src/lib/email-rendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,18 @@ const entryJs = deindent`
const result = await renderAll();
return { status: "ok", data: result };
} catch (e) {
return { status: "error", error: String(e) };
if (e instanceof Error) {
return { status: "error", error: { message: e.message, stack: e.stack, cause: e.cause } };
}
return { status: "error", error: { message: String(e), stack: undefined, cause: undefined } };
}
};
`;

type EmailRenderResult = { html: string, text: string, subject?: string, notificationCategory?: string };
type ExecuteResult =
| { status: "ok", data: unknown }
| { status: "error", error: string };
| { status: "error", error: unknown };

async function bundleAndExecute<T>(
files: Record<string, string> & { '/entry.js': string }
Expand All @@ -87,7 +90,7 @@ async function bundleAndExecute<T>(
if (["development", "test"].includes(getNodeEnvironment())) {
const executeResult = await executeJavascript(bundle.data, { nodeModules, engine: 'freestyle' }) as ExecuteResult;
if (executeResult.status === "error") {
return Result.error(executeResult.error);
return Result.error(JSON.stringify(executeResult.error));
}
return Result.ok(executeResult.data as T);
}
Expand All @@ -97,11 +100,23 @@ async function bundleAndExecute<T>(
if (executeResult.status === "error") {
const vercelResult = await executeJavascript(bundle.data, { nodeModules, engine: 'vercel-sandbox' }) as ExecuteResult;
if (vercelResult.status === "error") {
return Result.error(executeResult.error);
captureError("email-rendering-freestyle-and-vercel-runtime-error", new StackAssertionError(
"Email rendering failed with both freestyle and vercel-sandbox engines",
{
freestyleError: executeResult.error,
vercelError: vercelResult.error,
innerCode: bundle.data,
innerOptions: [
{ nodeModules, engine: 'freestyle' },
{ nodeModules, engine: 'vercel-sandbox' },
],
},
));
return Result.error(JSON.stringify(vercelResult.error));
}
captureError("email-rendering-freestyle-runtime-error", new StackAssertionError(
"Email rendering failed with freestyle but succeeded with vercel-sandbox",
{ freestyleError: executeResult.error }
{ freestyleError: executeResult.error, innerCode: bundle.data, innerOptions: { nodeModules, engine: 'freestyle' } }
));
return Result.ok(vercelResult.data as T);
}
Expand Down
41 changes: 18 additions & 23 deletions apps/backend/src/lib/js-execution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function createFreestyleEngine(): JsEngine {
});

if (response.result === undefined) {
throw new StackAssertionError("Freestyle execution returned undefined result", { response });
throw new StackAssertionError("Freestyle execution returned undefined result", { response, innerCode: code, innerOptions: options });
}

return response.result;
Expand Down Expand Up @@ -75,7 +75,7 @@ function createVercelSandboxEngine(): JsEngine {
const installResult = await sandbox.runCommand('npm', ['install', '--no-save', ...packages]);

if (installResult.exitCode !== 0) {
throw new StackAssertionError("Failed to install packages in Vercel Sandbox", { exitCode: installResult.exitCode });
throw new StackAssertionError("Failed to install packages in Vercel Sandbox", { exitCode: installResult.exitCode, innerCode: code, innerOptions: options });
}
}

Expand All @@ -85,7 +85,7 @@ function createVercelSandboxEngine(): JsEngine {
import { writeFileSync } from 'fs';
import fn from './code.mjs';
const result = await fn();
writeFileSync('${resultPath}', JSON.stringify(result));
writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify(result));
`;

await sandbox.writeFiles([
Expand All @@ -96,29 +96,19 @@ function createVercelSandboxEngine(): JsEngine {
const runResult = await sandbox.runCommand('node', ['/vercel/sandbox/runner.mjs']);

if (runResult.exitCode !== 0) {
throw new StackAssertionError("Vercel Sandbox execution failed", { exitCode: runResult.exitCode });
throw new StackAssertionError("Vercel Sandbox runner exited with non-zero code", { innerCode: code, innerOptions: options, exitCode: runResult.exitCode });
}

// Read the result file by catting it to stdout
let resultJson = '';
const { Writable } = await import('stream');
const stdoutStream = new Writable({
write(chunk, _encoding, callback) {
resultJson += chunk.toString();
callback();
},
});

const catResult = await sandbox.runCommand({ cmd: 'cat', args: [resultPath], stdout: stdoutStream });

if (catResult.exitCode !== 0) {
throw new StackAssertionError("Failed to read result file from Vercel Sandbox", { exitCode: catResult.exitCode });
const resultBuffer = await sandbox.readFileToBuffer({ path: resultPath });
if (resultBuffer === null) {
throw new StackAssertionError("Result file not found in Vercel Sandbox", { resultPath, innerCode: code, innerOptions: options });
}
const resultJson = resultBuffer.toString();

try {
return JSON.parse(resultJson);
} catch (e) {
throw new StackAssertionError("Failed to parse result from Vercel Sandbox", { resultJson, cause: e });
} catch (e: any) {
throw new StackAssertionError("Failed to parse result from Vercel Sandbox", { resultJson, cause: e, innerCode: code, innerOptions: options });
}
} finally {
await sandbox.stop();
Expand All @@ -134,6 +124,11 @@ const engineMap = new Map<string, JsEngine>([

const engines: JsEngine[] = Array.from(engineMap.values());

/**
* Executes the given code with the given options. Returns the result of the code execution
* if it is JSON-serializable. Has undefined behavior if it is not JSON-serializable or if
* the code throws an error.
*/
export async function executeJavascript(code: string, options: ExecuteJavascriptOptions = {}): Promise<unknown> {
return await traceSpan({
description: 'js-execution.executeJavascript',
Expand Down Expand Up @@ -178,7 +173,7 @@ async function runSanityTest(code: string, options: ExecuteJavascriptOptions) {
if (failures.length > 0) {
captureError("js-execution-sanity-test-failures", new StackAssertionError(
`JS execution sanity test: ${failures.length} engine(s) failed`,
{ failures, successfulEngines: results.map(r => r.engine) }
{ failures, successfulEngines: results.map(r => r.engine), innerCode: code, innerOptions: options }
));
}

Expand All @@ -191,7 +186,7 @@ async function runSanityTest(code: string, options: ExecuteJavascriptOptions) {
if (!allEqual) {
captureError("js-execution-sanity-test-mismatch", new StackAssertionError(
"JS execution sanity test: engines returned different results",
{ results }
{ results, innerCode: code, innerOptions: options }
));
}
}
Expand Down Expand Up @@ -228,7 +223,7 @@ async function runWithFallback(code: string, options: ExecuteJavascriptOptions):
if (i < engines.length - 1) {
captureError(`js-execution-${engine.name}-failed`, new StackAssertionError(
`JS execution engine '${engine.name}' failed, falling back to next engine`,
{ error: engineError, attempts: retryResult.attempts }
{ error: engineError, attempts: retryResult.attempts, innerCode: code, innerOptions: options }
));
}
}
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.