Merge branch 'master' into copilot/implement-python-scripting-features
This commit is contained in:
681
.github/copilot-instructions.md
vendored
681
.github/copilot-instructions.md
vendored
@@ -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,683 +122,19 @@ 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<PostData | null> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 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<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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
|
||||
<EditorProvider postId={selectedPostId}>
|
||||
<EditorToolbar />
|
||||
<EditorContent />
|
||||
<EditorStatusBar />
|
||||
</EditorProvider>
|
||||
|
||||
// ✅ 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>): 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<OutputType> {
|
||||
// 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
|
||||
```
|
||||
|
||||
## Security Reminders
|
||||
|
||||
- Never log sensitive data (auth tokens, passwords)
|
||||
@@ -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.
|
||||
|
||||
681
CLAUDE.md
681
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,683 +122,19 @@ 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<PostData | null> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 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<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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
|
||||
<EditorProvider postId={selectedPostId}>
|
||||
<EditorToolbar />
|
||||
<EditorContent />
|
||||
<EditorStatusBar />
|
||||
</EditorProvider>
|
||||
|
||||
// ✅ 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>): 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<OutputType> {
|
||||
// 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
|
||||
```
|
||||
|
||||
## Security Reminders
|
||||
|
||||
- Never log sensitive data (auth tokens, passwords)
|
||||
@@ -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.
|
||||
|
||||
681
GEMINI.md
681
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,683 +122,19 @@ 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<PostData | null> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 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<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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
|
||||
<EditorProvider postId={selectedPostId}>
|
||||
<EditorToolbar />
|
||||
<EditorContent />
|
||||
<EditorStatusBar />
|
||||
</EditorProvider>
|
||||
|
||||
// ✅ 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>): 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<OutputType> {
|
||||
// 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
|
||||
```
|
||||
|
||||
## Security Reminders
|
||||
|
||||
- Never log sensitive data (auth tokens, passwords)
|
||||
@@ -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.
|
||||
|
||||
155
package-lock.json
generated
155
package-lock.json
generated
@@ -186,6 +186,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -764,6 +765,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
|
||||
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -840,6 +842,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
|
||||
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -861,6 +864,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz",
|
||||
"integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -956,6 +960,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -996,6 +1001,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1044,9 +1050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -1318,13 +1324,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -1350,7 +1356,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1372,7 +1377,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1389,7 +1393,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1404,7 +1407,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -2434,9 +2436,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -2497,9 +2499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -3961,6 +3963,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz",
|
||||
"integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@libsql/core": "^0.17.0",
|
||||
"@libsql/hrana-client": "^0.9.0",
|
||||
@@ -5167,8 +5170,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -5403,6 +5405,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -5413,6 +5416,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -5520,6 +5524,7 @@
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
@@ -5674,13 +5679,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -5899,6 +5904,7 @@
|
||||
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -6085,6 +6091,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6113,11 +6120,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6148,9 +6156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6712,6 +6720,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6918,13 +6927,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cacache/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -7329,9 +7338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/conf/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7430,8 +7439,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7568,7 +7576,8 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3-cloud": {
|
||||
"version": "1.2.8",
|
||||
@@ -7828,9 +7837,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dir-compare/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -7846,6 +7855,7 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7947,8 +7957,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
@@ -8421,7 +8430,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8442,7 +8450,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8469,16 +8476,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -8586,6 +8583,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -8663,6 +8661,7 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8775,9 +8774,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -9101,9 +9100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -9428,9 +9427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -10170,6 +10169,7 @@
|
||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -10505,7 +10505,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -11775,7 +11774,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11788,6 +11786,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -12365,6 +12364,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12485,7 +12485,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -12503,7 +12502,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -12524,7 +12522,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -12540,7 +12537,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -12695,6 +12691,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
@@ -12728,6 +12725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
@@ -12761,6 +12759,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
|
||||
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
@@ -12851,6 +12850,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12916,6 +12916,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -12945,8 +12946,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
@@ -13251,7 +13251,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -14019,7 +14018,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -14299,7 +14297,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
@@ -14855,6 +14854,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15153,6 +15153,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -15712,6 +15713,7 @@
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
@@ -15789,6 +15791,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
|
||||
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.28",
|
||||
"@vue/compiler-sfc": "3.5.28",
|
||||
|
||||
Reference in New Issue
Block a user