From 0b959084b02dadcc5c2f8db15b5db880570ae004 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 26 Feb 2026 23:00:44 +0100 Subject: [PATCH] chore: tightened instructions --- .github/copilot-instructions.md | 687 +------------------------------- CLAUDE.md | 687 +------------------------------- GEMINI.md | 687 +------------------------------- 3 files changed, 39 insertions(+), 2022 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 34e7bef..916b356 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,8 +28,6 @@ This document provides context and best practices for GitHub Copilot when workin > Tests must import and exercise the REAL implementation classes, not inline helper functions. > Mock only external dependencies (database, filesystem), never the class under test. -See the [TDD Requirements](#test-driven-development-tdd-requirements) section for detailed guidelines. - --- ## ⚠️ MANDATORY: Fix All Test Failures @@ -124,682 +122,18 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo ### Separation of Concerns 1. **Engine Classes** (`src/main/engine/`): All business logic lives here - - `PostEngine`: Blog post CRUD, file I/O, markdown handling - - `MediaEngine`: Media import, metadata management - - `SyncEngine`: Remote database synchronization - - `TaskManager`: Async task queue with progress tracking + - No UI code, no IPC code, no direct database or filesystem access + - Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces 2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer - - Handlers call engine methods, never contain business logic - - Always use `ipcMain.handle` for async operations + - Handles all IPC communication, input validation, and error handling + - Calls engine methods and forwards results/events to renderer + - No business logic or UI code here 3. **UI Components** (`src/renderer/components/`): Presentation only - - Components should be stateless where possible - - Use Zustand store for shared state - - Never call IPC directly from deeply nested components - -### File Storage Pattern - -- **Posts**: Markdown files with YAML frontmatter in `posts/` directory -- **Media**: Binary files with `.meta` JSON sidecar files in `media/` directory -- **Database**: Index/cache only, source of truth is filesystem - -## TypeScript Best Practices - -### Type Safety - -```typescript -// ✅ DO: Use explicit return types for public methods -async function getPost(id: string): Promise { - // ... -} - -// ✅ DO: Use discriminated unions for status types -type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error'; - -// ✅ DO: Use Drizzle's inferred types -import { posts, type Post, type NewPost } from './schema'; - -// ❌ DON'T: Use `any` type -function processData(data: any) { } // Bad - -// ❌ DON'T: Ignore null/undefined possibilities -const post = await getPost(id); -console.log(post.title); // Bad - post might be null -``` - -### Async/Await Patterns - -```typescript -// ✅ DO: Use try-catch with specific error handling -try { - await fileOperation(); -} catch (error) { - if (error instanceof Error && error.message.includes('ENOENT')) { - // Handle file not found - } - throw error; -} - -// ✅ DO: Use Promise.all for concurrent operations -const [posts, media] = await Promise.all([ - postEngine.getAllPosts(), - mediaEngine.getAllMedia() -]); - -// ❌ DON'T: Forget to await async operations -savePost(post); // Bad - fire and forget -``` - -### Error Handling - -```typescript -// ✅ DO: Create typed error classes -class SyncError extends Error { - constructor( - message: string, - public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT' - ) { - super(message); - this.name = 'SyncError'; - } -} - -// ✅ DO: Return result objects for operations that can fail -interface OperationResult { - success: boolean; - data?: T; - error?: string; -} -``` - -## Electron Best Practices - -### IPC Communication - -```typescript -// ✅ DO: Use contextBridge for secure IPC exposure -// preload.ts -contextBridge.exposeInMainWorld('electronAPI', { - posts: { - getAll: () => ipcRenderer.invoke('posts:getAll'), - create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data), - } -}); - -// ✅ DO: Validate all IPC inputs in main process -ipcMain.handle('posts:create', async (_event, data: unknown) => { - const validated = validateCreatePostInput(data); - return postEngine.createPost(validated); -}); - -// ❌ DON'T: Use nodeIntegration: true -// ❌ DON'T: Expose Node.js APIs directly to renderer -``` - -### Window Management - -```typescript -// ✅ DO: Check window existence before sending messages -if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('event', data); -} - -// ✅ DO: Clean up IPC handlers on window close -mainWindow.on('closed', () => { - ipcMain.removeHandler('posts:getAll'); -}); -``` - -### Process Separation - -```typescript -// Main process: File system, database, native APIs -// Renderer process: UI only, no direct fs/db access - -// ✅ DO: All file operations in main process -// src/main/engine/PostEngine.ts -await fs.writeFile(filePath, content); - -// ❌ DON'T: Import 'fs' in renderer -// src/renderer/components/Editor.tsx -import fs from 'fs'; // Bad! -``` - -## SQLite & Drizzle ORM Best Practices - -### Schema Definition - -```typescript -// ✅ DO: Use proper column types and constraints -export const posts = sqliteTable('posts', { - id: text('id').primaryKey(), - title: text('title').notNull(), - slug: text('slug').notNull().unique(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'), -}); - -// ✅ DO: Create indexes for frequently queried columns -// CREATE INDEX idx_posts_slug ON posts(slug); -// CREATE INDEX idx_posts_sync_status ON posts(sync_status); -``` - -### Query Patterns - -```typescript -import { eq, and, desc, sql } from 'drizzle-orm'; - -// ✅ DO: Use Drizzle query builder -const drafts = await db - .select() - .from(posts) - .where(eq(posts.status, 'draft')) - .orderBy(desc(posts.updatedAt)); - -// ✅ DO: Use transactions for multi-table updates -await db.transaction(async (tx) => { - await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id)); - await tx.insert(syncLog).values({ entityId: id, operation: 'update' }); -}); - -// ❌ DON'T: Write raw SQL unless absolutely necessary -// ❌ DON'T: Use string interpolation in queries -const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk! -``` - -### Connection Management - -```typescript -// ✅ DO: Use singleton pattern for database connection -let dbInstance: DatabaseConnection | null = null; - -export function getDatabase(): DatabaseConnection { - if (!dbInstance) { - dbInstance = new DatabaseConnection(); - } - return dbInstance; -} - -// ✅ DO: Close connection gracefully on app quit -app.on('before-quit', async () => { - await getDatabase().close(); -}); -``` - -## Remote Sync Best Practices (Dropbox) - -### Sync Strategy - -```typescript -// ✅ DO: Track sync status per entity -interface SyncableEntity { - syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict'; - syncedAt: number | null; - checksum: string; -} - -// ✅ DO: Use checksums to detect conflicts -const localChecksum = calculateChecksum(localContent); -const remoteChecksum = await fetchRemoteChecksum(id); -if (localChecksum !== remoteChecksum) { - // Handle conflict -} - -// ✅ DO: Implement retry logic with exponential backoff -async function syncWithRetry(maxAttempts = 3): Promise { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await sync(); - return; - } catch (error) { - if (attempt === maxAttempts) throw error; - await sleep(Math.pow(2, attempt) * 1000); - } - } -} -``` - -### Offline-First Approach - -```typescript -// ✅ DO: Always write to local first -async function savePost(post: PostData): Promise { - // 1. Save to local file system - await writePostFile(post); - - // 2. Update local database - await updateLocalDatabase(post); - - // 3. Mark for sync (don't wait for network) - await markForSync(post.id); -} - -// ✅ DO: Queue sync operations -const syncQueue: SyncOperation[] = []; - -function queueSync(operation: SyncOperation): void { - syncQueue.push(operation); - processSyncQueue(); // Non-blocking -} - -// ✅ DO: Handle network state changes -window.addEventListener('online', () => { - syncEngine.processPendingChanges(); -}); -``` - -### Conflict Resolution - -```typescript -// ✅ DO: Implement clear conflict resolution strategy -type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual'; - -interface ConflictInfo { - entityId: string; - localVersion: EntityVersion; - remoteVersion: EntityVersion; - resolution?: ConflictResolution; -} - -// ✅ DO: Allow user to resolve conflicts manually when needed -async function resolveConflict( - conflict: ConflictInfo, - resolution: ConflictResolution -): Promise { - switch (resolution) { - case 'local-wins': - await pushLocalToRemote(conflict.entityId); - break; - case 'remote-wins': - await pullRemoteToLocal(conflict.entityId); - break; - case 'manual': - await showConflictResolutionUI(conflict); - break; - } -} -``` - -### Sync Logging - -```typescript -// ✅ DO: Log all sync operations for debugging -await db.insert(syncLog).values({ - id: generateId(), - entityType: 'post', - entityId: postId, - operation: 'push', - status: 'success', - timestamp: Date.now(), -}); - -// ✅ DO: Implement sync status visibility in UI -const pendingCount = await syncEngine.getPendingChangesCount(); -statusBar.setSyncStatus({ pending: pendingCount }); -``` - -## React & State Management - -### Zustand Patterns - -```typescript -// ✅ DO: Keep store slices focused -interface AppState { - // UI state - activeView: 'posts' | 'media' | 'settings'; - sidebarVisible: boolean; - - // Data - posts: PostData[]; - media: MediaData[]; - - // Actions - setActiveView: (view: AppState['activeView']) => void; - setPosts: (posts: PostData[]) => void; -} - -// ✅ DO: Use selectors for derived state -const draftCount = useAppStore((state) => - state.posts.filter(p => p.status === 'draft').length -); - -// ❌ DON'T: Store derived data -interface BadState { - posts: PostData[]; - draftCount: number; // Bad - derived from posts -} -``` - -### Component Patterns - -```typescript -// ✅ DO: Use composition over prop drilling - - - - - - -// ✅ DO: Separate container and presentation components -// Container: handles data fetching/IPC -// Presentation: pure rendering based on props - -// ❌ DON'T: Put IPC calls in deeply nested components -const DeepComponent = () => { - // Bad - hard to test and maintain - const handleClick = async () => { - await window.electronAPI.posts.save(data); - }; -}; -``` - -## File Naming Conventions - -``` -src/ - main/ - database/ - schema.ts # Drizzle schema definitions - connection.ts # Database connection management - engine/ - PostEngine.ts # PascalCase for classes - TaskManager.ts - ipc/ - handlers.ts # camelCase for modules - renderer/ - components/ - Editor/ - Editor.tsx # PascalCase for components - Editor.css # Matching CSS file - index.ts # Barrel exports - store/ - appStore.ts # camelCase for stores - types/ - electron.d.ts # Type declarations -``` - -## Test-Driven Development (TDD) Requirements - -> **⚠️ CRITICAL: This project follows STRICT Test-Driven Development.** -> -> Writing implementation code before tests is NOT acceptable. -> Pull requests without corresponding tests will be rejected. - -**All new features and bug fixes MUST have tests written BEFORE implementation.** - -### The Golden Rule: Test Real Implementations - -```typescript -// ✅ CORRECT: Import and test the REAL class -import { PostEngine } from '../../src/main/engine/PostEngine'; - -describe('PostEngine', () => { - let postEngine: PostEngine; - - beforeEach(() => { - // Mock only external dependencies, NOT the class under test - postEngine = new PostEngine(mockDatabase, mockFileSystem); - }); - - it('should create a post with generated slug from title', async () => { - const result = await postEngine.createPost({ title: 'Hello World' }); - expect(result.slug).toBe('hello-world'); - }); -}); - -// ❌ WRONG: Testing inline helper functions instead of real implementations -describe('PostEngine', () => { - it('should generate slug', () => { - // This tests a local function, not the actual PostEngine class! - const generateSlug = (title: string) => title.toLowerCase().replace(/ /g, '-'); - expect(generateSlug('Hello World')).toBe('hello-world'); - }); -}); -``` - -### TDD Workflow (Red-Green-Refactor) - -```typescript -// 1. RED: Write a failing test first that uses the REAL implementation -import { PostEngine } from '../../src/main/engine/PostEngine'; - -describe('PostEngine.createPost', () => { - it('should create a post with generated slug from title', async () => { - const postEngine = new PostEngine(mockDb, mockFs); - const result = await postEngine.createPost({ title: 'Hello World' }); - expect(result.slug).toBe('hello-world'); - }); -}); - -// 2. GREEN: Write minimal code in PostEngine to pass the test -// 3. REFACTOR: Improve the code while keeping tests green -``` - -### Test File Structure - -``` -tests/ -├── setup.ts # Global test configuration -├── utils/ -│ ├── factories.ts # Mock data factories -│ └── index.ts # Utility exports -└── engine/ - ├── TaskManager.test.ts # TaskManager unit tests - ├── PostEngine.test.ts # PostEngine unit tests - ├── MediaEngine.test.ts # MediaEngine unit tests - └── SyncEngine.test.ts # SyncEngine unit tests -``` - -### Test Naming Conventions - -```typescript -// ✅ DO: Use descriptive test names that explain behavior -it('should generate slug from title with lowercase and hyphens', () => {}); -it('should emit taskCompleted event when task finishes', () => {}); -it('should return null when post not found', () => {}); - -// ❌ DON'T: Use vague test names -it('works', () => {}); -it('test createPost', () => {}); -``` - -### Mock Factory Patterns - -```typescript -// ✅ DO: Use factory functions with overrides -import { createMockPost, createMockMedia } from '../utils/factories'; - -const post = createMockPost({ - title: 'Custom Title', - status: 'published' -}); - -// ✅ DO: Create specialized factories for common scenarios -const draftPost = createMockPost({ status: 'draft' }); -const publishedPost = createMockPublishedPost(); -const imageMedia = createMockMedia({ mimeType: 'image/jpeg' }); -const pdfMedia = createMockPdfMedia(); -``` - -### Testing Async Code - -```typescript -// ✅ DO: Use async/await in tests -it('should save post to filesystem', async () => { - const post = createMockPost(); - await postEngine.createPost(post); - - expect(fs.writeFile).toHaveBeenCalled(); -}); - -// ✅ DO: Test event emissions -it('should emit postCreated event', async () => { - const handler = vi.fn(); - postEngine.on('postCreated', handler); - - await postEngine.createPost({ title: 'Test' }); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Test' }) - ); -}); - -// ✅ DO: Test error cases -it('should throw when file write fails', async () => { - vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC')); - - await expect(postEngine.createPost({ title: 'Test' })) - .rejects.toThrow('ENOSPC'); -}); -``` - -### Mocking Dependencies - -```typescript -// ✅ DO: Mock at module level for consistent behavior -vi.mock('../../src/main/database', () => ({ - getDatabase: vi.fn(() => mockDatabase), -})); - -vi.mock('fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - unlink: vi.fn(), -})); - -// ✅ DO: Reset mocks between tests -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); -``` - -### Test Coverage Requirements - -- **Minimum coverage**: 80% for engine classes -- **Critical paths**: 100% coverage for data mutations -- **Edge cases**: Null handling, empty arrays, error conditions - -```bash -# Run tests with coverage -npm run test:coverage - -# View coverage report -open coverage/index.html -``` - -### When to Write Tests - -| Scenario | Test Requirement | -|----------|-----------------| -| New engine method | Unit tests BEFORE implementation | -| Bug fix | Failing test that reproduces bug FIRST | -| Refactoring | Ensure existing tests pass BEFORE and AFTER | -| IPC handler | Integration test with mocked engine | -| UI component | Behavior tests for user interactions | - -### Test Commands - -```bash -npm run test # Run all tests once -npm run test:watch # Run tests in watch mode -npm run test:coverage # Generate coverage report -npm run test:ui # Open Vitest UI -``` - -### Vitest Configuration - -Tests are configured in `vitest.config.ts` with: -- Global test utilities (describe, it, expect, vi) -- Node environment for main process tests -- Coverage reports via v8 -- Custom setup file for Electron mocks - -## Testing Considerations - -```typescript -// ✅ DO: Design for testability -// - Engine classes should accept dependencies via constructor -// - Use interfaces for external dependencies -// - Keep pure business logic separate from I/O - -class PostEngine { - constructor( - private db: DatabaseConnection, - private fs: FileSystemAdapter, // Mockable - private eventEmitter: EventEmitter - ) {} -} - -// ✅ DO: Use factory functions for test data -function createTestPost(overrides?: Partial): PostData { - return { - id: 'test-id', - title: 'Test Post', - status: 'draft', - ...overrides, - }; -} - -// ✅ DO: Test pure functions in isolation -describe('generateSlug', () => { - it('should convert to lowercase', () => { - expect(generateSlug('Hello World')).toBe('hello-world'); - }); - - it('should replace special characters', () => { - expect(generateSlug('Hello, World!')).toBe('hello-world'); - }); -}); -``` - -## Common Patterns in This Codebase - -### Creating a New Engine Method - -```typescript -// 1. Add method to engine class -async newOperation(input: InputType): Promise { - // Validate input - // Perform operation - // Emit event for UI updates - this.emit('operationCompleted', result); - return result; -} - -// 2. Add IPC handler -ipcMain.handle('entity:operation', async (_event, input) => { - return engine.newOperation(input); -}); - -// 3. Expose in preload -entity: { - operation: (input) => ipcRenderer.invoke('entity:operation', input), -} - -// 4. Update store and UI -``` - -### Adding a New Database Table - -```typescript -// 1. Define in schema.ts with proper types -export const newTable = sqliteTable('new_table', { - id: text('id').primaryKey(), - // ... columns -}); - -// 2. Export types -export type NewTableRow = typeof newTable.$inferSelect; -export type NewTableInsert = typeof newTable.$inferInsert; - -// 3. Add CREATE TABLE to migrations in connection.ts -// 4. Create corresponding engine class -``` + - Components should be stateless where possible + - Use Zustand store for shared state + - Never call IPC directly from deeply nested components ## Security Reminders @@ -808,3 +142,8 @@ export type NewTableInsert = typeof newTable.$inferInsert; - Use `contextIsolation: true` and `sandbox: false` only when necessary - Store Dropbox auth tokens in secure storage, not in code - Sanitize user input before rendering (XSS prevention) + +## Plan Mode + +- Make the plan extremely concise. Sacrifice grammar for the sake of concision. +- At the end of each plan, give me a list of unresolved questions to answer, if any. diff --git a/CLAUDE.md b/CLAUDE.md index 34e7bef..916b356 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,6 @@ This document provides context and best practices for GitHub Copilot when workin > Tests must import and exercise the REAL implementation classes, not inline helper functions. > Mock only external dependencies (database, filesystem), never the class under test. -See the [TDD Requirements](#test-driven-development-tdd-requirements) section for detailed guidelines. - --- ## ⚠️ MANDATORY: Fix All Test Failures @@ -124,682 +122,18 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo ### Separation of Concerns 1. **Engine Classes** (`src/main/engine/`): All business logic lives here - - `PostEngine`: Blog post CRUD, file I/O, markdown handling - - `MediaEngine`: Media import, metadata management - - `SyncEngine`: Remote database synchronization - - `TaskManager`: Async task queue with progress tracking + - No UI code, no IPC code, no direct database or filesystem access + - Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces 2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer - - Handlers call engine methods, never contain business logic - - Always use `ipcMain.handle` for async operations + - Handles all IPC communication, input validation, and error handling + - Calls engine methods and forwards results/events to renderer + - No business logic or UI code here 3. **UI Components** (`src/renderer/components/`): Presentation only - - Components should be stateless where possible - - Use Zustand store for shared state - - Never call IPC directly from deeply nested components - -### File Storage Pattern - -- **Posts**: Markdown files with YAML frontmatter in `posts/` directory -- **Media**: Binary files with `.meta` JSON sidecar files in `media/` directory -- **Database**: Index/cache only, source of truth is filesystem - -## TypeScript Best Practices - -### Type Safety - -```typescript -// ✅ DO: Use explicit return types for public methods -async function getPost(id: string): Promise { - // ... -} - -// ✅ DO: Use discriminated unions for status types -type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error'; - -// ✅ DO: Use Drizzle's inferred types -import { posts, type Post, type NewPost } from './schema'; - -// ❌ DON'T: Use `any` type -function processData(data: any) { } // Bad - -// ❌ DON'T: Ignore null/undefined possibilities -const post = await getPost(id); -console.log(post.title); // Bad - post might be null -``` - -### Async/Await Patterns - -```typescript -// ✅ DO: Use try-catch with specific error handling -try { - await fileOperation(); -} catch (error) { - if (error instanceof Error && error.message.includes('ENOENT')) { - // Handle file not found - } - throw error; -} - -// ✅ DO: Use Promise.all for concurrent operations -const [posts, media] = await Promise.all([ - postEngine.getAllPosts(), - mediaEngine.getAllMedia() -]); - -// ❌ DON'T: Forget to await async operations -savePost(post); // Bad - fire and forget -``` - -### Error Handling - -```typescript -// ✅ DO: Create typed error classes -class SyncError extends Error { - constructor( - message: string, - public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT' - ) { - super(message); - this.name = 'SyncError'; - } -} - -// ✅ DO: Return result objects for operations that can fail -interface OperationResult { - success: boolean; - data?: T; - error?: string; -} -``` - -## Electron Best Practices - -### IPC Communication - -```typescript -// ✅ DO: Use contextBridge for secure IPC exposure -// preload.ts -contextBridge.exposeInMainWorld('electronAPI', { - posts: { - getAll: () => ipcRenderer.invoke('posts:getAll'), - create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data), - } -}); - -// ✅ DO: Validate all IPC inputs in main process -ipcMain.handle('posts:create', async (_event, data: unknown) => { - const validated = validateCreatePostInput(data); - return postEngine.createPost(validated); -}); - -// ❌ DON'T: Use nodeIntegration: true -// ❌ DON'T: Expose Node.js APIs directly to renderer -``` - -### Window Management - -```typescript -// ✅ DO: Check window existence before sending messages -if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('event', data); -} - -// ✅ DO: Clean up IPC handlers on window close -mainWindow.on('closed', () => { - ipcMain.removeHandler('posts:getAll'); -}); -``` - -### Process Separation - -```typescript -// Main process: File system, database, native APIs -// Renderer process: UI only, no direct fs/db access - -// ✅ DO: All file operations in main process -// src/main/engine/PostEngine.ts -await fs.writeFile(filePath, content); - -// ❌ DON'T: Import 'fs' in renderer -// src/renderer/components/Editor.tsx -import fs from 'fs'; // Bad! -``` - -## SQLite & Drizzle ORM Best Practices - -### Schema Definition - -```typescript -// ✅ DO: Use proper column types and constraints -export const posts = sqliteTable('posts', { - id: text('id').primaryKey(), - title: text('title').notNull(), - slug: text('slug').notNull().unique(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'), -}); - -// ✅ DO: Create indexes for frequently queried columns -// CREATE INDEX idx_posts_slug ON posts(slug); -// CREATE INDEX idx_posts_sync_status ON posts(sync_status); -``` - -### Query Patterns - -```typescript -import { eq, and, desc, sql } from 'drizzle-orm'; - -// ✅ DO: Use Drizzle query builder -const drafts = await db - .select() - .from(posts) - .where(eq(posts.status, 'draft')) - .orderBy(desc(posts.updatedAt)); - -// ✅ DO: Use transactions for multi-table updates -await db.transaction(async (tx) => { - await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id)); - await tx.insert(syncLog).values({ entityId: id, operation: 'update' }); -}); - -// ❌ DON'T: Write raw SQL unless absolutely necessary -// ❌ DON'T: Use string interpolation in queries -const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk! -``` - -### Connection Management - -```typescript -// ✅ DO: Use singleton pattern for database connection -let dbInstance: DatabaseConnection | null = null; - -export function getDatabase(): DatabaseConnection { - if (!dbInstance) { - dbInstance = new DatabaseConnection(); - } - return dbInstance; -} - -// ✅ DO: Close connection gracefully on app quit -app.on('before-quit', async () => { - await getDatabase().close(); -}); -``` - -## Remote Sync Best Practices (Dropbox) - -### Sync Strategy - -```typescript -// ✅ DO: Track sync status per entity -interface SyncableEntity { - syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict'; - syncedAt: number | null; - checksum: string; -} - -// ✅ DO: Use checksums to detect conflicts -const localChecksum = calculateChecksum(localContent); -const remoteChecksum = await fetchRemoteChecksum(id); -if (localChecksum !== remoteChecksum) { - // Handle conflict -} - -// ✅ DO: Implement retry logic with exponential backoff -async function syncWithRetry(maxAttempts = 3): Promise { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await sync(); - return; - } catch (error) { - if (attempt === maxAttempts) throw error; - await sleep(Math.pow(2, attempt) * 1000); - } - } -} -``` - -### Offline-First Approach - -```typescript -// ✅ DO: Always write to local first -async function savePost(post: PostData): Promise { - // 1. Save to local file system - await writePostFile(post); - - // 2. Update local database - await updateLocalDatabase(post); - - // 3. Mark for sync (don't wait for network) - await markForSync(post.id); -} - -// ✅ DO: Queue sync operations -const syncQueue: SyncOperation[] = []; - -function queueSync(operation: SyncOperation): void { - syncQueue.push(operation); - processSyncQueue(); // Non-blocking -} - -// ✅ DO: Handle network state changes -window.addEventListener('online', () => { - syncEngine.processPendingChanges(); -}); -``` - -### Conflict Resolution - -```typescript -// ✅ DO: Implement clear conflict resolution strategy -type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual'; - -interface ConflictInfo { - entityId: string; - localVersion: EntityVersion; - remoteVersion: EntityVersion; - resolution?: ConflictResolution; -} - -// ✅ DO: Allow user to resolve conflicts manually when needed -async function resolveConflict( - conflict: ConflictInfo, - resolution: ConflictResolution -): Promise { - switch (resolution) { - case 'local-wins': - await pushLocalToRemote(conflict.entityId); - break; - case 'remote-wins': - await pullRemoteToLocal(conflict.entityId); - break; - case 'manual': - await showConflictResolutionUI(conflict); - break; - } -} -``` - -### Sync Logging - -```typescript -// ✅ DO: Log all sync operations for debugging -await db.insert(syncLog).values({ - id: generateId(), - entityType: 'post', - entityId: postId, - operation: 'push', - status: 'success', - timestamp: Date.now(), -}); - -// ✅ DO: Implement sync status visibility in UI -const pendingCount = await syncEngine.getPendingChangesCount(); -statusBar.setSyncStatus({ pending: pendingCount }); -``` - -## React & State Management - -### Zustand Patterns - -```typescript -// ✅ DO: Keep store slices focused -interface AppState { - // UI state - activeView: 'posts' | 'media' | 'settings'; - sidebarVisible: boolean; - - // Data - posts: PostData[]; - media: MediaData[]; - - // Actions - setActiveView: (view: AppState['activeView']) => void; - setPosts: (posts: PostData[]) => void; -} - -// ✅ DO: Use selectors for derived state -const draftCount = useAppStore((state) => - state.posts.filter(p => p.status === 'draft').length -); - -// ❌ DON'T: Store derived data -interface BadState { - posts: PostData[]; - draftCount: number; // Bad - derived from posts -} -``` - -### Component Patterns - -```typescript -// ✅ DO: Use composition over prop drilling - - - - - - -// ✅ DO: Separate container and presentation components -// Container: handles data fetching/IPC -// Presentation: pure rendering based on props - -// ❌ DON'T: Put IPC calls in deeply nested components -const DeepComponent = () => { - // Bad - hard to test and maintain - const handleClick = async () => { - await window.electronAPI.posts.save(data); - }; -}; -``` - -## File Naming Conventions - -``` -src/ - main/ - database/ - schema.ts # Drizzle schema definitions - connection.ts # Database connection management - engine/ - PostEngine.ts # PascalCase for classes - TaskManager.ts - ipc/ - handlers.ts # camelCase for modules - renderer/ - components/ - Editor/ - Editor.tsx # PascalCase for components - Editor.css # Matching CSS file - index.ts # Barrel exports - store/ - appStore.ts # camelCase for stores - types/ - electron.d.ts # Type declarations -``` - -## Test-Driven Development (TDD) Requirements - -> **⚠️ CRITICAL: This project follows STRICT Test-Driven Development.** -> -> Writing implementation code before tests is NOT acceptable. -> Pull requests without corresponding tests will be rejected. - -**All new features and bug fixes MUST have tests written BEFORE implementation.** - -### The Golden Rule: Test Real Implementations - -```typescript -// ✅ CORRECT: Import and test the REAL class -import { PostEngine } from '../../src/main/engine/PostEngine'; - -describe('PostEngine', () => { - let postEngine: PostEngine; - - beforeEach(() => { - // Mock only external dependencies, NOT the class under test - postEngine = new PostEngine(mockDatabase, mockFileSystem); - }); - - it('should create a post with generated slug from title', async () => { - const result = await postEngine.createPost({ title: 'Hello World' }); - expect(result.slug).toBe('hello-world'); - }); -}); - -// ❌ WRONG: Testing inline helper functions instead of real implementations -describe('PostEngine', () => { - it('should generate slug', () => { - // This tests a local function, not the actual PostEngine class! - const generateSlug = (title: string) => title.toLowerCase().replace(/ /g, '-'); - expect(generateSlug('Hello World')).toBe('hello-world'); - }); -}); -``` - -### TDD Workflow (Red-Green-Refactor) - -```typescript -// 1. RED: Write a failing test first that uses the REAL implementation -import { PostEngine } from '../../src/main/engine/PostEngine'; - -describe('PostEngine.createPost', () => { - it('should create a post with generated slug from title', async () => { - const postEngine = new PostEngine(mockDb, mockFs); - const result = await postEngine.createPost({ title: 'Hello World' }); - expect(result.slug).toBe('hello-world'); - }); -}); - -// 2. GREEN: Write minimal code in PostEngine to pass the test -// 3. REFACTOR: Improve the code while keeping tests green -``` - -### Test File Structure - -``` -tests/ -├── setup.ts # Global test configuration -├── utils/ -│ ├── factories.ts # Mock data factories -│ └── index.ts # Utility exports -└── engine/ - ├── TaskManager.test.ts # TaskManager unit tests - ├── PostEngine.test.ts # PostEngine unit tests - ├── MediaEngine.test.ts # MediaEngine unit tests - └── SyncEngine.test.ts # SyncEngine unit tests -``` - -### Test Naming Conventions - -```typescript -// ✅ DO: Use descriptive test names that explain behavior -it('should generate slug from title with lowercase and hyphens', () => {}); -it('should emit taskCompleted event when task finishes', () => {}); -it('should return null when post not found', () => {}); - -// ❌ DON'T: Use vague test names -it('works', () => {}); -it('test createPost', () => {}); -``` - -### Mock Factory Patterns - -```typescript -// ✅ DO: Use factory functions with overrides -import { createMockPost, createMockMedia } from '../utils/factories'; - -const post = createMockPost({ - title: 'Custom Title', - status: 'published' -}); - -// ✅ DO: Create specialized factories for common scenarios -const draftPost = createMockPost({ status: 'draft' }); -const publishedPost = createMockPublishedPost(); -const imageMedia = createMockMedia({ mimeType: 'image/jpeg' }); -const pdfMedia = createMockPdfMedia(); -``` - -### Testing Async Code - -```typescript -// ✅ DO: Use async/await in tests -it('should save post to filesystem', async () => { - const post = createMockPost(); - await postEngine.createPost(post); - - expect(fs.writeFile).toHaveBeenCalled(); -}); - -// ✅ DO: Test event emissions -it('should emit postCreated event', async () => { - const handler = vi.fn(); - postEngine.on('postCreated', handler); - - await postEngine.createPost({ title: 'Test' }); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Test' }) - ); -}); - -// ✅ DO: Test error cases -it('should throw when file write fails', async () => { - vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC')); - - await expect(postEngine.createPost({ title: 'Test' })) - .rejects.toThrow('ENOSPC'); -}); -``` - -### Mocking Dependencies - -```typescript -// ✅ DO: Mock at module level for consistent behavior -vi.mock('../../src/main/database', () => ({ - getDatabase: vi.fn(() => mockDatabase), -})); - -vi.mock('fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - unlink: vi.fn(), -})); - -// ✅ DO: Reset mocks between tests -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); -``` - -### Test Coverage Requirements - -- **Minimum coverage**: 80% for engine classes -- **Critical paths**: 100% coverage for data mutations -- **Edge cases**: Null handling, empty arrays, error conditions - -```bash -# Run tests with coverage -npm run test:coverage - -# View coverage report -open coverage/index.html -``` - -### When to Write Tests - -| Scenario | Test Requirement | -|----------|-----------------| -| New engine method | Unit tests BEFORE implementation | -| Bug fix | Failing test that reproduces bug FIRST | -| Refactoring | Ensure existing tests pass BEFORE and AFTER | -| IPC handler | Integration test with mocked engine | -| UI component | Behavior tests for user interactions | - -### Test Commands - -```bash -npm run test # Run all tests once -npm run test:watch # Run tests in watch mode -npm run test:coverage # Generate coverage report -npm run test:ui # Open Vitest UI -``` - -### Vitest Configuration - -Tests are configured in `vitest.config.ts` with: -- Global test utilities (describe, it, expect, vi) -- Node environment for main process tests -- Coverage reports via v8 -- Custom setup file for Electron mocks - -## Testing Considerations - -```typescript -// ✅ DO: Design for testability -// - Engine classes should accept dependencies via constructor -// - Use interfaces for external dependencies -// - Keep pure business logic separate from I/O - -class PostEngine { - constructor( - private db: DatabaseConnection, - private fs: FileSystemAdapter, // Mockable - private eventEmitter: EventEmitter - ) {} -} - -// ✅ DO: Use factory functions for test data -function createTestPost(overrides?: Partial): PostData { - return { - id: 'test-id', - title: 'Test Post', - status: 'draft', - ...overrides, - }; -} - -// ✅ DO: Test pure functions in isolation -describe('generateSlug', () => { - it('should convert to lowercase', () => { - expect(generateSlug('Hello World')).toBe('hello-world'); - }); - - it('should replace special characters', () => { - expect(generateSlug('Hello, World!')).toBe('hello-world'); - }); -}); -``` - -## Common Patterns in This Codebase - -### Creating a New Engine Method - -```typescript -// 1. Add method to engine class -async newOperation(input: InputType): Promise { - // Validate input - // Perform operation - // Emit event for UI updates - this.emit('operationCompleted', result); - return result; -} - -// 2. Add IPC handler -ipcMain.handle('entity:operation', async (_event, input) => { - return engine.newOperation(input); -}); - -// 3. Expose in preload -entity: { - operation: (input) => ipcRenderer.invoke('entity:operation', input), -} - -// 4. Update store and UI -``` - -### Adding a New Database Table - -```typescript -// 1. Define in schema.ts with proper types -export const newTable = sqliteTable('new_table', { - id: text('id').primaryKey(), - // ... columns -}); - -// 2. Export types -export type NewTableRow = typeof newTable.$inferSelect; -export type NewTableInsert = typeof newTable.$inferInsert; - -// 3. Add CREATE TABLE to migrations in connection.ts -// 4. Create corresponding engine class -``` + - Components should be stateless where possible + - Use Zustand store for shared state + - Never call IPC directly from deeply nested components ## Security Reminders @@ -808,3 +142,8 @@ export type NewTableInsert = typeof newTable.$inferInsert; - Use `contextIsolation: true` and `sandbox: false` only when necessary - Store Dropbox auth tokens in secure storage, not in code - Sanitize user input before rendering (XSS prevention) + +## Plan Mode + +- Make the plan extremely concise. Sacrifice grammar for the sake of concision. +- At the end of each plan, give me a list of unresolved questions to answer, if any. diff --git a/GEMINI.md b/GEMINI.md index 34e7bef..916b356 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -28,8 +28,6 @@ This document provides context and best practices for GitHub Copilot when workin > Tests must import and exercise the REAL implementation classes, not inline helper functions. > Mock only external dependencies (database, filesystem), never the class under test. -See the [TDD Requirements](#test-driven-development-tdd-requirements) section for detailed guidelines. - --- ## ⚠️ MANDATORY: Fix All Test Failures @@ -124,682 +122,18 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo ### Separation of Concerns 1. **Engine Classes** (`src/main/engine/`): All business logic lives here - - `PostEngine`: Blog post CRUD, file I/O, markdown handling - - `MediaEngine`: Media import, metadata management - - `SyncEngine`: Remote database synchronization - - `TaskManager`: Async task queue with progress tracking + - No UI code, no IPC code, no direct database or filesystem access + - Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces 2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer - - Handlers call engine methods, never contain business logic - - Always use `ipcMain.handle` for async operations + - Handles all IPC communication, input validation, and error handling + - Calls engine methods and forwards results/events to renderer + - No business logic or UI code here 3. **UI Components** (`src/renderer/components/`): Presentation only - - Components should be stateless where possible - - Use Zustand store for shared state - - Never call IPC directly from deeply nested components - -### File Storage Pattern - -- **Posts**: Markdown files with YAML frontmatter in `posts/` directory -- **Media**: Binary files with `.meta` JSON sidecar files in `media/` directory -- **Database**: Index/cache only, source of truth is filesystem - -## TypeScript Best Practices - -### Type Safety - -```typescript -// ✅ DO: Use explicit return types for public methods -async function getPost(id: string): Promise { - // ... -} - -// ✅ DO: Use discriminated unions for status types -type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error'; - -// ✅ DO: Use Drizzle's inferred types -import { posts, type Post, type NewPost } from './schema'; - -// ❌ DON'T: Use `any` type -function processData(data: any) { } // Bad - -// ❌ DON'T: Ignore null/undefined possibilities -const post = await getPost(id); -console.log(post.title); // Bad - post might be null -``` - -### Async/Await Patterns - -```typescript -// ✅ DO: Use try-catch with specific error handling -try { - await fileOperation(); -} catch (error) { - if (error instanceof Error && error.message.includes('ENOENT')) { - // Handle file not found - } - throw error; -} - -// ✅ DO: Use Promise.all for concurrent operations -const [posts, media] = await Promise.all([ - postEngine.getAllPosts(), - mediaEngine.getAllMedia() -]); - -// ❌ DON'T: Forget to await async operations -savePost(post); // Bad - fire and forget -``` - -### Error Handling - -```typescript -// ✅ DO: Create typed error classes -class SyncError extends Error { - constructor( - message: string, - public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT' - ) { - super(message); - this.name = 'SyncError'; - } -} - -// ✅ DO: Return result objects for operations that can fail -interface OperationResult { - success: boolean; - data?: T; - error?: string; -} -``` - -## Electron Best Practices - -### IPC Communication - -```typescript -// ✅ DO: Use contextBridge for secure IPC exposure -// preload.ts -contextBridge.exposeInMainWorld('electronAPI', { - posts: { - getAll: () => ipcRenderer.invoke('posts:getAll'), - create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data), - } -}); - -// ✅ DO: Validate all IPC inputs in main process -ipcMain.handle('posts:create', async (_event, data: unknown) => { - const validated = validateCreatePostInput(data); - return postEngine.createPost(validated); -}); - -// ❌ DON'T: Use nodeIntegration: true -// ❌ DON'T: Expose Node.js APIs directly to renderer -``` - -### Window Management - -```typescript -// ✅ DO: Check window existence before sending messages -if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('event', data); -} - -// ✅ DO: Clean up IPC handlers on window close -mainWindow.on('closed', () => { - ipcMain.removeHandler('posts:getAll'); -}); -``` - -### Process Separation - -```typescript -// Main process: File system, database, native APIs -// Renderer process: UI only, no direct fs/db access - -// ✅ DO: All file operations in main process -// src/main/engine/PostEngine.ts -await fs.writeFile(filePath, content); - -// ❌ DON'T: Import 'fs' in renderer -// src/renderer/components/Editor.tsx -import fs from 'fs'; // Bad! -``` - -## SQLite & Drizzle ORM Best Practices - -### Schema Definition - -```typescript -// ✅ DO: Use proper column types and constraints -export const posts = sqliteTable('posts', { - id: text('id').primaryKey(), - title: text('title').notNull(), - slug: text('slug').notNull().unique(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'), -}); - -// ✅ DO: Create indexes for frequently queried columns -// CREATE INDEX idx_posts_slug ON posts(slug); -// CREATE INDEX idx_posts_sync_status ON posts(sync_status); -``` - -### Query Patterns - -```typescript -import { eq, and, desc, sql } from 'drizzle-orm'; - -// ✅ DO: Use Drizzle query builder -const drafts = await db - .select() - .from(posts) - .where(eq(posts.status, 'draft')) - .orderBy(desc(posts.updatedAt)); - -// ✅ DO: Use transactions for multi-table updates -await db.transaction(async (tx) => { - await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id)); - await tx.insert(syncLog).values({ entityId: id, operation: 'update' }); -}); - -// ❌ DON'T: Write raw SQL unless absolutely necessary -// ❌ DON'T: Use string interpolation in queries -const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk! -``` - -### Connection Management - -```typescript -// ✅ DO: Use singleton pattern for database connection -let dbInstance: DatabaseConnection | null = null; - -export function getDatabase(): DatabaseConnection { - if (!dbInstance) { - dbInstance = new DatabaseConnection(); - } - return dbInstance; -} - -// ✅ DO: Close connection gracefully on app quit -app.on('before-quit', async () => { - await getDatabase().close(); -}); -``` - -## Remote Sync Best Practices (Dropbox) - -### Sync Strategy - -```typescript -// ✅ DO: Track sync status per entity -interface SyncableEntity { - syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict'; - syncedAt: number | null; - checksum: string; -} - -// ✅ DO: Use checksums to detect conflicts -const localChecksum = calculateChecksum(localContent); -const remoteChecksum = await fetchRemoteChecksum(id); -if (localChecksum !== remoteChecksum) { - // Handle conflict -} - -// ✅ DO: Implement retry logic with exponential backoff -async function syncWithRetry(maxAttempts = 3): Promise { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await sync(); - return; - } catch (error) { - if (attempt === maxAttempts) throw error; - await sleep(Math.pow(2, attempt) * 1000); - } - } -} -``` - -### Offline-First Approach - -```typescript -// ✅ DO: Always write to local first -async function savePost(post: PostData): Promise { - // 1. Save to local file system - await writePostFile(post); - - // 2. Update local database - await updateLocalDatabase(post); - - // 3. Mark for sync (don't wait for network) - await markForSync(post.id); -} - -// ✅ DO: Queue sync operations -const syncQueue: SyncOperation[] = []; - -function queueSync(operation: SyncOperation): void { - syncQueue.push(operation); - processSyncQueue(); // Non-blocking -} - -// ✅ DO: Handle network state changes -window.addEventListener('online', () => { - syncEngine.processPendingChanges(); -}); -``` - -### Conflict Resolution - -```typescript -// ✅ DO: Implement clear conflict resolution strategy -type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual'; - -interface ConflictInfo { - entityId: string; - localVersion: EntityVersion; - remoteVersion: EntityVersion; - resolution?: ConflictResolution; -} - -// ✅ DO: Allow user to resolve conflicts manually when needed -async function resolveConflict( - conflict: ConflictInfo, - resolution: ConflictResolution -): Promise { - switch (resolution) { - case 'local-wins': - await pushLocalToRemote(conflict.entityId); - break; - case 'remote-wins': - await pullRemoteToLocal(conflict.entityId); - break; - case 'manual': - await showConflictResolutionUI(conflict); - break; - } -} -``` - -### Sync Logging - -```typescript -// ✅ DO: Log all sync operations for debugging -await db.insert(syncLog).values({ - id: generateId(), - entityType: 'post', - entityId: postId, - operation: 'push', - status: 'success', - timestamp: Date.now(), -}); - -// ✅ DO: Implement sync status visibility in UI -const pendingCount = await syncEngine.getPendingChangesCount(); -statusBar.setSyncStatus({ pending: pendingCount }); -``` - -## React & State Management - -### Zustand Patterns - -```typescript -// ✅ DO: Keep store slices focused -interface AppState { - // UI state - activeView: 'posts' | 'media' | 'settings'; - sidebarVisible: boolean; - - // Data - posts: PostData[]; - media: MediaData[]; - - // Actions - setActiveView: (view: AppState['activeView']) => void; - setPosts: (posts: PostData[]) => void; -} - -// ✅ DO: Use selectors for derived state -const draftCount = useAppStore((state) => - state.posts.filter(p => p.status === 'draft').length -); - -// ❌ DON'T: Store derived data -interface BadState { - posts: PostData[]; - draftCount: number; // Bad - derived from posts -} -``` - -### Component Patterns - -```typescript -// ✅ DO: Use composition over prop drilling - - - - - - -// ✅ DO: Separate container and presentation components -// Container: handles data fetching/IPC -// Presentation: pure rendering based on props - -// ❌ DON'T: Put IPC calls in deeply nested components -const DeepComponent = () => { - // Bad - hard to test and maintain - const handleClick = async () => { - await window.electronAPI.posts.save(data); - }; -}; -``` - -## File Naming Conventions - -``` -src/ - main/ - database/ - schema.ts # Drizzle schema definitions - connection.ts # Database connection management - engine/ - PostEngine.ts # PascalCase for classes - TaskManager.ts - ipc/ - handlers.ts # camelCase for modules - renderer/ - components/ - Editor/ - Editor.tsx # PascalCase for components - Editor.css # Matching CSS file - index.ts # Barrel exports - store/ - appStore.ts # camelCase for stores - types/ - electron.d.ts # Type declarations -``` - -## Test-Driven Development (TDD) Requirements - -> **⚠️ CRITICAL: This project follows STRICT Test-Driven Development.** -> -> Writing implementation code before tests is NOT acceptable. -> Pull requests without corresponding tests will be rejected. - -**All new features and bug fixes MUST have tests written BEFORE implementation.** - -### The Golden Rule: Test Real Implementations - -```typescript -// ✅ CORRECT: Import and test the REAL class -import { PostEngine } from '../../src/main/engine/PostEngine'; - -describe('PostEngine', () => { - let postEngine: PostEngine; - - beforeEach(() => { - // Mock only external dependencies, NOT the class under test - postEngine = new PostEngine(mockDatabase, mockFileSystem); - }); - - it('should create a post with generated slug from title', async () => { - const result = await postEngine.createPost({ title: 'Hello World' }); - expect(result.slug).toBe('hello-world'); - }); -}); - -// ❌ WRONG: Testing inline helper functions instead of real implementations -describe('PostEngine', () => { - it('should generate slug', () => { - // This tests a local function, not the actual PostEngine class! - const generateSlug = (title: string) => title.toLowerCase().replace(/ /g, '-'); - expect(generateSlug('Hello World')).toBe('hello-world'); - }); -}); -``` - -### TDD Workflow (Red-Green-Refactor) - -```typescript -// 1. RED: Write a failing test first that uses the REAL implementation -import { PostEngine } from '../../src/main/engine/PostEngine'; - -describe('PostEngine.createPost', () => { - it('should create a post with generated slug from title', async () => { - const postEngine = new PostEngine(mockDb, mockFs); - const result = await postEngine.createPost({ title: 'Hello World' }); - expect(result.slug).toBe('hello-world'); - }); -}); - -// 2. GREEN: Write minimal code in PostEngine to pass the test -// 3. REFACTOR: Improve the code while keeping tests green -``` - -### Test File Structure - -``` -tests/ -├── setup.ts # Global test configuration -├── utils/ -│ ├── factories.ts # Mock data factories -│ └── index.ts # Utility exports -└── engine/ - ├── TaskManager.test.ts # TaskManager unit tests - ├── PostEngine.test.ts # PostEngine unit tests - ├── MediaEngine.test.ts # MediaEngine unit tests - └── SyncEngine.test.ts # SyncEngine unit tests -``` - -### Test Naming Conventions - -```typescript -// ✅ DO: Use descriptive test names that explain behavior -it('should generate slug from title with lowercase and hyphens', () => {}); -it('should emit taskCompleted event when task finishes', () => {}); -it('should return null when post not found', () => {}); - -// ❌ DON'T: Use vague test names -it('works', () => {}); -it('test createPost', () => {}); -``` - -### Mock Factory Patterns - -```typescript -// ✅ DO: Use factory functions with overrides -import { createMockPost, createMockMedia } from '../utils/factories'; - -const post = createMockPost({ - title: 'Custom Title', - status: 'published' -}); - -// ✅ DO: Create specialized factories for common scenarios -const draftPost = createMockPost({ status: 'draft' }); -const publishedPost = createMockPublishedPost(); -const imageMedia = createMockMedia({ mimeType: 'image/jpeg' }); -const pdfMedia = createMockPdfMedia(); -``` - -### Testing Async Code - -```typescript -// ✅ DO: Use async/await in tests -it('should save post to filesystem', async () => { - const post = createMockPost(); - await postEngine.createPost(post); - - expect(fs.writeFile).toHaveBeenCalled(); -}); - -// ✅ DO: Test event emissions -it('should emit postCreated event', async () => { - const handler = vi.fn(); - postEngine.on('postCreated', handler); - - await postEngine.createPost({ title: 'Test' }); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Test' }) - ); -}); - -// ✅ DO: Test error cases -it('should throw when file write fails', async () => { - vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC')); - - await expect(postEngine.createPost({ title: 'Test' })) - .rejects.toThrow('ENOSPC'); -}); -``` - -### Mocking Dependencies - -```typescript -// ✅ DO: Mock at module level for consistent behavior -vi.mock('../../src/main/database', () => ({ - getDatabase: vi.fn(() => mockDatabase), -})); - -vi.mock('fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - unlink: vi.fn(), -})); - -// ✅ DO: Reset mocks between tests -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); -``` - -### Test Coverage Requirements - -- **Minimum coverage**: 80% for engine classes -- **Critical paths**: 100% coverage for data mutations -- **Edge cases**: Null handling, empty arrays, error conditions - -```bash -# Run tests with coverage -npm run test:coverage - -# View coverage report -open coverage/index.html -``` - -### When to Write Tests - -| Scenario | Test Requirement | -|----------|-----------------| -| New engine method | Unit tests BEFORE implementation | -| Bug fix | Failing test that reproduces bug FIRST | -| Refactoring | Ensure existing tests pass BEFORE and AFTER | -| IPC handler | Integration test with mocked engine | -| UI component | Behavior tests for user interactions | - -### Test Commands - -```bash -npm run test # Run all tests once -npm run test:watch # Run tests in watch mode -npm run test:coverage # Generate coverage report -npm run test:ui # Open Vitest UI -``` - -### Vitest Configuration - -Tests are configured in `vitest.config.ts` with: -- Global test utilities (describe, it, expect, vi) -- Node environment for main process tests -- Coverage reports via v8 -- Custom setup file for Electron mocks - -## Testing Considerations - -```typescript -// ✅ DO: Design for testability -// - Engine classes should accept dependencies via constructor -// - Use interfaces for external dependencies -// - Keep pure business logic separate from I/O - -class PostEngine { - constructor( - private db: DatabaseConnection, - private fs: FileSystemAdapter, // Mockable - private eventEmitter: EventEmitter - ) {} -} - -// ✅ DO: Use factory functions for test data -function createTestPost(overrides?: Partial): PostData { - return { - id: 'test-id', - title: 'Test Post', - status: 'draft', - ...overrides, - }; -} - -// ✅ DO: Test pure functions in isolation -describe('generateSlug', () => { - it('should convert to lowercase', () => { - expect(generateSlug('Hello World')).toBe('hello-world'); - }); - - it('should replace special characters', () => { - expect(generateSlug('Hello, World!')).toBe('hello-world'); - }); -}); -``` - -## Common Patterns in This Codebase - -### Creating a New Engine Method - -```typescript -// 1. Add method to engine class -async newOperation(input: InputType): Promise { - // Validate input - // Perform operation - // Emit event for UI updates - this.emit('operationCompleted', result); - return result; -} - -// 2. Add IPC handler -ipcMain.handle('entity:operation', async (_event, input) => { - return engine.newOperation(input); -}); - -// 3. Expose in preload -entity: { - operation: (input) => ipcRenderer.invoke('entity:operation', input), -} - -// 4. Update store and UI -``` - -### Adding a New Database Table - -```typescript -// 1. Define in schema.ts with proper types -export const newTable = sqliteTable('new_table', { - id: text('id').primaryKey(), - // ... columns -}); - -// 2. Export types -export type NewTableRow = typeof newTable.$inferSelect; -export type NewTableInsert = typeof newTable.$inferInsert; - -// 3. Add CREATE TABLE to migrations in connection.ts -// 4. Create corresponding engine class -``` + - Components should be stateless where possible + - Use Zustand store for shared state + - Never call IPC directly from deeply nested components ## Security Reminders @@ -808,3 +142,8 @@ export type NewTableInsert = typeof newTable.$inferInsert; - Use `contextIsolation: true` and `sandbox: false` only when necessary - Store Dropbox auth tokens in secure storage, not in code - Sanitize user input before rendering (XSS prevention) + +## Plan Mode + +- Make the plan extremely concise. Sacrifice grammar for the sake of concision. +- At the end of each plan, give me a list of unresolved questions to answer, if any.