diff --git a/backend/package.json b/backend/package.json index 53459fb7..92fc2b51 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,9 +7,9 @@ "dev": "bun run --watch src/index.ts", "build": "tsc --noEmit", "start": "bun run src/index.ts", - "test": "LOG_LEVEL=silent bun test src/__tests__/websocket-handler.test.ts && LOG_LEVEL=silent bun test src/__tests__/sync-integration.test.ts && LOG_LEVEL=silent bun test src/__tests__/session-manager.test.ts && LOG_LEVEL=silent bun test src/__tests__/file-browser.test.ts src/__tests__/file-upload.test.ts src/__tests__/fuzzy-matcher.test.ts src/__tests__/health-collector.test.ts src/__tests__/inspiration-manager.test.ts src/__tests__/meeting-capture.test.ts src/__tests__/note-capture.test.ts src/__tests__/search-index.perf.test.ts src/__tests__/search-index.test.ts src/__tests__/search-integration.test.ts src/__tests__/server.test.ts src/__tests__/task-manager.test.ts src/__tests__/vault-config.test.ts src/__tests__/vault-manager.test.ts src/__tests__/vault-setup.test.ts src/__tests__/vault-transfer.test.ts src/__tests__/widget-integration.test.ts src/__tests__/widget-performance.test.ts && LOG_LEVEL=silent bun test src/sync/__tests__/sync-pipeline.test.ts && LOG_LEVEL=silent bun test src/sync/__tests__/api-response-cache.test.ts src/sync/__tests__/bgg-connector.test.ts src/sync/__tests__/config-loader.test.ts src/sync/__tests__/connector-interface.test.ts src/sync/__tests__/frontmatter-updater.test.ts src/sync/__tests__/schemas.test.ts src/sync/__tests__/vocabulary-normalizer.test.ts && LOG_LEVEL=silent bun test src/widgets/__tests__/file-watcher.test.ts && LOG_LEVEL=silent bun test src/widgets/__tests__/aggregators.test.ts src/widgets/__tests__/comparators.test.ts src/widgets/__tests__/context-aggregators.test.ts src/widgets/__tests__/dag-integration.test.ts src/widgets/__tests__/dependency-graph.test.ts src/widgets/__tests__/expression-eval.test.ts src/widgets/__tests__/frontmatter.test.ts src/widgets/__tests__/schemas.test.ts src/widgets/__tests__/task002-validation.test.ts src/widgets/__tests__/widget-cache.test.ts src/widgets/__tests__/widget-engine.test.ts src/widgets/__tests__/widget-includes.test.ts src/widgets/__tests__/widget-loader.test.ts && LOG_LEVEL=silent bun test src/handlers/__tests__/sync-handlers.test.ts", + "test": "LOG_LEVEL=silent bun test", "test:unit": "bun run test", - "test:coverage": "LOG_LEVEL=silent bun test src/__tests__/websocket-handler.test.ts --coverage && LOG_LEVEL=silent bun test src/__tests__/sync-integration.test.ts --coverage && LOG_LEVEL=silent bun test src/__tests__/session-manager.test.ts --coverage && LOG_LEVEL=silent bun test src/__tests__/file-browser.test.ts src/__tests__/file-upload.test.ts src/__tests__/fuzzy-matcher.test.ts src/__tests__/health-collector.test.ts src/__tests__/inspiration-manager.test.ts src/__tests__/meeting-capture.test.ts src/__tests__/note-capture.test.ts src/__tests__/search-index.perf.test.ts src/__tests__/search-index.test.ts src/__tests__/search-integration.test.ts src/__tests__/server.test.ts src/__tests__/task-manager.test.ts src/__tests__/vault-config.test.ts src/__tests__/vault-manager.test.ts src/__tests__/vault-setup.test.ts src/__tests__/vault-transfer.test.ts src/__tests__/widget-integration.test.ts src/__tests__/widget-performance.test.ts --coverage && LOG_LEVEL=silent bun test src/sync/__tests__/sync-pipeline.test.ts --coverage && LOG_LEVEL=silent bun test src/sync/__tests__/api-response-cache.test.ts src/sync/__tests__/bgg-connector.test.ts src/sync/__tests__/config-loader.test.ts src/sync/__tests__/connector-interface.test.ts src/sync/__tests__/frontmatter-updater.test.ts src/sync/__tests__/schemas.test.ts src/sync/__tests__/vocabulary-normalizer.test.ts --coverage && LOG_LEVEL=silent bun test src/widgets/__tests__/file-watcher.test.ts --coverage && LOG_LEVEL=silent bun test src/widgets/__tests__/aggregators.test.ts src/widgets/__tests__/comparators.test.ts src/widgets/__tests__/context-aggregators.test.ts src/widgets/__tests__/dag-integration.test.ts src/widgets/__tests__/dependency-graph.test.ts src/widgets/__tests__/expression-eval.test.ts src/widgets/__tests__/frontmatter.test.ts src/widgets/__tests__/schemas.test.ts src/widgets/__tests__/task002-validation.test.ts src/widgets/__tests__/widget-cache.test.ts src/widgets/__tests__/widget-engine.test.ts src/widgets/__tests__/widget-includes.test.ts src/widgets/__tests__/widget-loader.test.ts --coverage && LOG_LEVEL=silent bun test src/handlers/__tests__/sync-handlers.test.ts --coverage", + "test:coverage": "LOG_LEVEL=silent bun test --coverage", "typecheck": "tsc --noEmit", "lint": "eslint ." }, diff --git a/backend/src/__tests__/session-manager.test.ts b/backend/src/__tests__/session-manager.test.ts index 07f89382..d7e9b4b4 100644 --- a/backend/src/__tests__/session-manager.test.ts +++ b/backend/src/__tests__/session-manager.test.ts @@ -2,7 +2,7 @@ * Session Manager Tests * * Unit tests for session lifecycle management. - * Uses mocking for the SDK to avoid real API calls. + * Uses dependency injection for the SDK to avoid real API calls. */ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; @@ -11,16 +11,6 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import type { SessionMetadata, VaultInfo } from "@memory-loop/shared"; -// Mock the SDK before importing session-manager -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockQuery = mock<(...args: any[]) => any>(() => undefined); -const mockInterrupt = mock(() => Promise.resolve()); - -void mock.module("@anthropic-ai/claude-agent-sdk", () => ({ - query: mockQuery, -})); - -// Now import session-manager (it will use the mocked SDK) import { getSessionsDir, getSessionFilePath, @@ -36,8 +26,26 @@ import { resumeSession, querySession, SESSIONS_DIR, + type QueryFunction, } from "../session-manager"; +// ============================================================================= +// Mock SDK Query Function (injected via DI) +// ============================================================================= + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockQuery = mock<(...args: any[]) => any>(() => undefined); +const mockInterrupt = mock(() => Promise.resolve()); +const mockSupportedCommands = mock(() => Promise.resolve([])); + +/** + * Creates the mock query function that can be injected. + */ +function createMockQueryFn(): QueryFunction { + // Return the mock's current implementation + return mockQuery as unknown as QueryFunction; +} + // ============================================================================= // Test Fixtures // ============================================================================= @@ -110,6 +118,7 @@ function createMockQueryGenerator( return this; }, interrupt: mockInterrupt, + supportedCommands: mockSupportedCommands, }; return generator; @@ -698,7 +707,8 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("new-session-id"); mockQuery.mockReturnValue(mockGenerator); - await createSession(vault, "Hello"); + // Pass mock query function via DI (last parameter) + await createSession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); expect(mockQuery).toHaveBeenCalledTimes(1); const calls = mockQuery.mock.calls; @@ -716,7 +726,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("saved-session-id"); mockQuery.mockReturnValue(mockGenerator); - const result = await createSession(vault, "Hello"); + const result = await createSession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); expect(result.sessionId).toBe("saved-session-id"); @@ -733,7 +743,7 @@ describe("Session Manager", () => { ]); mockQuery.mockReturnValue(mockGenerator); - const result = await createSession(vault, "Hello"); + const result = await createSession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); // Consume events const events: unknown[] = []; @@ -752,7 +762,7 @@ describe("Session Manager", () => { }); try { - await createSession(vault, "Hello"); + await createSession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(SessionError); @@ -765,7 +775,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("interruptible-session"); mockQuery.mockReturnValue(mockGenerator); - const result = await createSession(vault, "Hello"); + const result = await createSession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); expect(typeof result.interrupt).toBe("function"); await result.interrupt(); @@ -777,7 +787,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("custom-options-session"); mockQuery.mockReturnValue(mockGenerator); - await createSession(vault, "Hello", { maxTurns: 5 }); + await createSession(vault, "Hello", { maxTurns: 5 }, undefined, undefined, createMockQueryFn()); const calls = mockQuery.mock.calls; expect(calls.length).toBeGreaterThan(0); @@ -790,7 +800,8 @@ describe("Session Manager", () => { describe("resumeSession", () => { test("throws for non-existent session", async () => { try { - await resumeSession(vaultPath, "non-existent", "Continue"); + // No queryFn needed since it throws before calling the SDK + await resumeSession(vaultPath, "non-existent", "Continue", undefined, undefined, undefined, createMockQueryFn()); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(SessionError); @@ -805,7 +816,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("existing-session"); mockQuery.mockReturnValue(mockGenerator); - await resumeSession(vaultPath, "existing-session", "Continue"); + await resumeSession(vaultPath, "existing-session", "Continue", undefined, undefined, undefined, createMockQueryFn()); expect(mockQuery).toHaveBeenCalledTimes(1); const calls = mockQuery.mock.calls; @@ -828,7 +839,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("resume-session"); mockQuery.mockReturnValue(mockGenerator); - await resumeSession(vaultPath, "resume-session", "Continue"); + await resumeSession(vaultPath, "resume-session", "Continue", undefined, undefined, undefined, createMockQueryFn()); const updated = await loadSession(vaultPath, "resume-session"); expect(updated!.lastActiveAt).not.toBe("2025-01-01T00:00:00.000Z"); @@ -841,7 +852,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("auto-created-session"); mockQuery.mockReturnValue(mockGenerator); - const result = await querySession(vault, "Hello"); + const result = await querySession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); expect(result.sessionId).toBe("auto-created-session"); @@ -864,7 +875,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("existing-for-query"); mockQuery.mockReturnValue(mockGenerator); - const result = await querySession(vault, "Continue", "existing-for-query"); + const result = await querySession(vault, "Continue", "existing-for-query", undefined, undefined, createMockQueryFn()); expect(result.sessionId).toBe("existing-for-query"); @@ -906,7 +917,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("empty-prompt-session"); mockQuery.mockReturnValue(mockGenerator); - const result = await createSession(vault, ""); + const result = await createSession(vault, "", undefined, undefined, undefined, createMockQueryFn()); expect(result.sessionId).toBe("empty-prompt-session"); const calls = mockQuery.mock.calls; @@ -925,7 +936,7 @@ describe("Session Manager", () => { const mockGenerator = createMockQueryGenerator("spaces-session"); mockQuery.mockReturnValue(mockGenerator); - await createSession(vault, "Hello"); + await createSession(vault, "Hello", undefined, undefined, undefined, createMockQueryFn()); const calls = mockQuery.mock.calls; expect(calls.length).toBeGreaterThan(0); diff --git a/backend/src/__tests__/sync-integration.test.ts b/backend/src/__tests__/sync-integration.test.ts index 32205090..96f2ae96 100644 --- a/backend/src/__tests__/sync-integration.test.ts +++ b/backend/src/__tests__/sync-integration.test.ts @@ -24,8 +24,10 @@ import { join } from "node:path"; import yaml from "js-yaml"; import matter from "gray-matter"; import type { ApiConnector, ApiResponse } from "../sync/connector-interface.js"; -import type { SyncProgress } from "../sync/sync-pipeline.js"; +import type { SyncProgress, GetConnectorFn } from "../sync/sync-pipeline.js"; +import { createSyncPipelineManager } from "../sync/sync-pipeline.js"; import type { MergeStrategy } from "../sync/schemas.js"; +import type { VocabularyNormalizer, NormalizationResult } from "../sync/vocabulary-normalizer.js"; // ============================================================================= // Mock Fixtures @@ -46,11 +48,12 @@ const BGG_GLOOMHAVEN_RESPONSE: ApiResponse = { }; // ============================================================================= -// Mock Connector +// Mock Connector (injected via DI) // ============================================================================= let mockFetchById: ReturnType Promise>>; let mockConnector: ApiConnector; +let mockGetConnector: GetConnectorFn; function setupMockConnector(response: ApiResponse | Error = BGG_GLOOMHAVEN_RESPONSE) { mockFetchById = mock((id: string) => { @@ -74,42 +77,55 @@ function setupMockConnector(response: ApiResponse | Error = BGG_GLOOMHAVEN_RESPO return result; }, }; -} -// Mock the connector-interface module -void mock.module("../sync/connector-interface.js", () => ({ - getConnector: (name: string) => { + mockGetConnector = (name: string) => { if (name === "bgg") return mockConnector; throw new Error(`Unknown connector "${name}".`); - }, -})); + }; +} -// Mock the vocabulary normalizer to avoid real API calls -void mock.module("../sync/vocabulary-normalizer.js", () => ({ - VocabularyNormalizer: class MockVocabularyNormalizer { - normalizeBatch( - terms: string[], +// ============================================================================= +// Mock Vocabulary Normalizer (injected via DI) +// ============================================================================= + +/** + * Create a mock normalizer that matches terms against vocabulary. + * This avoids real LLM API calls during tests. + */ +function createMockNormalizer(): VocabularyNormalizer { + return { + // eslint-disable-next-line @typescript-eslint/require-await + normalize: async (term: string, vocabulary: Record): Promise => { + for (const [canonical, variations] of Object.entries(vocabulary)) { + if ( + variations.some((v) => v.toLowerCase() === term.toLowerCase()) || + canonical.toLowerCase() === term.toLowerCase() + ) { + return canonical; + } + } + return term; + }, + // eslint-disable-next-line @typescript-eslint/require-await + normalizeWithDetails: async ( + term: string, vocabulary: Record - ): Promise> { - const results = terms.map((term) => { - for (const [canonical, variations] of Object.entries(vocabulary)) { - if ( - variations.some((v) => v.toLowerCase() === term.toLowerCase()) || - canonical.toLowerCase() === term.toLowerCase() - ) { - return { original: term, normalized: canonical, matched: true }; - } + ): Promise => { + for (const [canonical, variations] of Object.entries(vocabulary)) { + if ( + variations.some((v) => v.toLowerCase() === term.toLowerCase()) || + canonical.toLowerCase() === term.toLowerCase() + ) { + return { original: term, normalized: canonical, matched: true }; } - return { original: term, normalized: term, matched: false }; - }); - return Promise.resolve(results); - } - }, - createVocabularyNormalizer: () => ({ - normalizeBatch: ( + } + return { original: term, normalized: term, matched: false }; + }, + // eslint-disable-next-line @typescript-eslint/require-await + normalizeBatch: async ( terms: string[], vocabulary: Record - ): Promise> => { + ): Promise => { const results = terms.map((term) => { for (const [canonical, variations] of Object.entries(vocabulary)) { if ( @@ -121,10 +137,10 @@ void mock.module("../sync/vocabulary-normalizer.js", () => ({ } return { original: term, normalized: term, matched: false }; }); - return Promise.resolve(results); + return results; }, - }), -})); + } as VocabularyNormalizer; +} // ============================================================================= // Temp Directory Management @@ -217,8 +233,15 @@ async function readGameFile( return { data: parsed.data as Record, content: parsed.content }; } -// Import after mocks are set up -const { createSyncPipelineManager } = await import("../sync/sync-pipeline.js"); +/** + * Create a pipeline manager with mock dependencies injected. + */ +function createTestPipelineManager() { + return createSyncPipelineManager({ + getConnector: mockGetConnector, + normalizer: createMockNormalizer(), + }); +} // ============================================================================= // Acceptance Test 1: Basic BGG Sync @@ -236,7 +259,7 @@ describe("Acceptance Test 1: Basic BGG Sync", () => { }); await createGameFile("Games/Gloomhaven.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); expect(result.status).toBe("success"); @@ -275,7 +298,7 @@ describe("Acceptance Test 2: Vocabulary Normalization", () => { }); await createGameFile("Games/Test.md", { bgg_id: "12345" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); expect(result.status).toBe("success"); @@ -304,7 +327,7 @@ describe("Acceptance Test 3: Preserve User Edits", () => { my_rating: 9.5, // User's custom rating }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, mode: "full" }); const { data } = await readGameFile("Games/Test.md"); @@ -345,7 +368,7 @@ describe("Acceptance Test 4: Incremental Sync", () => { await createGameFile(`Games/new-${i}.md`, { bgg_id: `new-${i}` }); } - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "incremental", @@ -379,7 +402,7 @@ describe("Acceptance Test 5: Rate Limit Handling", () => { await createPipelineConfig("bgg.yaml"); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); // The sync should eventually succeed after retries @@ -399,7 +422,7 @@ describe("Acceptance Test 6: Sync Status UI", () => { await createGameFile("Games/Test.md", { bgg_id: "174430" }); const progressUpdates: SyncProgress[] = []; - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, @@ -440,7 +463,7 @@ describe("Acceptance Test 7: Error Reporting", () => { await createGameFile(`Games/game-${i}.md`, { bgg_id: `id-${i}` }); } - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); expect(result.status).toBe("error"); @@ -478,7 +501,7 @@ describe("Acceptance Test 8: Secrets Not Logged", () => { await createPipelineConfig("bgg.yaml"); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, mode: "full" }); // Check that the secret never appears in any log output @@ -512,7 +535,7 @@ describe("Acceptance Test 9: LLM Normalization Fallback", () => { }); await createGameFile("Games/Test.md", { bgg_id: "12345" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); expect(result.status).toBe("success"); @@ -542,7 +565,7 @@ describe("Acceptance Test 10: Invalid Config Handling", () => { await createPipelineConfig("valid.yaml"); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); // The valid pipeline should still execute @@ -564,7 +587,7 @@ describe("Acceptance Test 10: Invalid Config Handling", () => { await createPipelineConfig("valid.yaml"); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); const result = await manager.sync({ vaultRoot, mode: "full" }); // Valid pipeline should still execute @@ -593,7 +616,7 @@ describe("Merge Strategy Integration", () => { mechanics: ["Hand Management", "User Added Mechanic"], }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, mode: "full" }); const { data } = await readGameFile("Games/Test.md"); @@ -621,7 +644,7 @@ describe("Namespace Support", () => { }); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, mode: "full" }); const { data } = await readGameFile("Games/Test.md"); @@ -641,7 +664,7 @@ describe("Namespace Support", () => { }); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, mode: "full" }); const { data } = await readGameFile("Games/Test.md"); @@ -654,7 +677,7 @@ describe("Sync Metadata", () => { await createPipelineConfig("bgg.yaml"); await createGameFile("Games/Test.md", { bgg_id: "174430" }); - const manager = createSyncPipelineManager(); + const manager = createTestPipelineManager(); await manager.sync({ vaultRoot, mode: "full" }); const { data } = await readGameFile("Games/Test.md"); diff --git a/backend/src/__tests__/websocket-handler.test.ts b/backend/src/__tests__/websocket-handler.test.ts index d0789ae3..a3610b6b 100644 --- a/backend/src/__tests__/websocket-handler.test.ts +++ b/backend/src/__tests__/websocket-handler.test.ts @@ -2,7 +2,7 @@ * WebSocket Handler Tests * * Unit tests for WebSocket message routing and handling. - * Uses mocking for external dependencies (vault manager, session manager, note capture). + * Uses dependency injection for external dependencies. */ /* eslint-disable @typescript-eslint/require-await, require-yield */ @@ -11,10 +11,20 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import type { VaultInfo, ServerMessage } from "@memory-loop/shared"; +import type { VaultInfo, ServerMessage, SlashCommand, EditableVaultConfig } from "@memory-loop/shared"; +import type { SessionMetadata, ConversationMessage } from "../session-manager"; +import type { VaultConfig } from "../vault-config"; +import type { SetupResult } from "../vault-setup"; +import { + WebSocketHandler, + createWebSocketHandler, + createConnectionState, + generateMessageId, + type WebSocketHandlerDependencies, +} from "../websocket-handler"; // ============================================================================= -// Mock Setup +// Mock Setup (injected via DI) // ============================================================================= // Mock vault manager functions @@ -46,20 +56,127 @@ const mockResumeSession = mock<(...args: any[]) => Promise>(() => supportedCommands: mockSupportedCommands, }) ); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockLoadSession = mock<(sessionId: string) => Promise>(() => +const mockLoadSession = mock<(vaultPath: string, sessionId: string) => Promise>(() => Promise.resolve(null) ); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockAppendMessage = mock<(...args: any[]) => Promise>(() => +const mockAppendMessage = mock<(vaultPath: string, sessionId: string, message: ConversationMessage) => Promise>(() => Promise.resolve() ); -const mockDeleteSession = mock<(sessionId: string) => Promise>(() => +const mockDeleteSession = mock<(vaultPath: string, sessionId: string) => Promise>(() => Promise.resolve(true) ); -// Mock note capture +// Mock vault config functions +const mockLoadVaultConfig = mock<(vaultPath: string) => Promise>(() => + Promise.resolve({}) +); + +const mockLoadSlashCommands = mock<(vaultPath: string) => Promise>(() => + Promise.resolve(undefined) +); + +const mockSaveSlashCommands = mock<(vaultPath: string, commands: SlashCommand[]) => Promise>(() => + Promise.resolve() +); + +const mockSavePinnedAssets = mock<(vaultPath: string, paths: string[]) => Promise>(() => + Promise.resolve() +); + +const mockSaveVaultConfig = mock< + (vaultPath: string, config: EditableVaultConfig) => Promise<{ success: true } | { success: false; error: string }> +>(() => Promise.resolve({ success: true })); + +// Mock vault setup +const mockRunVaultSetup = mock<(vaultId: string) => Promise>(() => + Promise.resolve({ + success: true, + summary: ["Installed 6 commands", "Created 4 directories", "CLAUDE.md updated"], + }) +); + +// Mock widget engine +const mockComputeGroundWidgets = mock<() => Promise; + data: unknown; + isEmpty: boolean; +}>>>(() => Promise.resolve([])); + +const mockComputeRecallWidgets = mock<(filePath: string) => Promise; + data: unknown; + isEmpty: boolean; +}>>>(() => Promise.resolve([])); + +const mockHandleFilesChanged = mock<(paths: string[]) => { invalidatedWidgets: string[]; totalEntriesInvalidated: number }>(() => + ({ invalidatedWidgets: [], totalEntriesInvalidated: 0 }) +); + +const mockWidgetEngineShutdown = mock(() => {}); + +const mockGetWidgets = mock<() => Array<{ + id: string; + filePath: string; + config: { location: string; source: { pattern: string }; name: string; type: string; display: Record }; +}>>(() => []); + +// Mock WidgetEngine class +class MockWidgetEngine { + computeGroundWidgets = mockComputeGroundWidgets; + computeRecallWidgets = mockComputeRecallWidgets; + handleFilesChanged = mockHandleFilesChanged; + shutdown = mockWidgetEngineShutdown; + getWidgets = mockGetWidgets; + isInitialized = () => true; + getVaultPath = () => "/tmp/test-vault"; + getVaultId = () => "test-vault"; + setHealthCallback = () => {}; +} + +const mockCreateWidgetEngine = mock<(contentRoot: string, vaultId: string) => Promise<{ + engine: MockWidgetEngine; + loaderResult: { widgets: Array<{ id: string; config: { source: { pattern: string } } }>; errors: Array<{ id?: string; filePath: string; error: string }> }; +}>>(() => + Promise.resolve({ + engine: new MockWidgetEngine(), + loaderResult: { widgets: [], errors: [] }, + }) +); + +// Mock FileWatcher class +const mockFileWatcherStart = mock<(patterns: string[]) => Promise>(() => Promise.resolve()); +const mockFileWatcherStop = mock<() => Promise>(() => Promise.resolve()); + +class MockFileWatcher { + start = mockFileWatcherStart; + stop = mockFileWatcherStop; + isActive = () => true; + getVaultPath = () => "/tmp/test-vault"; +} + +const mockCreateFileWatcher = mock<(vaultPath: string, onFilesChanged: (paths: string[]) => void) => MockFileWatcher>(() => + new MockFileWatcher() +); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockGetRecentSessions = mock<(...args: any[]) => Promise>(() => + Promise.resolve([]) +); + +// ============================================================================= +// Handler Dependencies Mocks (injected via DI, no mock.module needed) +// ============================================================================= + +// Note capture mocks const mockCaptureToDaily = mock< // eslint-disable-next-line @typescript-eslint/no-explicit-any (...args: any[]) => Promise<{ @@ -81,47 +198,7 @@ const mockGetRecentNotes = mock<(...args: any[]) => Promise>(() => Promise.resolve([]) ); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockGetRecentSessions = mock<(...args: any[]) => Promise>(() => - Promise.resolve([]) -); - -// Apply mocks -void mock.module("../vault-manager", () => ({ - discoverVaults: mockDiscoverVaults, - getVaultById: mockGetVaultById, - VaultsDirError: class VaultsDirError extends Error { - constructor(message: string) { - super(message); - this.name = "VaultsDirError"; - } - }, -})); - -void mock.module("../session-manager", () => ({ - createSession: mockCreateSession, - resumeSession: mockResumeSession, - loadSession: mockLoadSession, - appendMessage: mockAppendMessage, - getRecentSessions: mockGetRecentSessions, - deleteSession: mockDeleteSession, - SessionError: class SessionError extends Error { - constructor( - message: string, - public readonly code: string - ) { - super(message); - this.name = "SessionError"; - } - }, -})); - -void mock.module("../note-capture", () => ({ - captureToDaily: mockCaptureToDaily, - getRecentNotes: mockGetRecentNotes, -})); - -// Mock file browser functions +// File browser mocks const mockListDirectory = mock< // eslint-disable-next-line @typescript-eslint/no-explicit-any (...args: any[]) => Promise> @@ -142,7 +219,15 @@ const mockDeleteFile = mock< (...args: any[]) => Promise >(() => Promise.resolve()); -// FileBrowserError mock class +const mockArchiveFile = mock< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (...args: any[]) => Promise<{ originalPath: string; archivePath: string }> +>(() => Promise.resolve({ originalPath: "", archivePath: "04_Archive/archived-dir" })); + +/** + * Mock FileBrowserError for tests. + * Uses name="FileBrowserError" so isFileBrowserError() works correctly. + */ class MockFileBrowserError extends Error { constructor( message: string, @@ -153,17 +238,10 @@ class MockFileBrowserError extends Error { } } -void mock.module("../file-browser", () => ({ - listDirectory: mockListDirectory, - readMarkdownFile: mockReadMarkdownFile, - writeMarkdownFile: mockWriteMarkdownFile, - deleteFile: mockDeleteFile, - FileBrowserError: MockFileBrowserError, -})); - -// Mock inspiration manager +// Inspiration manager mock const mockGetInspiration = mock< - (vaultPath: string) => Promise<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (...args: any[]) => Promise<{ contextual: { text: string; attribution?: string } | null; quote: { text: string; attribution?: string }; }> @@ -174,11 +252,7 @@ const mockGetInspiration = mock< }) ); -void mock.module("../inspiration-manager", () => ({ - getInspiration: mockGetInspiration, -})); - -// Mock task manager +// Task manager mocks const mockGetAllTasks = mock< // eslint-disable-next-line @typescript-eslint/no-explicit-any (...args: any[]) => Promise<{ @@ -213,49 +287,78 @@ const mockToggleTask = mock< }) ); -void mock.module("../task-manager", () => ({ - getAllTasks: mockGetAllTasks, - toggleTask: mockToggleTask, -})); - -// Mock vault config -const mockLoadVaultConfig = mock< - (vaultPath: string) => Promise> ->(() => Promise.resolve({})); - -const mockLoadSlashCommands = mock< - (vaultPath: string) => Promise | undefined> ->(() => Promise.resolve(undefined)); - -const mockSaveVaultConfig = mock< - (vaultPath: string, config: Record) => Promise<{ success: true } | { success: false; error: string }> ->(() => Promise.resolve({ success: true })); +// Frontmatter parsing mock +const mockParseFrontmatter = mock< + (content: string) => { data: Record; content: string } +>(() => ({ data: {}, content: "" })); -void mock.module("../vault-config", () => ({ - loadVaultConfig: mockLoadVaultConfig, - loadSlashCommands: mockLoadSlashCommands, - saveVaultConfig: mockSaveVaultConfig, -})); +/** + * Creates the mock dependencies for WebSocketHandler. + */ +function createMockDeps(): WebSocketHandlerDependencies { + return { + discoverVaults: mockDiscoverVaults, + getVaultById: mockGetVaultById, + createSession: mockCreateSession, + resumeSession: mockResumeSession, + loadSession: mockLoadSession, + appendMessage: mockAppendMessage, + deleteSession: mockDeleteSession, + loadVaultConfig: mockLoadVaultConfig, + loadSlashCommands: mockLoadSlashCommands, + saveSlashCommands: mockSaveSlashCommands, + savePinnedAssets: mockSavePinnedAssets, + saveVaultConfig: mockSaveVaultConfig, + runVaultSetup: mockRunVaultSetup, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + createWidgetEngine: mockCreateWidgetEngine as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + createFileWatcher: mockCreateFileWatcher as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + createSearchIndex: ((contentRoot: string) => new MockSearchIndexManager(contentRoot)) as any, + // Handler dependencies (injected to extracted handlers) + handlerDeps: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + captureToDaily: mockCaptureToDaily as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + getRecentNotes: mockGetRecentNotes as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + listDirectory: mockListDirectory as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + readMarkdownFile: mockReadMarkdownFile as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + writeMarkdownFile: mockWriteMarkdownFile as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + deleteFile: mockDeleteFile as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + archiveFile: mockArchiveFile as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + getInspiration: mockGetInspiration as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + getAllTasks: mockGetAllTasks as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + toggleTask: mockToggleTask as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + getRecentSessions: mockGetRecentSessions as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + loadVaultConfig: mockLoadVaultConfig as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + parseFrontmatter: mockParseFrontmatter as any, + }, + }; +} -// Mock vault setup -const mockRunVaultSetup = mock< - (vaultId: string) => Promise<{ - success: boolean; - summary: string[]; - errors?: string[]; - }> ->(() => - Promise.resolve({ - success: true, - summary: ["Installed 6 commands", "Created 4 directories", "CLAUDE.md updated"], - }) -); +/** + * Test helper: creates a WebSocketHandler with mock dependencies. + */ +function createTestHandler(): WebSocketHandler { + return createWebSocketHandler(createMockDeps()); +} -void mock.module("../vault-setup", () => ({ - runVaultSetup: mockRunVaultSetup, -})); +// ============================================================================= +// Search Index Mocks (set directly on connection state) +// ============================================================================= -// Mock search index manager const mockSearchFiles = mock< // eslint-disable-next-line @typescript-eslint/no-explicit-any (...args: any[]) => Promise> >(() => Promise.resolve([])); -// Create a mock class for SearchIndexManager +/** + * Mock SearchIndexManager for tests. + * Instances are set directly on connection state. + */ class MockSearchIndexManager { private contentRoot: string; @@ -302,111 +408,6 @@ class MockSearchIndexManager { getSnippets = mockGetSnippets; } -void mock.module("../search/search-index", () => ({ - SearchIndexManager: MockSearchIndexManager, -})); - -// Mock widgets module -const mockComputeGroundWidgets = mock< - () => Promise; - data: unknown; - isEmpty: boolean; - }>> ->(() => Promise.resolve([])); - -const mockComputeRecallWidgets = mock< - (filePath: string) => Promise; - data: unknown; - isEmpty: boolean; - }>> ->(() => Promise.resolve([])); - -const mockHandleFilesChanged = mock< - (paths: string[]) => { invalidatedWidgets: string[]; totalEntriesInvalidated: number } ->(() => ({ invalidatedWidgets: [], totalEntriesInvalidated: 0 })); - -const mockWidgetEngineShutdown = mock(() => {}); -const mockWidgetEngineInitialize = mock< - () => Promise<{ widgets: Array<{ id: string; config: { source: { pattern: string } } }>; errors: string[] }> ->(() => Promise.resolve({ widgets: [], errors: [] })); - -const mockGetWidgets = mock< - () => Array<{ id: string; filePath: string; config: { location: string; source: { pattern: string }; name: string; type: string; display: Record } }> ->(() => []); - -// Mock WidgetEngine class -class MockWidgetEngine { - computeGroundWidgets = mockComputeGroundWidgets; - computeRecallWidgets = mockComputeRecallWidgets; - handleFilesChanged = mockHandleFilesChanged; - shutdown = mockWidgetEngineShutdown; - initialize = mockWidgetEngineInitialize; - getWidgets = mockGetWidgets; - isInitialized = () => true; - getVaultPath = () => "/tmp/test-vault"; - getVaultId = () => "test-vault"; - setHealthCallback = () => {}; // No-op for tests -} - -const mockCreateWidgetEngine = mock< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]) => Promise<{ - engine: MockWidgetEngine; - loaderResult: { widgets: Array<{ id: string; config: { source: { pattern: string } } }>; errors: string[] }; - }> ->(() => - Promise.resolve({ - engine: new MockWidgetEngine(), - loaderResult: { widgets: [], errors: [] }, - }) -); - -// Mock FileWatcher class -const mockFileWatcherStart = mock<(patterns: string[]) => Promise>(() => Promise.resolve()); -const mockFileWatcherStop = mock<() => Promise>(() => Promise.resolve()); - -class MockFileWatcher { - start = mockFileWatcherStart; - stop = mockFileWatcherStop; - isActive = () => true; - getVaultPath = () => "/tmp/test-vault"; -} - -const mockCreateFileWatcher = mock< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]) => MockFileWatcher ->(() => new MockFileWatcher()); - -const mockParseFrontmatter = mock< - (content: string) => { data: Record; content: string } ->(() => ({ data: {}, content: "" })); - -void mock.module("../widgets", () => ({ - WidgetEngine: MockWidgetEngine, - createWidgetEngine: mockCreateWidgetEngine, - FileWatcher: MockFileWatcher, - createFileWatcher: mockCreateFileWatcher, - parseFrontmatter: mockParseFrontmatter, -})); - -// Import handler after mocks are set up -import { - WebSocketHandler, - createWebSocketHandler, - createConnectionState, - generateMessageId, -} from "../websocket-handler"; - // ============================================================================= // Test Fixtures // ============================================================================= @@ -483,10 +484,15 @@ describe("WebSocket Handler", () => { mockResumeSession.mockReset(); mockLoadSession.mockReset(); mockAppendMessage.mockReset(); + mockDeleteSession.mockReset(); + mockLoadSlashCommands.mockReset(); + mockSaveSlashCommands.mockReset(); + mockSavePinnedAssets.mockReset(); + mockRunVaultSetup.mockReset(); + mockSupportedCommands.mockReset(); mockCaptureToDaily.mockReset(); mockGetRecentNotes.mockReset(); mockGetRecentSessions.mockReset(); - mockInterrupt.mockReset(); mockListDirectory.mockReset(); mockReadMarkdownFile.mockReset(); mockWriteMarkdownFile.mockReset(); @@ -503,7 +509,6 @@ describe("WebSocket Handler", () => { mockComputeRecallWidgets.mockReset(); mockHandleFilesChanged.mockReset(); mockWidgetEngineShutdown.mockReset(); - mockWidgetEngineInitialize.mockReset(); mockGetWidgets.mockReset(); mockCreateWidgetEngine.mockReset(); mockFileWatcherStart.mockReset(); @@ -581,7 +586,7 @@ describe("WebSocket Handler", () => { describe("createWebSocketHandler", () => { test("creates a new WebSocketHandler instance", () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); expect(handler).toBeInstanceOf(WebSocketHandler); }); }); @@ -608,7 +613,7 @@ describe("WebSocket Handler", () => { const vaults = [createMockVault({ id: "vault-1", name: "Vault 1" })]; mockDiscoverVaults.mockResolvedValue(vaults); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onOpen(ws as unknown as Parameters[0]); @@ -624,7 +629,7 @@ describe("WebSocket Handler", () => { test("sends error if vault discovery fails", async () => { mockDiscoverVaults.mockRejectedValue(new Error("Discovery failed")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onOpen(ws as unknown as Parameters[0]); @@ -640,7 +645,7 @@ describe("WebSocket Handler", () => { describe("onClose", () => { test("clears connection state", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); // Set up some state const vault = createMockVault(); @@ -679,7 +684,7 @@ describe("WebSocket Handler", () => { interrupt: localInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -743,7 +748,7 @@ describe("WebSocket Handler", () => { test("handles string data", async () => { mockDiscoverVaults.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -756,7 +761,7 @@ describe("WebSocket Handler", () => { }); test("handles ArrayBuffer data", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); const data = new TextEncoder().encode(JSON.stringify({ type: "ping" })); @@ -770,7 +775,7 @@ describe("WebSocket Handler", () => { }); test("sends error for invalid JSON", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -787,7 +792,7 @@ describe("WebSocket Handler", () => { }); test("sends error for invalid message structure", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -803,7 +808,7 @@ describe("WebSocket Handler", () => { }); test("sends error for missing required fields", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // select_vault requires vaultId @@ -829,7 +834,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -852,7 +857,7 @@ describe("WebSocket Handler", () => { test("sends error for non-existent vault", async () => { mockGetVaultById.mockResolvedValue(null); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -871,7 +876,7 @@ describe("WebSocket Handler", () => { const vault1 = createMockVault({ id: "vault-1" }); const vault2 = createMockVault({ id: "vault-2" }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select first vault @@ -922,7 +927,7 @@ describe("WebSocket Handler", () => { notePath: "/tmp/test-vault/00_Inbox/2025-01-15.md", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -948,7 +953,7 @@ describe("WebSocket Handler", () => { }); test("sends error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -974,7 +979,7 @@ describe("WebSocket Handler", () => { error: "Failed to write file", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -1004,7 +1009,7 @@ describe("WebSocket Handler", () => { describe("get_recent_activity", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1031,7 +1036,7 @@ describe("WebSocket Handler", () => { { sessionId: "session-1", preview: "Hello", time: "09:00", date: "2025-01-15", messageCount: 5 }, ]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1060,7 +1065,7 @@ describe("WebSocket Handler", () => { mockGetRecentNotes.mockResolvedValue([]); mockGetRecentSessions.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1087,7 +1092,7 @@ describe("WebSocket Handler", () => { mockGetRecentNotes.mockResolvedValue([]); mockGetRecentSessions.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1109,7 +1114,7 @@ describe("WebSocket Handler", () => { mockGetRecentNotes.mockResolvedValue([]); mockGetRecentSessions.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1130,7 +1135,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockGetRecentNotes.mockRejectedValue(new Error("Filesystem error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1183,7 +1188,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -1228,7 +1233,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault @@ -1261,7 +1266,7 @@ describe("WebSocket Handler", () => { }); test("sends error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1322,7 +1327,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1416,7 +1421,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1488,7 +1493,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); await handler.onMessage( ws as unknown as Parameters[0], @@ -1545,7 +1550,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1612,7 +1617,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -1667,7 +1672,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1718,7 +1723,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1767,7 +1772,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1814,7 +1819,7 @@ describe("WebSocket Handler", () => { supportedCommands: mockSupportedCommands, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -1863,7 +1868,7 @@ describe("WebSocket Handler", () => { supportedCommands: mockSupportedCommands, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1905,7 +1910,7 @@ describe("WebSocket Handler", () => { supportedCommands: mockSupportedCommands, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -1960,7 +1965,7 @@ describe("WebSocket Handler", () => { supportedCommands: mockSupportedCommands, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Resume an existing session @@ -1999,7 +2004,7 @@ describe("WebSocket Handler", () => { messages: [], }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -2030,7 +2035,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockLoadSession.mockResolvedValue(null); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -2065,7 +2070,7 @@ describe("WebSocket Handler", () => { messages: [], }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -2089,7 +2094,7 @@ describe("WebSocket Handler", () => { test("sends error when no vault pre-selected", async () => { // With per-vault session storage, vault must be selected first - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Resume without selecting vault first @@ -2118,7 +2123,7 @@ describe("WebSocket Handler", () => { }); mockGetVaultById.mockResolvedValue(null); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2139,7 +2144,7 @@ describe("WebSocket Handler", () => { // Simulate storage failure or corruption mockLoadSession.mockRejectedValue(new Error("Storage read failed")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -2177,7 +2182,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault and create session @@ -2208,7 +2213,7 @@ describe("WebSocket Handler", () => { }); test("sends error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2236,7 +2241,7 @@ describe("WebSocket Handler", () => { interrupt: activeInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2279,7 +2284,7 @@ describe("WebSocket Handler", () => { interrupt: mockInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault and create session @@ -2326,7 +2331,7 @@ describe("WebSocket Handler", () => { interrupt: activeInterrupt, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2354,7 +2359,7 @@ describe("WebSocket Handler", () => { }); test("does nothing if no active query", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Should not throw @@ -2374,7 +2379,7 @@ describe("WebSocket Handler", () => { describe("ping", () => { test("responds with pong", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2396,8 +2401,8 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler1 = createWebSocketHandler(); - const handler2 = createWebSocketHandler(); + const handler1 = createTestHandler(); + const handler2 = createTestHandler(); const ws1 = createMockWebSocket(); // Select vault on handler1 @@ -2422,7 +2427,7 @@ describe("WebSocket Handler", () => { timestamp: "2025-01-01T00:00:00.000Z", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault @@ -2463,7 +2468,7 @@ describe("WebSocket Handler", () => { new SessionError("SDK unavailable", "SDK_ERROR") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2488,7 +2493,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockCreateSession.mockRejectedValue(new Error("Unexpected failure")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2516,7 +2521,7 @@ describe("WebSocket Handler", () => { describe("list_directory handler", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2540,7 +2545,7 @@ describe("WebSocket Handler", () => { { name: "note.md", type: "file", path: "note.md" }, ]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2572,7 +2577,7 @@ describe("WebSocket Handler", () => { { name: "nested.md", type: "file", path: "folder1/nested.md" }, ]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2602,7 +2607,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Path outside vault", "PATH_TRAVERSAL") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2629,7 +2634,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Directory not found", "DIRECTORY_NOT_FOUND") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2654,7 +2659,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockListDirectory.mockRejectedValue(new Error("Unexpected failure")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2678,7 +2683,7 @@ describe("WebSocket Handler", () => { describe("read_file handler", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2702,7 +2707,7 @@ describe("WebSocket Handler", () => { truncated: false, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2734,7 +2739,7 @@ describe("WebSocket Handler", () => { truncated: true, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2761,7 +2766,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Only .md files allowed", "INVALID_FILE_TYPE") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2788,7 +2793,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("File not found", "FILE_NOT_FOUND") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2815,7 +2820,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Path outside vault", "PATH_TRAVERSAL") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2840,7 +2845,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockReadMarkdownFile.mockRejectedValue(new Error("Disk read error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2868,7 +2873,7 @@ describe("WebSocket Handler", () => { describe("write_file handler", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2889,7 +2894,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockWriteMarkdownFile.mockResolvedValue(undefined); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2925,7 +2930,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockWriteMarkdownFile.mockResolvedValue(undefined); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2953,7 +2958,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockWriteMarkdownFile.mockResolvedValue(undefined); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -2990,7 +2995,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Path outside vault", "PATH_TRAVERSAL") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3021,7 +3026,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Only .md files allowed", "INVALID_FILE_TYPE") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3052,7 +3057,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("File does not exist", "FILE_NOT_FOUND") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3081,7 +3086,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockWriteMarkdownFile.mockRejectedValue(new Error("Disk write error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3107,7 +3112,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockWriteMarkdownFile.mockRejectedValue(new Error("EACCES: permission denied")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3135,7 +3140,7 @@ describe("WebSocket Handler", () => { describe("delete_file handler", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3156,7 +3161,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockDeleteFile.mockResolvedValue(undefined); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3183,7 +3188,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockDeleteFile.mockResolvedValue(undefined); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3212,7 +3217,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError('File "missing.md" does not exist', "FILE_NOT_FOUND") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3239,7 +3244,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Path is outside the vault boundary", "PATH_TRAVERSAL") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3266,7 +3271,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Can only delete files, not directories", "INVALID_FILE_TYPE") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3291,7 +3296,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockDeleteFile.mockRejectedValue(new Error("Disk failure")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3319,7 +3324,7 @@ describe("WebSocket Handler", () => { describe("get_inspiration", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3338,7 +3343,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault({ path: "/test/vault/path" }); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3362,7 +3367,7 @@ describe("WebSocket Handler", () => { quote: { text: "Carpe diem", attribution: "Horace" }, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3397,7 +3402,7 @@ describe("WebSocket Handler", () => { quote: { text: "Stay curious", attribution: "Einstein" }, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3423,7 +3428,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockGetInspiration.mockRejectedValue(new Error("Generation failed")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3452,7 +3457,7 @@ describe("WebSocket Handler", () => { describe("get_tasks handler", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3480,7 +3485,7 @@ describe("WebSocket Handler", () => { total: 2, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3513,7 +3518,7 @@ describe("WebSocket Handler", () => { total: 0, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3548,7 +3553,7 @@ describe("WebSocket Handler", () => { total: 0, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3573,7 +3578,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockGetAllTasks.mockRejectedValue(new Error("Filesystem error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3601,7 +3606,7 @@ describe("WebSocket Handler", () => { describe("toggle_task handler", () => { test("returns error if no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3625,7 +3630,7 @@ describe("WebSocket Handler", () => { newState: "x", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3662,7 +3667,7 @@ describe("WebSocket Handler", () => { error: "Path outside vault", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3690,7 +3695,7 @@ describe("WebSocket Handler", () => { error: "File not found: missing.md", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3718,7 +3723,7 @@ describe("WebSocket Handler", () => { error: "Line 3 is not a task", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3746,7 +3751,7 @@ describe("WebSocket Handler", () => { new MockFileBrowserError("Path outside vault", "PATH_TRAVERSAL") ); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3771,7 +3776,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockToggleTask.mockRejectedValue(new Error("Disk write error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3809,7 +3814,7 @@ describe("WebSocket Handler", () => { }); }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3839,7 +3844,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockDeleteSession.mockImplementation(() => Promise.resolve(true)); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -3866,7 +3871,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockDeleteSession.mockImplementation(() => Promise.resolve(false)); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -3902,7 +3907,7 @@ describe("WebSocket Handler", () => { supportedCommands: mockSupportedCommands, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault @@ -3934,7 +3939,7 @@ describe("WebSocket Handler", () => { }); test("validates sessionId is required", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3959,7 +3964,7 @@ describe("WebSocket Handler", () => { summary: ["Installed 6 commands", "Created 4 directories"], }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -3986,7 +3991,7 @@ describe("WebSocket Handler", () => { errors: ["CLAUDE.md: SDK error"], }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4005,7 +4010,7 @@ describe("WebSocket Handler", () => { test("returns VAULT_NOT_FOUND when vault doesn't exist", async () => { mockGetVaultById.mockResolvedValue(null); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4025,7 +4030,7 @@ describe("WebSocket Handler", () => { vault.hasClaudeMd = false; mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4046,7 +4051,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockRunVaultSetup.mockRejectedValue(new Error("Unexpected error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4062,7 +4067,7 @@ describe("WebSocket Handler", () => { }); test("validates vaultId is required", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4084,7 +4089,7 @@ describe("WebSocket Handler", () => { describe("search_files handler", () => { test("returns VAULT_NOT_FOUND when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4108,7 +4113,7 @@ describe("WebSocket Handler", () => { { path: "another-test.md", name: "another-test.md", score: 80, matchPositions: [8, 9, 10, 11] }, ]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4139,7 +4144,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSearchFiles.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4162,7 +4167,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSearchFiles.mockRejectedValue(new Error("Search failed")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4186,7 +4191,7 @@ describe("WebSocket Handler", () => { }); test("validates query is required", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4204,7 +4209,7 @@ describe("WebSocket Handler", () => { describe("search_content handler", () => { test("returns VAULT_NOT_FOUND when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4228,7 +4233,7 @@ describe("WebSocket Handler", () => { { path: "projects/tasks.md", name: "tasks.md", matchCount: 3 }, ]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4259,7 +4264,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSearchContent.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4282,7 +4287,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSearchContent.mockRejectedValue(new Error("Index not ready")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4308,7 +4313,7 @@ describe("WebSocket Handler", () => { describe("get_snippets handler", () => { test("returns VAULT_NOT_FOUND when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4342,7 +4347,7 @@ describe("WebSocket Handler", () => { }, ]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4374,7 +4379,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockGetSnippets.mockResolvedValue([]); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4397,7 +4402,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockGetSnippets.mockRejectedValue(new Error("File read error")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault first @@ -4421,7 +4426,7 @@ describe("WebSocket Handler", () => { }); test("validates path is required", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4437,7 +4442,7 @@ describe("WebSocket Handler", () => { }); test("validates query is required", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4458,7 +4463,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault({ contentRoot: "/test/vault/content" }); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Verify searchIndex is null before vault selection @@ -4479,7 +4484,7 @@ describe("WebSocket Handler", () => { const vault1 = createMockVault({ id: "vault-1", contentRoot: "/vault1/content" }); const vault2 = createMockVault({ id: "vault-2", contentRoot: "/vault2/content" }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select first vault @@ -4513,7 +4518,7 @@ describe("WebSocket Handler", () => { describe("Widget Handlers", () => { describe("get_ground_widgets", () => { test("returns error when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4534,7 +4539,7 @@ describe("WebSocket Handler", () => { // Make createWidgetEngine throw to simulate no engine mockCreateWidgetEngine.mockRejectedValue(new Error("No widgets dir")); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault (widget engine will fail) @@ -4576,7 +4581,7 @@ describe("WebSocket Handler", () => { ]; mockComputeGroundWidgets.mockResolvedValue(mockWidgets); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault @@ -4604,7 +4609,7 @@ describe("WebSocket Handler", () => { describe("get_recall_widgets", () => { test("returns error when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4636,7 +4641,7 @@ describe("WebSocket Handler", () => { ]; mockComputeRecallWidgets.mockResolvedValue(mockWidgets); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault @@ -4665,7 +4670,7 @@ describe("WebSocket Handler", () => { describe("widget_edit", () => { test("returns error when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4699,7 +4704,7 @@ describe("WebSocket Handler", () => { content: "# Content", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault @@ -4735,7 +4740,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4776,7 +4781,7 @@ describe("WebSocket Handler", () => { loaderResult: { widgets: [{ id: "test", config: { source: { pattern: "Games/**/*.md" } } }], errors: [] }, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); await handler.onMessage( @@ -4796,7 +4801,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault to initialize widgets @@ -4838,7 +4843,7 @@ describe("WebSocket Handler", () => { loaderResult: { widgets: [{ id: "test", config: { source: { pattern: "Games/**/*.md" } } }], errors: [] }, }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Select vault to initialize widgets @@ -4862,7 +4867,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSaveVaultConfig.mockResolvedValue({ success: true }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -4893,7 +4898,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSaveVaultConfig.mockResolvedValue({ success: true }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -4925,7 +4930,7 @@ describe("WebSocket Handler", () => { }); test("returns config_updated with error when no vault selected", async () => { - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // Try to update config without selecting vault @@ -4953,7 +4958,7 @@ describe("WebSocket Handler", () => { error: "Permission denied: cannot write to config file", }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -4984,7 +4989,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -5016,7 +5021,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -5046,7 +5051,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSaveVaultConfig.mockResolvedValue({ success: true }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -5087,7 +5092,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -5119,7 +5124,7 @@ describe("WebSocket Handler", () => { const vault = createMockVault(); mockGetVaultById.mockResolvedValue(vault); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault @@ -5158,7 +5163,7 @@ describe("WebSocket Handler", () => { mockGetVaultById.mockResolvedValue(vault); mockSaveVaultConfig.mockResolvedValue({ success: true }); - const handler = createWebSocketHandler(); + const handler = createTestHandler(); const ws = createMockWebSocket(); // First select the vault diff --git a/backend/src/handlers/__tests__/sync-handlers.test.ts b/backend/src/handlers/__tests__/sync-handlers.test.ts index b814425b..3efadd12 100644 --- a/backend/src/handlers/__tests__/sync-handlers.test.ts +++ b/backend/src/handlers/__tests__/sync-handlers.test.ts @@ -15,8 +15,10 @@ import { join } from "node:path"; import yaml from "js-yaml"; import matter from "gray-matter"; import type { VaultInfo, ServerMessage } from "@memory-loop/shared"; -import type { HandlerContext, ConnectionState } from "../types.js"; +import type { HandlerContext, ConnectionState, RequiredHandlerDependencies } from "../types.js"; import type { ApiConnector, ApiResponse } from "../../sync/connector-interface.js"; +import type { GetConnectorFn, SyncPipelineManagerDependencies } from "../../sync/sync-pipeline.js"; +import { handleTriggerSync } from "../sync-handlers.js"; // ============================================================================= // Test Fixtures @@ -41,7 +43,7 @@ const API_RESPONSE: ApiResponse = { }; // ============================================================================= -// Mock Connector Module +// Mock Connector (injected via DI) // ============================================================================= const mockFetchById = mock(() => Promise.resolve(API_RESPONSE)); @@ -52,13 +54,14 @@ const mockConnector: ApiConnector = { extractFields: (response: ApiResponse) => response as Record, }; -// Mock the connector-interface module -void mock.module("../../sync/connector-interface.js", () => ({ - getConnector: (name: string) => { - if (name === "test") return mockConnector; - throw new Error(`Unknown connector "${name}".`); - }, -})); +const mockGetConnector: GetConnectorFn = (name: string) => { + if (name === "test") return mockConnector; + throw new Error(`Unknown connector "${name}".`); +}; + +const mockDeps: SyncPipelineManagerDependencies = { + getConnector: mockGetConnector, +}; // ============================================================================= // Temp Directory Management @@ -132,6 +135,23 @@ async function createGameFile( await writeFile(fullPath, fileContent, "utf-8"); } +// Stub handler dependencies (not used by sync handlers, but required by HandlerContext) +const stubHandlerDeps: RequiredHandlerDependencies = { + captureToDaily: () => Promise.resolve({ success: true, timestamp: "", notePath: "" }), + getRecentNotes: () => Promise.resolve([]), + listDirectory: () => Promise.resolve([]), + readMarkdownFile: () => Promise.resolve({ content: "", truncated: false }), + writeMarkdownFile: () => Promise.resolve(), + deleteFile: () => Promise.resolve(), + archiveFile: () => Promise.resolve({ originalPath: "", archivePath: "" }), + getInspiration: () => Promise.resolve({ contextual: null, quote: { text: "", attribution: "" } }), + getAllTasks: () => Promise.resolve({ tasks: [], incomplete: 0, total: 0 }), + toggleTask: () => Promise.resolve({ success: true }), + getRecentSessions: () => Promise.resolve([]), + loadVaultConfig: () => Promise.resolve({}), + parseFrontmatter: () => ({ data: {}, content: "" }), +}; + function createMockContext(): HandlerContext { return { state: mockState, @@ -141,15 +161,10 @@ function createMockContext(): HandlerContext { sendError: (code, message) => { sentMessages.push({ type: "error", code, message } as ServerMessage); }, + deps: stubHandlerDeps, }; } -// ============================================================================= -// Import Handler After Mocks -// ============================================================================= - -const { handleTriggerSync } = await import("../sync-handlers.js"); - // ============================================================================= // Tests // ============================================================================= @@ -159,7 +174,7 @@ describe("handleTriggerSync", () => { await createPipelineConfig(PIPELINE_CONFIG); const ctx = createMockContext(); - await handleTriggerSync(ctx, "full"); + await handleTriggerSync(ctx, "full", undefined, mockDeps); // First message should be syncing status expect(sentMessages.length).toBeGreaterThan(0); @@ -175,7 +190,7 @@ describe("handleTriggerSync", () => { await createGameFile("Games/test.md", { game_id: "123" }); const ctx = createMockContext(); - await handleTriggerSync(ctx, "full"); + await handleTriggerSync(ctx, "full", undefined, mockDeps); // Last message should be success const lastMessage = sentMessages[sentMessages.length - 1]; @@ -189,7 +204,7 @@ describe("handleTriggerSync", () => { mockState.currentVault = null; const ctx = createMockContext(); - await handleTriggerSync(ctx, "full"); + await handleTriggerSync(ctx, "full", undefined, mockDeps); // Should send error expect(sentMessages.length).toBeGreaterThan(0); @@ -203,7 +218,7 @@ describe("handleTriggerSync", () => { await createGameFile("Games/game2.md", { game_id: "456" }); const ctx = createMockContext(); - await handleTriggerSync(ctx, "full"); + await handleTriggerSync(ctx, "full", undefined, mockDeps); // Should have progress updates with current/total const progressMessages = sentMessages.filter( @@ -217,7 +232,7 @@ describe("handleTriggerSync", () => { await createGameFile("Games/test.md", { game_id: "123" }); const ctx = createMockContext(); - await handleTriggerSync(ctx, "full", "test-sync"); + await handleTriggerSync(ctx, "full", "test-sync", mockDeps); // Should complete successfully const lastMessage = sentMessages[sentMessages.length - 1]; @@ -230,7 +245,7 @@ describe("handleTriggerSync", () => { it("should handle sync with no pipelines configured", async () => { const ctx = createMockContext(); - await handleTriggerSync(ctx, "full"); + await handleTriggerSync(ctx, "full", undefined, mockDeps); // Should complete (no error, success with 0 files) const lastMessage = sentMessages[sentMessages.length - 1]; @@ -249,7 +264,7 @@ describe("handleTriggerSync", () => { await createGameFile("Games/test.md", { game_id: "123" }); const ctx = createMockContext(); - await handleTriggerSync(ctx, "full"); + await handleTriggerSync(ctx, "full", undefined, mockDeps); // Should have error status with errors array const lastMessage = sentMessages[sentMessages.length - 1]; @@ -266,7 +281,7 @@ describe("handleTriggerSync", () => { await createGameFile("Games/test.md", { game_id: "123" }); const ctx = createMockContext(); - await handleTriggerSync(ctx, "incremental"); + await handleTriggerSync(ctx, "incremental", undefined, mockDeps); // Should complete successfully const lastMessage = sentMessages[sentMessages.length - 1]; diff --git a/backend/src/handlers/browser-handlers.ts b/backend/src/handlers/browser-handlers.ts index d3bf8671..32faf56f 100644 --- a/backend/src/handlers/browser-handlers.ts +++ b/backend/src/handlers/browser-handlers.ts @@ -6,18 +6,11 @@ * - read_file: Read markdown file content * - write_file: Write content to a markdown file * - delete_file: Delete a file + * - archive_file: Archive a directory */ import type { HandlerContext } from "./types.js"; -import { requireVault } from "./types.js"; -import { - listDirectory, - readMarkdownFile, - writeMarkdownFile, - deleteFile, - archiveFile, - FileBrowserError, -} from "../file-browser.js"; +import { requireVault, isFileBrowserError } from "./types.js"; import { wsLog as log } from "../logger.js"; /** @@ -36,7 +29,7 @@ export async function handleListDirectory( } try { - const entries = await listDirectory(ctx.state.currentVault.contentRoot, path); + const entries = await ctx.deps.listDirectory(ctx.state.currentVault.contentRoot, path); log.info(`Found ${entries.length} entries in ${path || "/"}`); ctx.send({ type: "directory_listing", @@ -45,7 +38,7 @@ export async function handleListDirectory( }); } catch (error) { log.error("Directory listing failed", error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { const message = @@ -71,7 +64,7 @@ export async function handleReadFile( } try { - const result = await readMarkdownFile(ctx.state.currentVault.contentRoot, path); + const result = await ctx.deps.readMarkdownFile(ctx.state.currentVault.contentRoot, path); log.info(`File read: ${path} (truncated: ${result.truncated})`); ctx.send({ type: "file_content", @@ -81,7 +74,7 @@ export async function handleReadFile( }); } catch (error) { log.error("File reading failed", error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { const message = @@ -108,7 +101,7 @@ export async function handleWriteFile( } try { - await writeMarkdownFile(ctx.state.currentVault.contentRoot, path, content); + await ctx.deps.writeMarkdownFile(ctx.state.currentVault.contentRoot, path, content); log.info(`File written: ${path} (${content.length} bytes)`); ctx.send({ type: "file_written", @@ -117,7 +110,7 @@ export async function handleWriteFile( }); } catch (error) { log.error("File writing failed", error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { const message = @@ -143,7 +136,7 @@ export async function handleDeleteFile( } try { - await deleteFile(ctx.state.currentVault.contentRoot, path); + await ctx.deps.deleteFile(ctx.state.currentVault.contentRoot, path); log.info(`File deleted: ${path}`); ctx.send({ type: "file_deleted", @@ -151,7 +144,7 @@ export async function handleDeleteFile( }); } catch (error) { log.error("File deletion failed", error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { const message = @@ -177,7 +170,7 @@ export async function handleArchiveFile( } try { - const result = await archiveFile(ctx.state.currentVault.contentRoot, path); + const result = await ctx.deps.archiveFile(ctx.state.currentVault.contentRoot, path, "04_Archive"); log.info(`Directory archived: ${path} -> ${result.archivePath}`); ctx.send({ type: "file_archived", @@ -186,7 +179,7 @@ export async function handleArchiveFile( }); } catch (error) { log.error("Archive failed", error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { const message = diff --git a/backend/src/handlers/home-handlers.ts b/backend/src/handlers/home-handlers.ts index d6708e3a..58101eef 100644 --- a/backend/src/handlers/home-handlers.ts +++ b/backend/src/handlers/home-handlers.ts @@ -12,18 +12,12 @@ */ import type { HandlerContext } from "./types.js"; -import { requireVault } from "./types.js"; -import { captureToDaily, getRecentNotes } from "../note-capture.js"; +import { requireVault, isFileBrowserError } from "./types.js"; import { getVaultGoals } from "../vault-manager.js"; -import { getInspiration } from "../inspiration-manager.js"; -import { getAllTasks, toggleTask } from "../task-manager.js"; -import { getRecentSessions } from "../session-manager.js"; import { - loadVaultConfig, resolveRecentCaptures, resolveRecentDiscussions, } from "../vault-config.js"; -import { FileBrowserError } from "../file-browser.js"; import { wsLog as log } from "../logger.js"; /** @@ -42,7 +36,7 @@ export async function handleCaptureNote( } try { - const result = await captureToDaily(ctx.state.currentVault, text); + const result = await ctx.deps.captureToDaily(ctx.state.currentVault, text); if (!result.success) { log.error("Note capture failed", result.error); @@ -75,7 +69,7 @@ export async function handleGetRecentNotes(ctx: HandlerContext): Promise { } try { - const notes = await getRecentNotes(ctx.state.currentVault, 5); + const notes = await ctx.deps.getRecentNotes(ctx.state.currentVault, 5); log.info(`Found ${notes.length} recent notes`); ctx.send({ type: "recent_notes", @@ -101,13 +95,13 @@ export async function handleGetRecentActivity(ctx: HandlerContext): Promise { } try { - const result = await getInspiration(ctx.state.currentVault); + const result = await ctx.deps.getInspiration(ctx.state.currentVault); log.info( `Inspiration fetched: contextual=${result.contextual !== null}, quote="${result.quote.text.slice(0, 30)}..."` ); @@ -191,8 +185,8 @@ export async function handleGetTasks(ctx: HandlerContext): Promise { } try { - const config = await loadVaultConfig(ctx.state.currentVault.path); - const result = await getAllTasks(ctx.state.currentVault.contentRoot, config); + const config = await ctx.deps.loadVaultConfig(ctx.state.currentVault.path); + const result = await ctx.deps.getAllTasks(ctx.state.currentVault.contentRoot, config); log.info(`Found ${result.total} tasks (${result.incomplete} incomplete)`); ctx.send({ type: "tasks", @@ -225,7 +219,7 @@ export async function handleToggleTask( } try { - const result = await toggleTask( + const result = await ctx.deps.toggleTask( ctx.state.currentVault.contentRoot, filePath, lineNumber, @@ -253,7 +247,7 @@ export async function handleToggleTask( }); } catch (error) { log.error("Failed to toggle task", error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { const message = error instanceof Error ? error.message : "Failed to toggle task"; diff --git a/backend/src/handlers/sync-handlers.ts b/backend/src/handlers/sync-handlers.ts index 23ca6978..a0bacb52 100644 --- a/backend/src/handlers/sync-handlers.ts +++ b/backend/src/handlers/sync-handlers.ts @@ -14,7 +14,12 @@ import type { HandlerContext } from "./types.js"; import { requireVault } from "./types.js"; -import { createSyncPipelineManager, type SyncMode, type SyncProgress } from "../sync/sync-pipeline.js"; +import { + createSyncPipelineManager, + type SyncMode, + type SyncProgress, + type SyncPipelineManagerDependencies, +} from "../sync/sync-pipeline.js"; import { wsLog as log } from "../logger.js"; /** Health issue ID for sync errors */ @@ -27,11 +32,13 @@ const SYNC_ERROR_ID = "sync_error"; * @param ctx - Handler context with connection state and send utilities * @param mode - Sync mode: "full" re-syncs all, "incremental" skips recent * @param pipeline - Optional specific pipeline to sync (omit for all) + * @param deps - Optional dependencies for testing (default: uses real implementations) */ export async function handleTriggerSync( ctx: HandlerContext, mode: SyncMode, - pipeline?: string + pipeline?: string, + deps?: SyncPipelineManagerDependencies ): Promise { log.info(`Triggering ${mode} sync${pipeline ? ` for pipeline: ${pipeline}` : ""}`); @@ -49,7 +56,7 @@ export async function handleTriggerSync( }); try { - const manager = createSyncPipelineManager(); + const manager = createSyncPipelineManager(deps); // Progress callback sends updates to client const onProgress = (progress: SyncProgress): void => { diff --git a/backend/src/handlers/types.ts b/backend/src/handlers/types.ts index b3d541b2..b16de72b 100644 --- a/backend/src/handlers/types.ts +++ b/backend/src/handlers/types.ts @@ -4,12 +4,139 @@ * Common types, interfaces, and helper functions used across all handler modules. */ -import type { VaultInfo, ServerMessage, ErrorCode } from "@memory-loop/shared"; +import type { + VaultInfo, + ServerMessage, + ErrorCode, + FileEntry, + RecentNoteEntry, + RecentDiscussionEntry, + TaskEntry, +} from "@memory-loop/shared"; import type { SessionQueryResult } from "../session-manager.js"; import type { SearchIndexManager } from "../search/search-index.js"; import type { WidgetEngine, FileWatcher } from "../widgets/index.js"; import type { HealthCollector } from "../health-collector.js"; import type { ActiveMeeting } from "../meeting-capture.js"; +import type { VaultConfig } from "../vault-config.js"; +import type { ArchiveResult } from "../file-browser.js"; + +// ============================================================================= +// Handler Dependencies (Injectable for Testing) +// ============================================================================= + +/** + * Result of reading a file. + */ +export interface FileReadResult { + content: string; + truncated: boolean; +} + +/** + * Result of capturing a note. + */ +export interface CaptureResult { + success: boolean; + timestamp: string; + notePath: string; + error?: string; +} + +/** + * Result of getting all tasks. + */ +export interface TasksResult { + tasks: TaskEntry[]; + incomplete: number; + total: number; +} + +/** + * Result of toggling a task. + */ +export interface ToggleResult { + success: boolean; + newState?: string; + error?: string; +} + +/** + * Result of getting inspiration. + */ +export interface InspirationResult { + contextual: { text: string; attribution?: string } | null; + quote: { text: string; attribution?: string }; +} + +/** + * Result of parsing frontmatter. + */ +export interface FrontmatterResult { + data: Record; + content: string; +} + +/** + * Dependencies for handler functions. + * All functions are optional; defaults are used when not provided. + */ +export interface HandlerDependencies { + // Note capture functions + captureToDaily?: ( + vault: VaultInfo, + text: string, + date?: Date + ) => Promise; + getRecentNotes?: ( + vault: VaultInfo, + limit?: number + ) => Promise; + + // File browser functions + listDirectory?: ( + vaultPath: string, + relativePath: string + ) => Promise; + readMarkdownFile?: ( + vaultPath: string, + relativePath: string + ) => Promise; + writeMarkdownFile?: ( + vaultPath: string, + relativePath: string, + content: string + ) => Promise; + deleteFile?: (vaultPath: string, relativePath: string) => Promise; + archiveFile?: (vaultPath: string, relativePath: string, archiveRoot: string) => Promise; + + // Inspiration manager + getInspiration?: (vault: VaultInfo) => Promise; + + // Task manager + getAllTasks?: ( + vaultPath: string, + config: VaultConfig + ) => Promise; + toggleTask?: ( + vaultPath: string, + filePath: string, + lineNumber: number, + newState?: string + ) => Promise; + + // Session manager + getRecentSessions?: ( + vaultPath: string, + limit?: number + ) => Promise; + + // Vault config + loadVaultConfig?: (vaultPath: string) => Promise; + + // Widgets + parseFrontmatter?: (content: string) => FrontmatterResult; +} /** * WebSocket interface for sending messages. @@ -65,6 +192,53 @@ export interface ConnectionState { activeMeeting: ActiveMeeting | null; } +/** + * Required handler dependencies (with defaults filled in). + */ +export interface RequiredHandlerDependencies { + captureToDaily: ( + vault: VaultInfo, + text: string, + date?: Date + ) => Promise; + getRecentNotes: ( + vault: VaultInfo, + limit?: number + ) => Promise; + listDirectory: ( + vaultPath: string, + relativePath: string + ) => Promise; + readMarkdownFile: ( + vaultPath: string, + relativePath: string + ) => Promise; + writeMarkdownFile: ( + vaultPath: string, + relativePath: string, + content: string + ) => Promise; + deleteFile: (vaultPath: string, relativePath: string) => Promise; + archiveFile: (vaultPath: string, relativePath: string, archiveRoot: string) => Promise; + getInspiration: (vault: VaultInfo) => Promise; + getAllTasks: ( + vaultPath: string, + config: VaultConfig + ) => Promise; + toggleTask: ( + vaultPath: string, + filePath: string, + lineNumber: number, + newState?: string + ) => Promise; + getRecentSessions: ( + vaultPath: string, + limit?: number + ) => Promise; + loadVaultConfig: (vaultPath: string) => Promise; + parseFrontmatter: (content: string) => FrontmatterResult; +} + /** * Handler context passed to all message handlers. * Provides access to connection state and utility functions. @@ -76,6 +250,22 @@ export interface HandlerContext { send: (message: ServerMessage) => void; /** Send an error message to the client */ sendError: (code: ErrorCode, message: string) => void; + /** Injectable dependencies for handler functions */ + deps: RequiredHandlerDependencies; +} + +/** + * Checks if an error is a FileBrowserError by checking its name property. + * Used instead of instanceof to avoid mock.module() dependencies. + */ +export function isFileBrowserError( + error: unknown +): error is Error & { code: ErrorCode } { + return ( + error instanceof Error && + error.name === "FileBrowserError" && + "code" in error + ); } /** diff --git a/backend/src/handlers/widget-handlers.ts b/backend/src/handlers/widget-handlers.ts index 51ae2fe9..82898339 100644 --- a/backend/src/handlers/widget-handlers.ts +++ b/backend/src/handlers/widget-handlers.ts @@ -8,9 +8,7 @@ */ import type { HandlerContext, WebSocketLike } from "./types.js"; -import { requireVault } from "./types.js"; -import { readMarkdownFile, writeMarkdownFile, FileBrowserError } from "../file-browser.js"; -import { parseFrontmatter } from "../widgets/index.js"; +import { requireVault, isFileBrowserError } from "./types.js"; import { wsLog as log } from "../logger.js"; import matter from "gray-matter"; @@ -152,19 +150,19 @@ export async function handleWidgetEdit( } try { - const { content } = await readMarkdownFile( + const { content } = await ctx.deps.readMarkdownFile( ctx.state.currentVault.contentRoot, filePath ); - const parsed = parseFrontmatter(content); + const parsed = ctx.deps.parseFrontmatter(content); const frontmatter = parsed.data; setNestedValue(frontmatter, fieldPath, value); const newContent = matter.stringify(parsed.content, frontmatter); - await writeMarkdownFile( + await ctx.deps.writeMarkdownFile( ctx.state.currentVault.contentRoot, filePath, newContent @@ -194,7 +192,7 @@ export async function handleWidgetEdit( } catch (error) { log.error(`Widget edit failed: ${filePath}`, error); - if (error instanceof FileBrowserError) { + if (isFileBrowserError(error)) { ctx.sendError(error.code, error.message); } else { ctx.send({ diff --git a/backend/src/session-manager.ts b/backend/src/session-manager.ts index 16330079..231f43f5 100644 --- a/backend/src/session-manager.ts +++ b/backend/src/session-manager.ts @@ -17,6 +17,14 @@ import { // Re-export the SDK's SlashCommand type for use by other modules export type { SDKSlashCommand }; + +// Re-export types from shared for convenience +export type { SessionMetadata, ConversationMessage } from "@memory-loop/shared"; + +/** + * Type for the SDK query function, to enable dependency injection for testing. + */ +export type QueryFunction = typeof query; import type { SessionMetadata, VaultInfo, RecentDiscussionEntry, ConversationMessage } from "@memory-loop/shared"; import { directoryExists, fileExists, getVaultById } from "./vault-manager"; import { @@ -768,6 +776,7 @@ function createCanUseTool( * @param options - Additional SDK options * @param requestToolPermission - Optional callback to request tool permission from user * @param askUserQuestion - Optional callback to handle AskUserQuestion tool + * @param queryFn - Optional query function for testing (default: SDK query) * @returns SessionQueryResult with session ID and event stream */ export async function createSession( @@ -775,7 +784,8 @@ export async function createSession( prompt: string, options?: Partial, requestToolPermission?: ToolPermissionCallback, - askUserQuestion?: AskUserQuestionCallback + askUserQuestion?: AskUserQuestionCallback, + queryFn: QueryFunction = query ): Promise { log.info(`Creating session for vault: ${vault.id}`); log.info(`Vault path: ${vault.path}`); @@ -820,7 +830,7 @@ export async function createSession( permissionMode: mergedOptions.permissionMode, hasCanUseTool: !!mergedOptions.canUseTool, }); - const queryResult = query({ + const queryResult = queryFn({ prompt, options: mergedOptions, }); @@ -876,6 +886,7 @@ export async function createSession( * @param options - Additional SDK options * @param requestToolPermission - Optional callback to request tool permission from user * @param askUserQuestion - Optional callback to handle AskUserQuestion tool + * @param queryFn - Optional query function for testing (default: SDK query) * @returns SessionQueryResult with session ID and event stream */ export async function resumeSession( @@ -884,7 +895,8 @@ export async function resumeSession( prompt: string, options?: Partial, requestToolPermission?: ToolPermissionCallback, - askUserQuestion?: AskUserQuestionCallback + askUserQuestion?: AskUserQuestionCallback, + queryFn: QueryFunction = query ): Promise { log.info(`Resuming session: ${sessionId}`); @@ -941,7 +953,7 @@ export async function resumeSession( permissionMode: mergedOptions.permissionMode, hasCanUseTool: !!mergedOptions.canUseTool, }); - const queryResult = query({ + const queryResult = queryFn({ prompt, options: mergedOptions, }); @@ -980,6 +992,7 @@ export async function resumeSession( * @param sessionId - Optional session ID to resume * @param options - Additional SDK options * @param requestToolPermission - Optional callback to request tool permission from user + * @param queryFn - Optional query function for testing (default: SDK query) * @returns SessionQueryResult */ export async function querySession( @@ -987,10 +1000,11 @@ export async function querySession( prompt: string, sessionId?: string, options?: Partial, - requestToolPermission?: ToolPermissionCallback + requestToolPermission?: ToolPermissionCallback, + queryFn: QueryFunction = query ): Promise { if (sessionId) { - return resumeSession(vault.path, sessionId, prompt, options, requestToolPermission); + return resumeSession(vault.path, sessionId, prompt, options, requestToolPermission, undefined, queryFn); } - return createSession(vault, prompt, options, requestToolPermission); + return createSession(vault, prompt, options, requestToolPermission, undefined, queryFn); } diff --git a/backend/src/sync/__tests__/sync-pipeline.test.ts b/backend/src/sync/__tests__/sync-pipeline.test.ts index f17700b7..b1ff572f 100644 --- a/backend/src/sync/__tests__/sync-pipeline.test.ts +++ b/backend/src/sync/__tests__/sync-pipeline.test.ts @@ -16,7 +16,11 @@ import { join } from "node:path"; import yaml from "js-yaml"; import matter from "gray-matter"; import type { ApiConnector, ApiResponse } from "../connector-interface.js"; -import type { SyncProgress } from "../sync-pipeline.js"; +import type { SyncProgress, GetConnectorFn } from "../sync-pipeline.js"; +import { + SyncPipelineManager, + createSyncPipelineManager, +} from "../sync-pipeline.js"; // ============================================================================= // Test Fixtures @@ -42,7 +46,7 @@ const API_RESPONSE: ApiResponse = { }; // ============================================================================= -// Mock Connector Module +// Mock Connector (injected via DI) // ============================================================================= const mockFetchById = mock(() => Promise.resolve(API_RESPONSE)); @@ -53,13 +57,10 @@ const mockConnector: ApiConnector = { extractFields: (response: ApiResponse) => response as Record, }; -// Mock the connector-interface module -void mock.module("../connector-interface.js", () => ({ - getConnector: (name: string) => { - if (name === "test") return mockConnector; - throw new Error(`Unknown connector "${name}".`); - }, -})); +const mockGetConnector: GetConnectorFn = (name: string) => { + if (name === "test") return mockConnector; + throw new Error(`Unknown connector "${name}".`); +}; // ============================================================================= // Temp Directory Management @@ -112,16 +113,12 @@ async function readGameFile( // Basic Sync Tests // ============================================================================= -// Import after mock is set up -const { SyncPipelineManager, createSyncPipelineManager } = await import( - "../sync-pipeline.js" -); - describe("SyncPipelineManager", () => { - let manager: InstanceType; + let manager: SyncPipelineManager; beforeEach(() => { - manager = new SyncPipelineManager(); + // Inject mock connector via DI + manager = new SyncPipelineManager({ getConnector: mockGetConnector }); // Reset mock call counts between tests mockFetchById.mockClear(); }); diff --git a/backend/src/sync/sync-pipeline.ts b/backend/src/sync/sync-pipeline.ts index 436b948b..722b60ac 100644 --- a/backend/src/sync/sync-pipeline.ts +++ b/backend/src/sync/sync-pipeline.ts @@ -29,7 +29,7 @@ import { get } from "lodash-es"; import { createLogger } from "../logger.js"; import { loadPipelineConfigs, loadSecrets, type ProtectedSecrets } from "./config-loader.js"; import { createApiResponseCache, type ApiResponseCache } from "./api-response-cache.js"; -import { getConnector, type ApiConnector } from "./connector-interface.js"; +import { getConnector as getConnectorFromRegistry, type ApiConnector } from "./connector-interface.js"; import { createVocabularyNormalizer, type VocabularyNormalizer } from "./vocabulary-normalizer.js"; import { createFrontmatterUpdater, type FrontmatterUpdater } from "./frontmatter-updater.js"; import type { PipelineConfig, SyncMeta, FieldMapping } from "./schemas.js"; @@ -87,6 +87,21 @@ export interface SyncResult { */ export type ProgressCallback = (progress: SyncProgress) => void; +/** + * Function type for retrieving API connectors by name. + */ +export type GetConnectorFn = (name: string) => ApiConnector; + +/** + * Dependencies for SyncPipelineManager (injectable for testing). + */ +export interface SyncPipelineManagerDependencies { + /** Function to retrieve connectors by name (default: registry lookup) */ + getConnector?: GetConnectorFn; + /** Vocabulary normalizer instance (default: creates new one) */ + normalizer?: VocabularyNormalizer; +} + /** * Options for sync execution. */ @@ -211,11 +226,13 @@ export class SyncPipelineManager { private cache: ApiResponseCache; private normalizer: VocabularyNormalizer; private updater: FrontmatterUpdater; + private getConnector: GetConnectorFn; - constructor() { + constructor(deps: SyncPipelineManagerDependencies = {}) { this.cache = createApiResponseCache(); - this.normalizer = createVocabularyNormalizer(); + this.normalizer = deps.normalizer ?? createVocabularyNormalizer(); this.updater = createFrontmatterUpdater(); + this.getConnector = deps.getConnector ?? getConnectorFromRegistry; } /** @@ -346,7 +363,7 @@ export class SyncPipelineManager { // Get connector let connector: ApiConnector; try { - connector = getConnector(config.connector); + connector = this.getConnector(config.connector); } catch { log.error(`Unknown connector: ${config.connector}`); errors.push({ @@ -531,6 +548,8 @@ export class SyncPipelineManager { /** * Create a new sync pipeline manager. */ -export function createSyncPipelineManager(): SyncPipelineManager { - return new SyncPipelineManager(); +export function createSyncPipelineManager( + deps?: SyncPipelineManagerDependencies +): SyncPipelineManager { + return new SyncPipelineManager(deps); } diff --git a/backend/src/websocket-handler.ts b/backend/src/websocket-handler.ts index f61887e8..e7e778a0 100644 --- a/backend/src/websocket-handler.ts +++ b/backend/src/websocket-handler.ts @@ -12,6 +12,7 @@ import type { StoredToolInvocation, SlashCommand, EditableVaultConfig, + VaultInfo, } from "@memory-loop/shared"; import { EditableVaultConfigSchema } from "@memory-loop/shared"; import type { @@ -36,40 +37,120 @@ type RawStreamEvent = SDKPartialAssistantMessage["event"]; type ContentStreamEvent = Exclude; import { safeParseClientMessage } from "@memory-loop/shared"; -import { discoverVaults, getVaultById } from "./vault-manager.js"; -import { SearchIndexManager } from "./search/search-index.js"; import { - createSession, - resumeSession, - loadSession, - appendMessage, - deleteSession, + discoverVaults as defaultDiscoverVaults, + getVaultById as defaultGetVaultById, +} from "./vault-manager.js"; +import { SearchIndexManager, type SearchIndexManager as ISearchIndexManager } from "./search/search-index.js"; + +/** + * Factory type for creating SearchIndexManager instances. + */ +export type CreateSearchIndexFn = (contentRoot: string) => ISearchIndexManager; +import { + createSession as defaultCreateSession, + resumeSession as defaultResumeSession, + loadSession as defaultLoadSession, + appendMessage as defaultAppendMessage, + deleteSession as defaultDeleteSession, SessionError, type SessionQueryResult, type ToolPermissionCallback, type AskUserQuestionCallback, type AskUserQuestionItem, + type SessionMetadata, + type ConversationMessage, } from "./session-manager.js"; import { isMockMode, generateMockResponse, createMockSession } from "./mock-sdk.js"; import { wsLog as log } from "./logger.js"; import { - loadVaultConfig, - loadSlashCommands, - saveSlashCommands, + loadVaultConfig as defaultLoadVaultConfig, + loadSlashCommands as defaultLoadSlashCommands, + saveSlashCommands as defaultSaveSlashCommands, slashCommandsEqual, - savePinnedAssets, + savePinnedAssets as defaultSavePinnedAssets, resolvePinnedAssets, - saveVaultConfig, + saveVaultConfig as defaultSaveVaultConfig, + type VaultConfig, } from "./vault-config.js"; -import { runVaultSetup } from "./vault-setup.js"; -import { createWidgetEngine, createFileWatcher } from "./widgets/index.js"; +import { runVaultSetup as defaultRunVaultSetup, type SetupResult } from "./vault-setup.js"; +import { + createWidgetEngine as defaultCreateWidgetEngine, + createFileWatcher as defaultCreateFileWatcher, + type WidgetEngine, + type WidgetLoaderResult, + parseFrontmatter as defaultParseFrontmatter, +} from "./widgets/index.js"; import { createHealthCollector } from "./health-collector.js"; +import { + captureToDaily as defaultCaptureToDaily, + getRecentNotes as defaultGetRecentNotes, +} from "./note-capture.js"; +import { + listDirectory as defaultListDirectory, + readMarkdownFile as defaultReadMarkdownFile, + writeMarkdownFile as defaultWriteMarkdownFile, + deleteFile as defaultDeleteFile, + archiveFile as defaultArchiveFile, +} from "./file-browser.js"; +import { getInspiration as defaultGetInspiration } from "./inspiration-manager.js"; +import { + getAllTasks as defaultGetAllTasks, + toggleTask as defaultToggleTask, +} from "./task-manager.js"; +import { getRecentSessions as defaultGetRecentSessions } from "./session-manager.js"; + +// ============================================================================= +// Dependency Injection Types +// ============================================================================= + +/** + * Dependencies for WebSocketHandler (injectable for testing). + * All functions default to their real implementations. + */ +export interface WebSocketHandlerDependencies { + // Vault manager + discoverVaults?: () => Promise; + getVaultById?: (id: string) => Promise; + + // Session manager + createSession?: typeof defaultCreateSession; + resumeSession?: typeof defaultResumeSession; + loadSession?: (vaultPath: string, sessionId: string) => Promise; + appendMessage?: (vaultPath: string, sessionId: string, message: ConversationMessage) => Promise; + deleteSession?: (vaultPath: string, sessionId: string) => Promise; + + // Vault config + loadVaultConfig?: (vaultPath: string) => Promise; + loadSlashCommands?: (vaultPath: string) => Promise; + saveSlashCommands?: (vaultPath: string, commands: SlashCommand[]) => Promise; + savePinnedAssets?: (vaultPath: string, paths: string[]) => Promise; + saveVaultConfig?: (vaultPath: string, config: EditableVaultConfig) => Promise<{ success: true } | { success: false; error: string }>; + + // Vault setup + runVaultSetup?: (vaultId: string) => Promise; + + // Widgets + createWidgetEngine?: (contentRoot: string, vaultId: string) => Promise<{ + engine: WidgetEngine; + loaderResult: WidgetLoaderResult; + }>; + createFileWatcher?: typeof defaultCreateFileWatcher; + + // Search index + createSearchIndex?: CreateSearchIndexFn; + + // Handler dependencies (passed through to extracted handlers) + handlerDeps?: HandlerDependencies; +} // Import extracted handlers import { type WebSocketLike, type ConnectionState, type HandlerContext, + type HandlerDependencies, + type RequiredHandlerDependencies, createConnectionState, generateMessageId, } from "./handlers/types.js"; @@ -174,9 +255,47 @@ function sanitizeSlashCommands(commands: SlashCommand[] | undefined): SlashComma */ export class WebSocketHandler { private state: ConnectionState; + private readonly deps: Required>; + private readonly handlerDeps: RequiredHandlerDependencies; - constructor() { + constructor(deps: WebSocketHandlerDependencies = {}) { this.state = createConnectionState(); + this.deps = { + discoverVaults: deps.discoverVaults ?? defaultDiscoverVaults, + getVaultById: deps.getVaultById ?? defaultGetVaultById, + createSession: deps.createSession ?? defaultCreateSession, + resumeSession: deps.resumeSession ?? defaultResumeSession, + loadSession: deps.loadSession ?? defaultLoadSession, + appendMessage: deps.appendMessage ?? defaultAppendMessage, + deleteSession: deps.deleteSession ?? defaultDeleteSession, + loadVaultConfig: deps.loadVaultConfig ?? defaultLoadVaultConfig, + loadSlashCommands: deps.loadSlashCommands ?? defaultLoadSlashCommands, + saveSlashCommands: deps.saveSlashCommands ?? defaultSaveSlashCommands, + savePinnedAssets: deps.savePinnedAssets ?? defaultSavePinnedAssets, + saveVaultConfig: deps.saveVaultConfig ?? defaultSaveVaultConfig, + runVaultSetup: deps.runVaultSetup ?? defaultRunVaultSetup, + createWidgetEngine: deps.createWidgetEngine ?? defaultCreateWidgetEngine, + createFileWatcher: deps.createFileWatcher ?? defaultCreateFileWatcher, + createSearchIndex: deps.createSearchIndex ?? ((contentRoot) => new SearchIndexManager(contentRoot)), + }; + + // Handler dependencies (injectable for testing) + const hd = deps.handlerDeps ?? {}; + this.handlerDeps = { + captureToDaily: hd.captureToDaily ?? defaultCaptureToDaily, + getRecentNotes: hd.getRecentNotes ?? defaultGetRecentNotes, + listDirectory: hd.listDirectory ?? defaultListDirectory, + readMarkdownFile: hd.readMarkdownFile ?? defaultReadMarkdownFile, + writeMarkdownFile: hd.writeMarkdownFile ?? defaultWriteMarkdownFile, + deleteFile: hd.deleteFile ?? defaultDeleteFile, + archiveFile: hd.archiveFile ?? defaultArchiveFile, + getInspiration: hd.getInspiration ?? defaultGetInspiration, + getAllTasks: hd.getAllTasks ?? defaultGetAllTasks, + toggleTask: hd.toggleTask ?? defaultToggleTask, + getRecentSessions: hd.getRecentSessions ?? defaultGetRecentSessions, + loadVaultConfig: hd.loadVaultConfig ?? defaultLoadVaultConfig, + parseFrontmatter: hd.parseFrontmatter ?? defaultParseFrontmatter, + }; } /** @@ -214,6 +333,7 @@ export class WebSocketHandler { state: this.state, send: (message: ServerMessage) => this.send(ws, message), sendError: (code: ErrorCode, message: string) => this.sendError(ws, code, message), + deps: this.handlerDeps, }; } @@ -244,10 +364,10 @@ export class WebSocketHandler { // Update cache if commands changed if (this.state.currentVault) { try { - const cachedCommands = await loadSlashCommands(this.state.currentVault.path); + const cachedCommands = await this.deps.loadSlashCommands(this.state.currentVault.path); if (!slashCommandsEqual(cachedCommands, commands)) { log.info("Slash commands changed, updating cache"); - await saveSlashCommands(this.state.currentVault.path, commands); + await this.deps.saveSlashCommands(this.state.currentVault.path, commands); } } catch (cacheError) { log.warn("Failed to update slash commands cache, continuing", cacheError); @@ -388,7 +508,7 @@ export class WebSocketHandler { async onOpen(ws: WebSocketLike): Promise { log.info("Connection opened, discovering vaults..."); try { - const vaults = await discoverVaults(); + const vaults = await this.deps.discoverVaults(); log.info(`Found ${vaults.length} vault(s)`, vaults.map((v) => v.id)); this.send(ws, { type: "vault_list", vaults }); } catch (error) { @@ -628,7 +748,7 @@ export class WebSocketHandler { ): Promise { log.info(`Selecting vault: ${vaultId}`); try { - const vault = await getVaultById(vaultId); + const vault = await this.deps.getVaultById(vaultId); if (!vault) { log.warn(`Vault not found: ${vaultId}`); @@ -659,7 +779,7 @@ export class WebSocketHandler { this.state.currentVault = vault; this.state.currentSessionId = null; this.state.activeQuery = null; - this.state.searchIndex = new SearchIndexManager(vault.contentRoot); + this.state.searchIndex = this.deps.createSearchIndex(vault.contentRoot); // Create health collector and subscribe to changes this.state.healthCollector = createHealthCollector(); @@ -669,7 +789,7 @@ export class WebSocketHandler { // Initialize widget engine try { - const { engine, loaderResult } = await createWidgetEngine(vault.contentRoot, vault.id); + const { engine, loaderResult } = await this.deps.createWidgetEngine(vault.contentRoot, vault.id); this.state.widgetEngine = engine; // Connect health callback for widget computation issues (cycles, expression errors) @@ -708,7 +828,7 @@ export class WebSocketHandler { const widgets = engine.getWidgets(); if (widgets.length > 0) { const patterns = [...new Set(widgets.map((w) => w.config.source.pattern))]; - this.state.widgetWatcher = createFileWatcher( + this.state.widgetWatcher = this.deps.createFileWatcher( vault.contentRoot, (changedPaths) => { handleWidgetFileChanges(this.createContext(ws), ws, changedPaths); @@ -733,7 +853,7 @@ export class WebSocketHandler { }); } - const cachedCommands = await loadSlashCommands(vault.path); + const cachedCommands = await this.deps.loadSlashCommands(vault.path); log.info("Sending session_ready"); this.send(ws, { @@ -797,7 +917,7 @@ export class WebSocketHandler { if (this.state.currentSessionId) { log.info(`Resuming session: ${this.state.currentSessionId}`); - queryResult = await resumeSession( + queryResult = await this.deps.resumeSession( this.state.currentVault.path, this.state.currentSessionId, text, @@ -807,7 +927,7 @@ export class WebSocketHandler { ); } else { log.info("Creating new session"); - queryResult = await createSession( + queryResult = await this.deps.createSession( this.state.currentVault, text, undefined, @@ -835,7 +955,7 @@ export class WebSocketHandler { } const userMessageId = generateMessageId(); - await appendMessage(this.state.currentVault.path, queryResult.sessionId, { + await this.deps.appendMessage(this.state.currentVault.path, queryResult.sessionId, { id: userMessageId, role: "user", content: text, @@ -854,7 +974,7 @@ export class WebSocketHandler { }); if (streamResult.content.length > 0 || streamResult.toolInvocations.length > 0) { - await appendMessage(this.state.currentVault.path, queryResult.sessionId, { + await this.deps.appendMessage(this.state.currentVault.path, queryResult.sessionId, { id: messageId, role: "assistant", content: streamResult.content, @@ -920,7 +1040,7 @@ export class WebSocketHandler { return; } - const metadata = await loadSession(this.state.currentVault.path, sessionId); + const metadata = await this.deps.loadSession(this.state.currentVault.path, sessionId); if (!metadata) { log.warn(`Session not found: ${sessionId}`); @@ -939,7 +1059,7 @@ export class WebSocketHandler { this.state.currentSessionId = sessionId; log.info(`Resuming session ${sessionId} with ${metadata.messages.length} messages`); - const cachedCommands = await loadSlashCommands(this.state.currentVault.path); + const cachedCommands = await this.deps.loadSlashCommands(this.state.currentVault.path); this.send(ws, { type: "session_ready", @@ -979,7 +1099,7 @@ export class WebSocketHandler { this.state.currentSessionId = null; - const cachedCommands = await loadSlashCommands(this.state.currentVault.path); + const cachedCommands = await this.deps.loadSlashCommands(this.state.currentVault.path); this.send(ws, { type: "session_ready", @@ -1011,7 +1131,7 @@ export class WebSocketHandler { } try { - const deleted = await deleteSession(this.state.currentVault.path, sessionId); + const deleted = await this.deps.deleteSession(this.state.currentVault.path, sessionId); if (deleted) { log.info(`Session deleted: ${sessionId.slice(0, 8)}...`); this.send(ws, { type: "session_deleted", sessionId }); @@ -1039,7 +1159,7 @@ export class WebSocketHandler { ): Promise { log.info(`Setting up vault: ${vaultId}`); - const vault = await getVaultById(vaultId); + const vault = await this.deps.getVaultById(vaultId); if (!vault) { log.warn(`Vault not found for setup: ${vaultId}`); this.sendError(ws, "VAULT_NOT_FOUND", `Vault "${vaultId}" not found`); @@ -1057,7 +1177,7 @@ export class WebSocketHandler { } try { - const result = await runVaultSetup(vaultId); + const result = await this.deps.runVaultSetup(vaultId); log.info( `Setup complete for ${vaultId}: success=${result.success}, ` + @@ -1105,7 +1225,7 @@ export class WebSocketHandler { } try { - const config = await loadVaultConfig(this.state.currentVault.path); + const config = await this.deps.loadVaultConfig(this.state.currentVault.path); const paths = resolvePinnedAssets(config); this.send(ws, { type: "pinned_assets", paths }); } catch (error) { @@ -1129,7 +1249,7 @@ export class WebSocketHandler { } try { - await savePinnedAssets(this.state.currentVault.path, paths); + await this.deps.savePinnedAssets(this.state.currentVault.path, paths); this.send(ws, { type: "pinned_assets", paths }); } catch (error) { log.error("Failed to save pinned assets", error); @@ -1151,7 +1271,7 @@ export class WebSocketHandler { // Determine which vault to update: explicit vaultId takes priority, then currentVault let targetVault = this.state.currentVault; if (vaultId) { - targetVault = await getVaultById(vaultId); + targetVault = await this.deps.getVaultById(vaultId); } if (!targetVault) { @@ -1177,7 +1297,7 @@ export class WebSocketHandler { } // Save validated config - const result = await saveVaultConfig(targetVault.path, validation.data); + const result = await this.deps.saveVaultConfig(targetVault.path, validation.data); if (result.success) { log.info(`Vault config updated for ${targetVault.id}`); @@ -1595,7 +1715,11 @@ export class WebSocketHandler { /** * Creates a new WebSocketHandler instance. * Factory function for creating handlers per connection. + * + * @param deps - Optional dependencies for testing */ -export function createWebSocketHandler(): WebSocketHandler { - return new WebSocketHandler(); +export function createWebSocketHandler( + deps?: WebSocketHandlerDependencies +): WebSocketHandler { + return new WebSocketHandler(deps); } diff --git a/backend/src/widgets/__tests__/file-watcher.test.ts b/backend/src/widgets/__tests__/file-watcher.test.ts index 6e128cc9..2e7097ac 100644 --- a/backend/src/widgets/__tests__/file-watcher.test.ts +++ b/backend/src/widgets/__tests__/file-watcher.test.ts @@ -1,22 +1,31 @@ /** * File Watcher Tests * - * Unit tests with mocked chokidar and fake timers. + * Unit tests with dependency injection for chokidar and fs. * Tests the FileWatcher's debouncing, hash comparison, and callback logic * without depending on actual filesystem event timing. */ import { describe, test, expect, beforeEach, mock } from "bun:test"; import { EventEmitter } from "node:events"; -// Import real fs BEFORE mocking to capture real implementations -import * as realFs from "node:fs/promises"; -import { FileWatcher, createFileWatcher } from "../file-watcher"; +import type { FSWatcher, ChokidarOptions } from "chokidar"; +import { + FileWatcher, + createFileWatcher, + type FileWatcherDependencies, + type WatchFunction, + type ReadFileFunction, +} from "../file-watcher"; // ============================================================================= -// Mock Setup +// Mock Setup (injected via DI) // ============================================================================= -// Mock FSWatcher that we can control +/** + * Mock FSWatcher that we can control. + * We use type assertion rather than implementing the full FSWatcher interface + * since chokidar 5.x has many internal properties we don't need to mock. + */ class MockFSWatcher extends EventEmitter { closed = false; @@ -25,35 +34,45 @@ class MockFSWatcher extends EventEmitter { this.removeAllListeners(); return Promise.resolve(); } + + add(): this { + return this; + } + unwatch(): this { + return this; + } + getWatched(): Record { + return {}; + } } // Track mock instances for assertions let mockWatcher: MockFSWatcher; -// Mock chokidar.watch -const mockWatch = mock(() => { +// Mock chokidar.watch (injected via DI) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockWatch = mock((_path: string, _options?: ChokidarOptions): FSWatcher => { mockWatcher = new MockFSWatcher(); // Auto-emit 'ready' after a microtask to simulate async initialization queueMicrotask(() => mockWatcher.emit("ready")); - return mockWatcher; + return mockWatcher as unknown as FSWatcher; }); -// Mock fs readFile for content hashing -const mockReadFile = mock((path: string) => { +// Mock fs readFile for content hashing (injected via DI) +const mockReadFile = mock((path: string): Promise => { // Default: return path as content (deterministic hash) return Promise.resolve(Buffer.from(`content-of-${path}`)); }); -// Apply mocks before importing the module -void mock.module("chokidar", () => ({ - watch: mockWatch, -})); - -void mock.module("node:fs/promises", () => ({ - ...realFs, // Keep all real functions (access, readdir, etc.) - readFile: mockReadFile, // Override only what we need for testing - stat: mock(() => Promise.resolve({ isFile: () => true })), -})); +/** + * Create the mock dependencies for injection. + */ +function createMockDeps(): FileWatcherDependencies { + return { + watch: mockWatch as WatchFunction, + readFile: mockReadFile as ReadFileFunction, + }; +} // ============================================================================= // Test Helpers @@ -66,11 +85,15 @@ function createTestWatcher( onError?: (error: Error) => void; } = {} ): FileWatcher { - return new FileWatcher("/test/vault", { - debounceMs: options.debounceMs ?? 100, - onFilesChanged: options.onFilesChanged ?? (() => {}), - onError: options.onError, - }); + return new FileWatcher( + "/test/vault", + { + debounceMs: options.debounceMs ?? 100, + onFilesChanged: options.onFilesChanged ?? (() => {}), + onError: options.onError, + }, + createMockDeps() + ); } // Simulate file events from chokidar @@ -578,22 +601,28 @@ describe("FileWatcher Error Handling", () => { describe("createFileWatcher", () => { test("creates watcher with callback", () => { - const watcher = createFileWatcher("/test/vault", () => {}); + const watcher = createFileWatcher("/test/vault", () => {}, undefined, createMockDeps()); expect(watcher).toBeInstanceOf(FileWatcher); expect(watcher.getVaultPath()).toBe("/test/vault"); }); test("creates watcher with custom options", () => { - const watcher = createFileWatcher("/test/vault", () => {}, { - debounceMs: 750, - }); + const watcher = createFileWatcher( + "/test/vault", + () => {}, + { debounceMs: 750 }, + createMockDeps() + ); expect(watcher.getDebounceMs()).toBe(750); }); test("creates watcher with error handler", () => { - const watcher = createFileWatcher("/test/vault", () => {}, { - onError: () => {}, - }); + const watcher = createFileWatcher( + "/test/vault", + () => {}, + { onError: () => {} }, + createMockDeps() + ); expect(watcher).toBeInstanceOf(FileWatcher); }); }); diff --git a/backend/src/widgets/file-watcher.ts b/backend/src/widgets/file-watcher.ts index df2a079a..226cee54 100644 --- a/backend/src/widgets/file-watcher.ts +++ b/backend/src/widgets/file-watcher.ts @@ -11,15 +11,39 @@ * - TD-3: File Watcher Implementation using chokidar */ -import { watch, type FSWatcher } from "chokidar"; +import { watch as chokidarWatch, type FSWatcher, type ChokidarOptions } from "chokidar"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile as fsReadFile } from "node:fs/promises"; import { relative, join, extname } from "node:path"; import picomatch from "picomatch"; import { createLogger } from "../logger"; const log = createLogger("FileWatcher"); +// ============================================================================= +// Dependency Injection Types +// ============================================================================= + +/** + * Type for the chokidar watch function, to enable dependency injection for testing. + */ +export type WatchFunction = (path: string, options?: ChokidarOptions) => FSWatcher; + +/** + * Type for the fs readFile function, to enable dependency injection for testing. + */ +export type ReadFileFunction = (path: string) => Promise; + +/** + * Dependencies for FileWatcher (injectable for testing). + */ +export interface FileWatcherDependencies { + /** Chokidar watch function (default: chokidar.watch) */ + watch?: WatchFunction; + /** File read function (default: fs.readFile) */ + readFile?: ReadFileFunction; +} + // ============================================================================= // Types // ============================================================================= @@ -64,9 +88,12 @@ interface PendingChange { * Compute SHA-256 hash of file content. * Returns null if file cannot be read (deleted, permission denied, etc.). */ -async function computeContentHash(filePath: string): Promise { +async function computeContentHash( + filePath: string, + readFileFn: ReadFileFunction +): Promise { try { - const content = await readFile(filePath); + const content = await readFileFn(filePath); return createHash("sha256").update(content).digest("hex"); } catch { // File may have been deleted or is inaccessible @@ -107,6 +134,8 @@ export class FileWatcher { private readonly debounceMs: number; private readonly onFilesChanged: (paths: string[]) => void; private readonly onError: (error: Error) => void; + private readonly watchFn: WatchFunction; + private readonly readFileFn: ReadFileFunction; private watcher: FSWatcher | null = null; private contentHashes: Map = new Map(); @@ -116,11 +145,17 @@ export class FileWatcher { private isInitialScan = true; private patternMatcher: ((path: string) => boolean) | null = null; - constructor(vaultPath: string, options: FileWatcherOptions) { + constructor( + vaultPath: string, + options: FileWatcherOptions, + deps: FileWatcherDependencies = {} + ) { this.vaultPath = vaultPath; this.debounceMs = options.debounceMs ?? 500; this.onFilesChanged = options.onFilesChanged; this.onError = options.onError ?? ((error) => log.error(`Watcher error: ${error.message}`)); + this.watchFn = deps.watch ?? chokidarWatch; + this.readFileFn = deps.readFile ?? fsReadFile; if (this.debounceMs < 0) { throw new Error("debounceMs must be non-negative"); @@ -180,7 +215,7 @@ export class FileWatcher { // Watch the vault directory, filtering with the ignored option // This approach works better than glob patterns with chokidar - this.watcher = watch(this.vaultPath, { + this.watcher = this.watchFn(this.vaultPath, { persistent: true, ignoreInitial: false, // We want initial 'add' events to populate hashes awaitWriteFinish: { @@ -271,7 +306,7 @@ export class FileWatcher { // Hash all files without triggering callbacks for (const change of changes) { if (change.eventType === "add") { - const hash = await computeContentHash(change.absolutePath); + const hash = await computeContentHash(change.absolutePath, this.readFileFn); if (hash !== null) { this.contentHashes.set(change.absolutePath, hash); } @@ -415,7 +450,7 @@ export class FileWatcher { } // For add/change, compute new hash - const newHash = await computeContentHash(absolutePath); + const newHash = await computeContentHash(absolutePath, this.readFileFn); if (newHash === null) { // File became unreadable; treat as deleted @@ -463,15 +498,21 @@ export class FileWatcher { * @param vaultPath - Absolute path to vault root * @param onFilesChanged - Callback for changed file paths * @param options - Optional configuration overrides + * @param deps - Optional dependencies for testing * @returns FileWatcher instance (not started) */ export function createFileWatcher( vaultPath: string, onFilesChanged: (paths: string[]) => void, - options?: Partial> + options?: Partial>, + deps?: FileWatcherDependencies ): FileWatcher { - return new FileWatcher(vaultPath, { - ...options, - onFilesChanged, - }); + return new FileWatcher( + vaultPath, + { + ...options, + onFilesChanged, + }, + deps + ); } diff --git a/coverage-report/amber.png b/coverage-report/amber.png new file mode 100644 index 00000000..2cab170d Binary files /dev/null and b/coverage-report/amber.png differ diff --git a/coverage-report/cmd_line b/coverage-report/cmd_line new file mode 100644 index 00000000..86a81628 --- /dev/null +++ b/coverage-report/cmd_line @@ -0,0 +1 @@ +genhtml backend/coverage/lcov.info -o coverage-report diff --git a/coverage-report/emerald.png b/coverage-report/emerald.png new file mode 100644 index 00000000..38ad4f40 Binary files /dev/null and b/coverage-report/emerald.png differ diff --git a/coverage-report/gcov.css b/coverage-report/gcov.css new file mode 100644 index 00000000..1cacc835 --- /dev/null +++ b/coverage-report/gcov.css @@ -0,0 +1,1125 @@ +/* All views: initial background and text color */ +body +{ + color: #000000; + background-color: #ffffff; +} + +/* All views: standard link format*/ +a:link +{ + color: #284fa8; + text-decoration: underline; +} + +/* All views: standard link - visited format */ +a:visited +{ + color: #00cb40; + text-decoration: underline; +} + +/* All views: standard link - activated format */ +a:active +{ + color: #ff0040; + text-decoration: underline; +} + +/* All views: main title format */ +td.title +{ + text-align: center; + padding-bottom: 10px; + font-family: sans-serif; + font-size: 20pt; + font-style: italic; + font-weight: bold; +} +/* table footnote */ +td.footnote +{ + text-align: left; + padding-left: 100px; + padding-right: 10px; + background-color: #dae7fe; /* light blue table background color */ + /* dark blue table header color + background-color: #6688d4; */ + white-space: nowrap; + font-family: sans-serif; + font-style: italic; + font-size:70%; +} +/* "Line coverage date bins" leader */ +td.subTableHeader +{ + text-align: center; + padding-bottom: 6px; + font-family: sans-serif; + font-weight: bold; + vertical-align: center; +} + +/* All views: header item format */ +td.headerItem +{ + text-align: right; + padding-right: 6px; + font-family: sans-serif; + font-weight: bold; + vertical-align: top; + white-space: nowrap; +} + +/* All views: header item value format */ +td.headerValue +{ + text-align: left; + color: #284fa8; + font-family: sans-serif; + font-weight: bold; + white-space: nowrap; +} + +/* All views: header item coverage table heading */ +td.headerCovTableHead +{ + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; +} + +/* All views: header item coverage table entry */ +td.headerCovTableEntry +{ + text-align: right; + color: #284fa8; + font-family: sans-serif; + font-weight: bold; + white-space: nowrap; + padding-left: 12px; + padding-right: 4px; + background-color: #dae7fe; +} + +/* All views: header item coverage table entry for high coverage rate */ +td.headerCovTableEntryHi +{ + text-align: right; + color: #000000; + font-family: sans-serif; + font-weight: bold; + white-space: nowrap; + padding-left: 12px; + padding-right: 4px; + background-color: #a7fc9d; +} + +/* All views: header item coverage table entry for medium coverage rate */ +td.headerCovTableEntryMed +{ + text-align: right; + color: #000000; + font-family: sans-serif; + font-weight: bold; + white-space: nowrap; + padding-left: 12px; + padding-right: 4px; + background-color: #ffea20; +} + +/* All views: header item coverage table entry for ow coverage rate */ +td.headerCovTableEntryLo +{ + text-align: right; + color: #000000; + font-family: sans-serif; + font-weight: bold; + white-space: nowrap; + padding-left: 12px; + padding-right: 4px; + background-color: #ff0000; +} + +/* All views: header legend value for legend entry */ +td.headerValueLeg +{ + text-align: left; + color: #000000; + font-family: sans-serif; + font-size: 80%; + white-space: nowrap; + padding-top: 4px; +} + +/* All views: color of horizontal ruler */ +td.ruler +{ + background-color: #6688d4; +} + +/* All views: version string format */ +td.versionInfo +{ + text-align: center; + padding-top: 2px; + font-family: sans-serif; + font-style: italic; +} + +/* Directory view/File view (all)/Test case descriptions: + table headline format */ +td.tableHead +{ + text-align: center; + color: #ffffff; + background-color: #6688d4; + font-family: sans-serif; + font-size: 120%; + font-weight: bold; + white-space: nowrap; + padding-left: 4px; + padding-right: 4px; +} + +span.tableHeadSort +{ + padding-right: 4px; +} + +/* Directory view/File view (all): filename entry format */ +td.coverFile +{ + text-align: left; + padding-left: 10px; + padding-right: 20px; + color: #284fa8; + background-color: #dae7fe; + font-family: monospace; +} + +/* Directory view/File view (all): directory name entry format */ +td.coverDirectory +{ + text-align: left; + padding-left: 10px; + padding-right: 20px; + color: #284fa8; + background-color: #b8d0ff; + font-family: monospace; +} + +/* Directory view/File view (all): filename entry format */ +td.overallOwner +{ + text-align: center; + font-weight: bold; + font-family: sans-serif; + background-color: #dae7fe; + padding-right: 10px; + padding-left: 10px; +} + +/* Directory view/File view (all): filename entry format */ +td.ownerName +{ + text-align: right; + font-style: italic; + font-family: sans-serif; + background-color: #E5DBDB; + padding-right: 10px; + padding-left: 20px; +} + +/* Directory view/File view (all): bar-graph entry format*/ +td.coverBar +{ + padding-left: 10px; + padding-right: 10px; + background-color: #dae7fe; +} + +/* Directory view/File view (all): bar-graph entry format*/ +td.owner_coverBar +{ + padding-left: 10px; + padding-right: 10px; + background-color: #E5DBDB; +} + +/* Directory view/File view (all): bar-graph outline color */ +td.coverBarOutline +{ + background-color: #000000; +} + +/* Directory view/File view (all): percentage entry for files with + high coverage rate */ +td.coverPerHi +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #a7fc9d; + font-weight: bold; + font-family: sans-serif; +} + +/* 'owner' entry: slightly lighter color than 'coverPerHi' */ +td.owner_coverPerHi +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #82E0AA; + font-weight: bold; + font-family: sans-serif; +} + +/* Directory view/File view (all): line count entry */ +td.coverNumDflt +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #dae7fe; + white-space: nowrap; + font-family: sans-serif; +} + +/* td background color and font for the 'owner' section of the table */ +td.ownerTla +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #E5DBDB; + white-space: nowrap; + font-family: sans-serif; + font-style: italic; +} + +/* Directory view/File view (all): line count entry for files with + high coverage rate */ +td.coverNumHi +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #a7fc9d; + white-space: nowrap; + font-family: sans-serif; +} + +td.owner_coverNumHi +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #82E0AA; + white-space: nowrap; + font-family: sans-serif; +} + +/* Directory view/File view (all): percentage entry for files with + medium coverage rate */ +td.coverPerMed +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #ffea20; + font-weight: bold; + font-family: sans-serif; +} + +td.owner_coverPerMed +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #F9E79F; + font-weight: bold; + font-family: sans-serif; +} + +/* Directory view/File view (all): line count entry for files with + medium coverage rate */ +td.coverNumMed +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #ffea20; + white-space: nowrap; + font-family: sans-serif; +} + +td.owner_coverNumMed +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #F9E79F; + white-space: nowrap; + font-family: sans-serif; +} + +/* Directory view/File view (all): percentage entry for files with + low coverage rate */ +td.coverPerLo +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #ff0000; + font-weight: bold; + font-family: sans-serif; +} + +td.owner_coverPerLo +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #EC7063; + font-weight: bold; + font-family: sans-serif; +} + +/* Directory view/File view (all): line count entry for files with + low coverage rate */ +td.coverNumLo +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #ff0000; + white-space: nowrap; + font-family: sans-serif; +} + +td.owner_coverNumLo +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #EC7063; + white-space: nowrap; + font-family: sans-serif; +} + +/* File view (all): "show/hide details" link format */ +a.detail:link +{ + color: #b8d0ff; + font-size:80%; +} + +/* File view (all): "show/hide details" link - visited format */ +a.detail:visited +{ + color: #b8d0ff; + font-size:80%; +} + +/* File view (all): "show/hide details" link - activated format */ +a.detail:active +{ + color: #ffffff; + font-size:80%; +} + +/* File view (detail): test name entry */ +td.testName +{ + text-align: right; + padding-right: 10px; + background-color: #dae7fe; + font-family: sans-serif; +} + +/* File view (detail): test percentage entry */ +td.testPer +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #dae7fe; + font-family: sans-serif; +} + +/* File view (detail): test lines count entry */ +td.testNum +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #dae7fe; + font-family: sans-serif; +} + +/* Test case descriptions: test name format*/ +dt +{ + font-family: sans-serif; + font-weight: bold; +} + +/* Test case descriptions: description table body */ +td.testDescription +{ + padding-top: 10px; + padding-left: 30px; + padding-bottom: 10px; + padding-right: 30px; + background-color: #dae7fe; +} + +/* Source code view: function entry */ +td.coverFn +{ + text-align: left; + padding-left: 10px; + padding-right: 20px; + color: #284fa8; + background-color: #dae7fe; + font-family: monospace; +} + +/* Source code view: function entry zero count*/ +td.coverFnLo +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #ff0000; + font-weight: bold; + font-family: sans-serif; +} + +/* Source code view: function entry nonzero count*/ +td.coverFnHi +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #dae7fe; + font-weight: bold; + font-family: sans-serif; +} + +td.coverFnAlias +{ + text-align: right; + padding-left: 10px; + padding-right: 20px; + color: #284fa8; + /* make this a slightly different color than the leader - otherwise, + otherwise the alias is hard to distinguish in the table */ + background-color: #E5DBDB; /* very light pale grey/blue */ + font-family: monospace; +} + +/* Source code view: function entry zero count*/ +td.coverFnAliasLo +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #EC7063; /* lighter red */ + font-family: sans-serif; +} + +/* Source code view: function entry nonzero count*/ +td.coverFnAliasHi +{ + text-align: right; + padding-left: 10px; + padding-right: 10px; + background-color: #dae7fe; + font-weight: bold; + font-family: sans-serif; +} + +/* Source code view: source code format */ +pre.source +{ + font-family: monospace; + white-space: pre; + margin-top: 2px; +} + +/* elided/removed code */ +span.elidedSource +{ + font-family: sans-serif; + /*font-size: 8pt; */ + font-style: italic; + background-color: lightgrey; +} + +/* Source code view: line number format */ +span.lineNum +{ + background-color: #efe383; +} + +/* Source code view: line number format when there are deleted + lines in the corresponding location */ +span.lineNumWithDelete +{ + foreground-color: #efe383; + background-color: lightgrey; +} + +/* Source code view: format for Cov legend */ +span.coverLegendCov +{ + padding-left: 10px; + padding-right: 10px; + padding-bottom: 2px; + background-color: #cad7fe; +} + +/* Source code view: format for NoCov legend */ +span.coverLegendNoCov +{ + padding-left: 10px; + padding-right: 10px; + padding-bottom: 2px; + background-color: #ff6230; +} + +/* Source code view: format for the source code heading line */ +pre.sourceHeading +{ + white-space: pre; + font-family: monospace; + font-weight: bold; + margin: 0px; +} + +/* All views: header legend value for low rate */ +td.headerValueLegL +{ + font-family: sans-serif; + text-align: center; + white-space: nowrap; + padding-left: 4px; + padding-right: 2px; + background-color: #ff0000; + font-size: 80%; +} + +/* All views: header legend value for med rate */ +td.headerValueLegM +{ + font-family: sans-serif; + text-align: center; + white-space: nowrap; + padding-left: 2px; + padding-right: 2px; + background-color: #ffea20; + font-size: 80%; +} + +/* All views: header legend value for hi rate */ +td.headerValueLegH +{ + font-family: sans-serif; + text-align: center; + white-space: nowrap; + padding-left: 2px; + padding-right: 4px; + background-color: #a7fc9d; + font-size: 80%; +} + +/* All views except source code view: legend format for low coverage */ +span.coverLegendCovLo +{ + padding-left: 10px; + padding-right: 10px; + padding-top: 2px; + background-color: #ff0000; +} + +/* All views except source code view: legend format for med coverage */ +span.coverLegendCovMed +{ + padding-left: 10px; + padding-right: 10px; + padding-top: 2px; + background-color: #ffea20; +} + +/* All views except source code view: legend format for hi coverage */ +span.coverLegendCovHi +{ + padding-left: 10px; + padding-right: 10px; + padding-top: 2px; + background-color: #a7fc9d; +} + +a.branchTla:link +{ + color: #000000; +} + +a.branchTla:visited +{ + color: #000000; +} + +a.mcdcTla:link +{ + color: #000000; +} + +a.mcdcTla:visited +{ + color: #000000; +} + +/* Source code view/table entry background: format for lines classified as "Uncovered New Code (+ => 0): +Newly added code is not tested" */ +td.tlaUNC +{ + text-align: right; + background-color: #FF6230; +} +td.tlaBgUNC { + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Uncovered New Code (+ => 0): +Newly added code is not tested" */ +span.tlaUNC +{ + text-align: left; + background-color: #FF6230; +} +span.tlaBgUNC { + background-color: #FF6230; +} +a.tlaBgUNC { + background-color: #FF6230; + color: #000000; +} + +td.headerCovTableHeadUNC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Lost Baseline Coverage (1 => 0): +Unchanged code is no longer tested" */ +td.tlaLBC +{ + text-align: right; + background-color: #FF6230; +} +td.tlaBgLBC { + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Lost Baseline Coverage (1 => 0): +Unchanged code is no longer tested" */ +span.tlaLBC +{ + text-align: left; + background-color: #FF6230; +} +span.tlaBgLBC { + background-color: #FF6230; +} +a.tlaBgLBC { + background-color: #FF6230; + color: #000000; +} + +td.headerCovTableHeadLBC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Uncovered Included Code (# => 0): +Previously unused code is untested" */ +td.tlaUIC +{ + text-align: right; + background-color: #FF6230; +} +td.tlaBgUIC { + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Uncovered Included Code (# => 0): +Previously unused code is untested" */ +span.tlaUIC +{ + text-align: left; + background-color: #FF6230; +} +span.tlaBgUIC { + background-color: #FF6230; +} +a.tlaBgUIC { + background-color: #FF6230; + color: #000000; +} + +td.headerCovTableHeadUIC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Uncovered Baseline Code (0 => 0): +Unchanged code was untested before, is untested now" */ +td.tlaUBC +{ + text-align: right; + background-color: #FF6230; +} +td.tlaBgUBC { + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Uncovered Baseline Code (0 => 0): +Unchanged code was untested before, is untested now" */ +span.tlaUBC +{ + text-align: left; + background-color: #FF6230; +} +span.tlaBgUBC { + background-color: #FF6230; +} +a.tlaBgUBC { + background-color: #FF6230; + color: #000000; +} + +td.headerCovTableHeadUBC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FF6230; +} + +/* Source code view/table entry background: format for lines classified as "Gained Baseline Coverage (0 => 1): +Unchanged code is tested now" */ +td.tlaGBC +{ + text-align: right; + background-color: #CAD7FE; +} +td.tlaBgGBC { + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Gained Baseline Coverage (0 => 1): +Unchanged code is tested now" */ +span.tlaGBC +{ + text-align: left; + background-color: #CAD7FE; +} +span.tlaBgGBC { + background-color: #CAD7FE; +} +a.tlaBgGBC { + background-color: #CAD7FE; + color: #000000; +} + +td.headerCovTableHeadGBC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Gained Included Coverage (# => 1): +Previously unused code is tested now" */ +td.tlaGIC +{ + text-align: right; + background-color: #CAD7FE; +} +td.tlaBgGIC { + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Gained Included Coverage (# => 1): +Previously unused code is tested now" */ +span.tlaGIC +{ + text-align: left; + background-color: #CAD7FE; +} +span.tlaBgGIC { + background-color: #CAD7FE; +} +a.tlaBgGIC { + background-color: #CAD7FE; + color: #000000; +} + +td.headerCovTableHeadGIC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Gained New Coverage (+ => 1): +Newly added code is tested" */ +td.tlaGNC +{ + text-align: right; + background-color: #CAD7FE; +} +td.tlaBgGNC { + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Gained New Coverage (+ => 1): +Newly added code is tested" */ +span.tlaGNC +{ + text-align: left; + background-color: #CAD7FE; +} +span.tlaBgGNC { + background-color: #CAD7FE; +} +a.tlaBgGNC { + background-color: #CAD7FE; + color: #000000; +} + +td.headerCovTableHeadGNC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Covered Baseline Code (1 => 1): +Unchanged code was tested before and is still tested" */ +td.tlaCBC +{ + text-align: right; + background-color: #CAD7FE; +} +td.tlaBgCBC { + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Covered Baseline Code (1 => 1): +Unchanged code was tested before and is still tested" */ +span.tlaCBC +{ + text-align: left; + background-color: #CAD7FE; +} +span.tlaBgCBC { + background-color: #CAD7FE; +} +a.tlaBgCBC { + background-color: #CAD7FE; + color: #000000; +} + +td.headerCovTableHeadCBC { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #CAD7FE; +} + +/* Source code view/table entry background: format for lines classified as "Excluded Uncovered Baseline (0 => #): +Previously untested code is unused now" */ +td.tlaEUB +{ + text-align: right; + background-color: #FFFFFF; +} +td.tlaBgEUB { + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Excluded Uncovered Baseline (0 => #): +Previously untested code is unused now" */ +span.tlaEUB +{ + text-align: left; + background-color: #FFFFFF; +} +span.tlaBgEUB { + background-color: #FFFFFF; +} +a.tlaBgEUB { + background-color: #FFFFFF; + color: #000000; +} + +td.headerCovTableHeadEUB { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Excluded Covered Baseline (1 => #): +Previously tested code is unused now" */ +td.tlaECB +{ + text-align: right; + background-color: #FFFFFF; +} +td.tlaBgECB { + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Excluded Covered Baseline (1 => #): +Previously tested code is unused now" */ +span.tlaECB +{ + text-align: left; + background-color: #FFFFFF; +} +span.tlaBgECB { + background-color: #FFFFFF; +} +a.tlaBgECB { + background-color: #FFFFFF; + color: #000000; +} + +td.headerCovTableHeadECB { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Deleted Uncovered Baseline (0 => -): +Previously untested code has been deleted" */ +td.tlaDUB +{ + text-align: right; + background-color: #FFFFFF; +} +td.tlaBgDUB { + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Deleted Uncovered Baseline (0 => -): +Previously untested code has been deleted" */ +span.tlaDUB +{ + text-align: left; + background-color: #FFFFFF; +} +span.tlaBgDUB { + background-color: #FFFFFF; +} +a.tlaBgDUB { + background-color: #FFFFFF; + color: #000000; +} + +td.headerCovTableHeadDUB { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Deleted Covered Baseline (1 => -): +Previously tested code has been deleted" */ +td.tlaDCB +{ + text-align: right; + background-color: #FFFFFF; +} +td.tlaBgDCB { + background-color: #FFFFFF; +} + +/* Source code view/table entry background: format for lines classified as "Deleted Covered Baseline (1 => -): +Previously tested code has been deleted" */ +span.tlaDCB +{ + text-align: left; + background-color: #FFFFFF; +} +span.tlaBgDCB { + background-color: #FFFFFF; +} +a.tlaBgDCB { + background-color: #FFFFFF; + color: #000000; +} + +td.headerCovTableHeadDCB { + text-align: center; + padding-right: 6px; + padding-left: 6px; + padding-bottom: 0px; + font-family: sans-serif; + white-space: nowrap; + background-color: #FFFFFF; +} + +/* Source code view: format for date/owner bin that is not hit */ +span.missBins +{ + background-color: #ff0000 /* red */ +} diff --git a/coverage-report/glass.png b/coverage-report/glass.png new file mode 100644 index 00000000..e1abc006 Binary files /dev/null and b/coverage-report/glass.png differ diff --git a/coverage-report/ruby.png b/coverage-report/ruby.png new file mode 100644 index 00000000..991b6d4e Binary files /dev/null and b/coverage-report/ruby.png differ diff --git a/coverage-report/snow.png b/coverage-report/snow.png new file mode 100644 index 00000000..2cdae107 Binary files /dev/null and b/coverage-report/snow.png differ diff --git a/coverage-report/updown.png b/coverage-report/updown.png new file mode 100644 index 00000000..aa56a238 Binary files /dev/null and b/coverage-report/updown.png differ diff --git a/frontend/package.json b/frontend/package.json index 268396d4..2e6aeaee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "test": "bun test", "test:coverage": "bun test --coverage", + "test:lcov": "lcov --list coverage/lcov.info", "typecheck": "tsc --noEmit", "lint": "eslint ." }, diff --git a/package.json b/package.json index 55033348..759dfbed 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "bun run --filter '*' lint", "test": "bun run --cwd backend test && bun run --cwd frontend test && bun run --cwd shared test", "test:coverage": "bun run --cwd backend test:coverage && bun run --cwd frontend test:coverage && bun run --cwd shared test:coverage", + "test:lcov": "bun run --cwd backend test:lcov && bun run --cwd frontend test:lcov && bun run --cwd shared test:lcov", "typecheck": "bun run --filter '*' typecheck" }, "devDependencies": { diff --git a/shared/package.json b/shared/package.json index 1843e24e..fa03e362 100644 --- a/shared/package.json +++ b/shared/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit", "test": "bun test", "test:coverage": "bun test --coverage", + "test:lcov": "lcov --list coverage/lcov.info", "lint": "eslint ." }, "dependencies": {