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
41 changes: 31 additions & 10 deletions src/lib/preview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ export const emailList = ({
return { files: null, path: emailPath };
}

const files = createEmailComponentList(emailPath, getFiles(fullPath));
// Use the absolute folder path as the root when creating the component list so
// we can compute correct relative paths on all platforms.
const files = createEmailComponentList(fullPath, getFiles(fullPath));

if (!files.length) {
return { files: null, path: emailPath };
Expand All @@ -75,11 +77,14 @@ export const emailList = ({
return { files, path: emailPath };
};

const getEmailComponent = async (emailPath: string, file: string) => {
export const getEmailComponent = async (emailPath: string, file: string) => {
const fileName = `${file}.svelte`;
try {
// Import the email component dynamically
return (await import(/* @vite-ignore */ path.join(emailPath, fileName))).default;
const normalizedEmailPath = emailPath.replace(/\\/g, '/').replace(/\/+$/, '');
const normalizedFile = file.replace(/\\/g, '/').replace(/^\/+/, '');
const importPath = `${normalizedEmailPath}/${normalizedFile}.svelte`;
return (await import(/* @vite-ignore */ importPath)).default;
} catch (err) {
throw new Error(
`Failed to import email component '${fileName}'. Make sure the file exists and includes the <Head /> component.\nOriginal error: ${err}`
Expand Down Expand Up @@ -233,7 +238,7 @@ export const sendEmail = ({
};

// Recursive function to get files
function getFiles(dir: string, files: string[] = []) {
export function getFiles(dir: string, files: string[] = []) {
// Get an array of all files and directories in the passed directory using fs.readdirSync
const fileList = fs.readdirSync(dir);
// Create the full path of the file/directory by concatenating the passed directory and file/directory name
Expand All @@ -258,12 +263,28 @@ function createEmailComponentList(root: string, paths: string[]) {
const emailComponentList: string[] = [];

paths.forEach((filePath) => {
if (filePath.includes(`.svelte`)) {
const fileName = filePath.substring(
filePath.indexOf(root) + root.length + 1,
filePath.indexOf('.svelte')
);
emailComponentList.push(fileName);
if (filePath.endsWith('.svelte')) {
// Get the directory name from the full path
const fileDir = path.dirname(filePath);
// Get the base name without extension
const baseName = path.basename(filePath, '.svelte');

// Normalize paths for cross-platform comparison
const rootNormalized = path.normalize(root);
const fileDirNormalized = path.normalize(fileDir);

// Find where root appears in the full directory path
const rootIndex = fileDirNormalized.indexOf(rootNormalized);

if (rootIndex !== -1) {
// Get everything after the root path
const afterRoot = fileDirNormalized.substring(rootIndex + rootNormalized.length);
// Combine with the base name using path.join for proper separators
const relativePath = afterRoot ? path.join(afterRoot, baseName) : baseName;
// Remove leading path separators
const cleanPath = relativePath.replace(/^[/\\]+/, '');
emailComponentList.push(cleanPath);
}
}
});

Expand Down
300 changes: 300 additions & 0 deletions src/lib/preview/preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'fs';

// Mock modules before importing the module under test
vi.mock('fs');
// Don't mock 'path' - we want to use the real implementation for cross-platform testing
vi.mock('resend');
vi.mock('svelte/server');
vi.mock('prettier/standalone');
vi.mock('prettier/parser-html');

// Import the functions we're testing
import { emailList, getFiles, getEmailComponent } from './index.js';

describe('Preview Path Resolution - Cross-Platform', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('emailList - Unix paths (Mac/Linux)', () => {
it('should list email files with Unix-style paths', () => {
const mockRoot = '/home/user/project';
const mockEmailPath = '/src/lib/emails';

vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['Welcome.svelte', 'Reset.svelte'] as any);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.path).toBe(mockEmailPath);
expect(result.files).toHaveLength(2);
expect(result.files).toContain('Welcome');
expect(result.files).toContain('Reset');
});

it('should handle nested directories with Unix paths', () => {
const mockRoot = '/home/user/project';
const mockEmailPath = '/src/lib/emails';

vi.mocked(fs.existsSync).mockReturnValue(true);

// Mock readdirSync to return directory first, then files
vi.mocked(fs.readdirSync)
.mockReturnValueOnce(['auth'] as any)
.mockReturnValueOnce(['Login.svelte', 'Register.svelte'] as any);

// Mock statSync to indicate first is directory, rest are files
vi.mocked(fs.statSync)
.mockReturnValueOnce({ isDirectory: () => true } as any)
.mockReturnValueOnce({ isDirectory: () => false } as any)
.mockReturnValueOnce({ isDirectory: () => false } as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toHaveLength(2);
// Files should be in auth subfolder
expect(result.files?.every((file) => file.includes('auth'))).toBe(true);
});

it('should return null when directory does not exist', () => {
const mockRoot = '/home/user/project';
const mockEmailPath = '/src/lib/emails';

vi.mocked(fs.existsSync).mockReturnValue(false);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toBeNull();
expect(result.path).toBe(mockEmailPath);
});
});

describe('emailList - Windows paths', () => {
it('should list email files with Windows-style paths', () => {
const mockRoot = 'C:\\Users\\user\\project';
const mockEmailPath = '\\src\\lib\\emails';

vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['Welcome.svelte', 'Reset.svelte'] as any);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toHaveLength(2);
expect(result.files).toContain('Welcome');
expect(result.files).toContain('Reset');
});

it('should handle nested directories with Windows paths', () => {
const mockRoot = 'C:\\Users\\user\\project';
const mockEmailPath = '\\src\\lib\\emails';

vi.mocked(fs.existsSync).mockReturnValue(true);

vi.mocked(fs.readdirSync)
.mockReturnValueOnce(['auth'] as any)
.mockReturnValueOnce(['Login.svelte', 'Register.svelte'] as any);

vi.mocked(fs.statSync)
.mockReturnValueOnce({ isDirectory: () => true } as any)
.mockReturnValueOnce({ isDirectory: () => false } as any)
.mockReturnValueOnce({ isDirectory: () => false } as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toHaveLength(2);
// On Windows, paths should contain backslashes
expect(result.files?.every((file) => file.includes('auth'))).toBe(true);
});
});

describe('getEmailComponent - path normalization', () => {
it('should normalize Windows backslashes to forward slashes in import paths', async () => {
const emailPath = 'C:\\Users\\user\\project\\src\\lib\\emails';
const file = 'auth\\Welcome';

try {
await getEmailComponent(emailPath, file);
expect(false).toBe(true); // Should not reach here
} catch (error) {
// Expected to fail since component doesn't exist
expect(error).toBeDefined();
const errorMessage = (error as Error).message;
// Verify the path was normalized (should contain forward slashes)
expect(errorMessage).toContain('auth/Welcome');
}
});

it('should remove trailing slashes from emailPath', async () => {
const emailPath = '/src/lib/emails/';
const file = 'Welcome';

try {
await getEmailComponent(emailPath, file);
expect(false).toBe(true);
} catch (error) {
expect(error).toBeDefined();
// Should not have double slashes
const errorMessage = (error as Error).message;
expect(errorMessage).not.toContain('//');
}
});

it('should remove leading slashes from file parameter', async () => {
const emailPath = '/src/lib/emails';
const file = '/auth/Welcome';

try {
await getEmailComponent(emailPath, file);
expect(false).toBe(true);
} catch (error) {
expect(error).toBeDefined();
const errorMessage = (error as Error).message;
// Should normalize properly
expect(errorMessage).toContain('auth/Welcome');
}
});

it('should handle Unix-style paths', async () => {
const emailPath = '/home/user/project/src/lib/emails';
const file = 'auth/Welcome';

try {
await getEmailComponent(emailPath, file);
expect(false).toBe(true);
} catch (error) {
expect(error).toBeDefined();
}
});
});

describe('getFiles - recursive file discovery', () => {
it('should recursively get all files from directory', () => {
const mockDir = '/home/user/project/emails';

vi.mocked(fs.readdirSync)
.mockReturnValueOnce(['Welcome.svelte', 'auth'] as any)
.mockReturnValueOnce(['Login.svelte'] as any);

vi.mocked(fs.statSync)
.mockReturnValueOnce({ isDirectory: () => false } as any)
.mockReturnValueOnce({ isDirectory: () => true } as any)
.mockReturnValueOnce({ isDirectory: () => false } as any);

const files = getFiles(mockDir);

expect(files).toHaveLength(2);
expect(files.some((f) => f.includes('Welcome.svelte'))).toBe(true);
expect(files.some((f) => f.includes('Login.svelte'))).toBe(true);
});

it('should handle deeply nested directories', () => {
const mockDir = '/project/emails';

vi.mocked(fs.readdirSync)
.mockReturnValueOnce(['level1'] as any)
.mockReturnValueOnce(['level2'] as any)
.mockReturnValueOnce(['Deep.svelte'] as any);

vi.mocked(fs.statSync)
.mockReturnValueOnce({ isDirectory: () => true } as any)
.mockReturnValueOnce({ isDirectory: () => true } as any)
.mockReturnValueOnce({ isDirectory: () => false } as any);

const files = getFiles(mockDir);

expect(files).toHaveLength(1);
expect(files[0]).toContain('Deep.svelte');
});

it('should return empty array for empty directory', () => {
const mockDir = '/project/emails';
vi.mocked(fs.readdirSync).mockReturnValue([] as any);

const files = getFiles(mockDir);

expect(files).toEqual([]);
});
});

describe('emailList - process.cwd() fallback', () => {
it('should use process.cwd() when root is not provided', () => {
const mockCwd = '/current/working/directory';
const originalCwd = process.cwd;
process.cwd = vi.fn().mockReturnValue(mockCwd);

vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['Test.svelte'] as any);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);

const result = emailList();

expect(process.cwd).toHaveBeenCalled();
expect(result.files).toHaveLength(1);

process.cwd = originalCwd;
});

it('should throw error when process.cwd() fails and root is not provided', () => {
const originalCwd = process.cwd;
process.cwd = vi.fn().mockImplementation(() => {
throw new Error('process.cwd() failed');
});

expect(() => emailList()).toThrow(/Could not determine the root path/);

process.cwd = originalCwd;
});
});

describe('Edge cases - path handling', () => {
it('should handle empty directory gracefully', () => {
const mockRoot = '/home/user/project';
const mockEmailPath = '/src/lib/emails';

vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([] as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toBeNull();
});

it('should only include .svelte files', () => {
const mockRoot = '/home/user/project';
const mockEmailPath = '/src/lib/emails';

vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'Email.svelte',
'Email.ts',
'Email.js',
'README.md',
'Another.svelte'
] as any);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toHaveLength(2);
expect(result.files).toContain('Email');
expect(result.files).toContain('Another');
});

it('should handle UNC paths on Windows', () => {
const mockRoot = '\\\\server\\share\\project';
const mockEmailPath = '\\src\\lib\\emails';

vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['Test.svelte'] as any);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);

const result = emailList({ path: mockEmailPath, root: mockRoot });

expect(result.files).toHaveLength(1);
expect(result.files).toContain('Test');
});
});
});
10 changes: 9 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
expect: { requireAssertions: true },
server: {
deps: {
inline: ['@sveltejs/kit']
}
},
projects: [
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
alias: {
$app: '/node_modules/@sveltejs/kit/src/runtime/app'
}
}
}
]
Expand Down