diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/comprehensive-sync.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/comprehensive-sync.test.ts index 78d072f7e..8b30bfd7b 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/comprehensive-sync.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/comprehensive-sync.test.ts @@ -1,21 +1,18 @@ import { - TaskBuilder, - ParsedTaskBuilder, - MarkdownBuilder, - createMockConfig, createMockPluginAPI, createTaskHierarchy, generateLargeMarkdown, + MarkdownBuilder, measureExecutionTime, + ParsedTaskBuilder, + TaskBuilder, } from '../test-utils'; -import { parseMarkdown } from '../../sync/markdown-parser'; +import { parseMarkdown, parseMarkdownWithHeader } from '../../sync/markdown-parser'; import { generateTaskOperations } from '../../sync/generate-task-operations'; import { convertTasksToMarkdown } from '../../sync/sp-to-md'; import { mdToSp } from '../../sync/md-to-sp'; -import { spToMd } from '../../sync/sp-to-md'; // Mock dependencies -jest.mock('../../sync/markdown-parser'); jest.mock('../../sync/generate-task-operations'); jest.mock('../../helper/file-utils'); @@ -117,44 +114,7 @@ describe('Comprehensive Sync Tests', () => { .addTask('Add animations', { id: 'animations', indent: 4 }) .build(); - // Mock parser to return appropriate structure - (parseMarkdown as jest.Mock).mockReturnValue([ - new ParsedTaskBuilder() - .withId('overview') - .withTitle('Project Overview') - .withNotes('This project contains multiple features') - .build(), - new ParsedTaskBuilder().withId('feature1').withTitle('Feature 1').build(), - new ParsedTaskBuilder() - .withId('api') - .withTitle('Implement API') - .withParentId('feature1') - .withIsSubtask(true) - .build(), - new ParsedTaskBuilder() - .withId('tests') - .withTitle('Write tests') - .withParentId('feature1') - .withIsSubtask(true) - .withCompleted(true) - .withNotes('Unit and integration tests') - .build(), - new ParsedTaskBuilder().withId('feature2').withTitle('Feature 2').build(), - new ParsedTaskBuilder() - .withId('ui') - .withTitle('Design UI') - .withParentId('feature2') - .withIsSubtask(true) - .build(), - new ParsedTaskBuilder() - .withId('animations') - .withTitle('Add animations') - .withParentId('ui') - .withIsSubtask(true) - .build(), - ]); - - // Mock operations + // Mock operations instead of parser since we're testing integration (generateTaskOperations as jest.Mock).mockReturnValue([ { type: 'create', @@ -165,7 +125,6 @@ describe('Comprehensive Sync Tests', () => { await mdToSp(markdown, 'test-project'); - expect(parseMarkdown).toHaveBeenCalledWith(markdown); expect(generateTaskOperations).toHaveBeenCalled(); expect(mockPluginAPI.batchUpdateForProject).toHaveBeenCalled(); }); @@ -175,20 +134,12 @@ describe('Comprehensive Sync Tests', () => { it('should handle large markdown efficiently', async () => { const largeMarkdown = generateLargeMarkdown(1000); - // Mock parseMarkdown to simulate parsing - (parseMarkdown as jest.Mock).mockImplementation((content) => { - // Simulate parsing time - const lines = content.split('\n'); - return lines - .filter((line: string) => line.match(/^[\s]*- \[/)) - .map((line: string, index: number) => - new ParsedTaskBuilder().withLine(index).withTitle(`Task ${index}`).build(), - ); - }); - + // Test actual parsing performance without mocking const time = await measureExecutionTime(() => { const result = parseMarkdown(largeMarkdown); - expect(result.length).toBeGreaterThan(900); // Some have notes + // The parser only returns tasks at depth 0 and 1 (parents and subtasks) + // Tasks with indent >= 4 (depth >= 2) are converted to notes + expect(result.length).toBeGreaterThan(200); // Should have many tasks }); expect(time).toBeLessThan(100); // Should parse in under 100ms @@ -201,9 +152,9 @@ describe('Comprehensive Sync Tests', () => { }); // Convert to markdown - const markdownTime = await measureExecutionTime(() => - convertTasksToMarkdown(tasks), - ); + const markdownTime = await measureExecutionTime(() => { + convertTasksToMarkdown(tasks); + }); expect(markdownTime).toBeLessThan(50); @@ -216,9 +167,9 @@ describe('Comprehensive Sync Tests', () => { })), ); - const opsTime = await measureExecutionTime(() => - generateTaskOperations([], tasks, 'test-project'), - ); + const opsTime = await measureExecutionTime(() => { + generateTaskOperations([], tasks, 'test-project'); + }); expect(opsTime).toBeLessThan(50); }); @@ -323,20 +274,9 @@ describe('Comprehensive Sync Tests', () => { // First cycle: SP to MD const markdown1 = convertTasksToMarkdown(initialTasks); - // Mock parse to return equivalent structure - (parseMarkdown as jest.Mock).mockReturnValue( - initialTasks.map((t) => - new ParsedTaskBuilder() - .withId(t.id) - .withTitle(t.title) - .withCompleted(t.isDone) - .withParentId(t.parentId) - .build(), - ), - ); - - // Second cycle: MD to SP - const parsed = parseMarkdown(markdown1); + // Second cycle: MD to SP - parse the actual markdown + const result = parseMarkdownWithHeader(markdown1); + const parsed = result.tasks; // Verify consistency expect(parsed).toHaveLength(initialTasks.length); diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/error-scenarios.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/error-scenarios.test.ts index 49a35a783..9f4e6ef06 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/error-scenarios.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/error-scenarios.test.ts @@ -1,6 +1,5 @@ import { parseMarkdown, parseMarkdownWithErrors } from '../../sync/markdown-parser'; import { generateTaskOperations } from '../../sync/generate-task-operations'; -import { Task } from '@super-productivity/plugin-api'; describe('Error Scenarios and Boundary Conditions', () => { describe('Markdown Parser - Error Scenarios', () => { @@ -313,7 +312,7 @@ describe('Error Scenarios and Boundary Conditions', () => { maliciousPatterns.forEach((pattern) => { const start = Date.now(); - const tasks = parseMarkdown(pattern); + parseMarkdown(pattern); const duration = Date.now() - start; // Should not hang or take excessive time diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/file-watcher.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/file-watcher.test.ts index 6c8788b9c..9c28ff52e 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/file-watcher.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/file-watcher.test.ts @@ -1,9 +1,8 @@ import { startFileWatcher, stopFileWatcher } from '../../sync/file-watcher'; -import * as path from 'path'; // Set up Node.js environment for tests if (typeof setImmediate === 'undefined') { - (global as any).setImmediate = (fn: Function) => setTimeout(fn, 0); + (global as any).setImmediate = (fn: () => void) => setTimeout(fn, 0); } // Mock PluginAPI globally diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/md-to-sp-complete.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/md-to-sp-complete.test.ts index 68ad651c9..3b2534f25 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/md-to-sp-complete.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/md-to-sp-complete.test.ts @@ -1,5 +1,5 @@ import { mdToSp } from '../../sync/md-to-sp'; -import { parseMarkdown } from '../../sync/markdown-parser'; +import { parseMarkdownWithHeader } from '../../sync/markdown-parser'; import { generateTaskOperations } from '../../sync/generate-task-operations'; import { Task } from '@super-productivity/plugin-api'; @@ -12,6 +12,19 @@ const mockPluginAPI = { getTasks: jest.fn(), batchUpdateForProject: jest.fn(), getAllProjects: jest.fn(), + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, + persistDataSynced: jest.fn(), + loadSyncedData: jest.fn(), }; (global as any).PluginAPI = mockPluginAPI; @@ -71,12 +84,15 @@ describe('MD to SP Sync - Complete Tests', () => { }, ]; - (parseMarkdown as jest.Mock).mockReturnValue(mockParsedTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: mockParsedTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockReturnValue(mockOperations); await mdToSp(mockMarkdownContent, mockProjectId); - expect(parseMarkdown).toHaveBeenCalledWith(mockMarkdownContent); + expect(parseMarkdownWithHeader).toHaveBeenCalledWith(mockMarkdownContent); expect(mockPluginAPI.getTasks).toHaveBeenCalledWith(); expect(generateTaskOperations).toHaveBeenCalledWith( mockParsedTasks, @@ -90,12 +106,12 @@ describe('MD to SP Sync - Complete Tests', () => { }); it('should handle empty markdown content', async () => { - (parseMarkdown as jest.Mock).mockReturnValue([]); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ tasks: [], errors: [] }); (generateTaskOperations as jest.Mock).mockReturnValue([]); await mdToSp('', mockProjectId); - expect(parseMarkdown).toHaveBeenCalledWith(''); + expect(parseMarkdownWithHeader).toHaveBeenCalledWith(''); // batchUpdateForProject won't be called with empty operations expect(mockPluginAPI.batchUpdateForProject).not.toHaveBeenCalled(); }); @@ -103,12 +119,12 @@ describe('MD to SP Sync - Complete Tests', () => { it('should handle markdown with only whitespace', async () => { const whitespaceContent = ' \n\n \t '; - (parseMarkdown as jest.Mock).mockReturnValue([]); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ tasks: [], errors: [] }); (generateTaskOperations as jest.Mock).mockReturnValue([]); await mdToSp(whitespaceContent, mockProjectId); - expect(parseMarkdown).toHaveBeenCalledWith(whitespaceContent); + expect(parseMarkdownWithHeader).toHaveBeenCalledWith(whitespaceContent); // batchUpdateForProject won't be called with empty operations expect(mockPluginAPI.batchUpdateForProject).not.toHaveBeenCalled(); }); @@ -150,7 +166,10 @@ describe('MD to SP Sync - Complete Tests', () => { ]; mockPluginAPI.getTasks.mockResolvedValue(existingTasks); - (parseMarkdown as jest.Mock).mockReturnValue(mockParsedTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: mockParsedTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockReturnValue(mockOperations); await mdToSp(mockMarkdownContent, mockProjectId); @@ -190,7 +209,7 @@ describe('MD to SP Sync - Complete Tests', () => { it('should handle parseMarkdown error', async () => { const error = new Error('Parse error'); - (parseMarkdown as jest.Mock).mockImplementation(() => { + (parseMarkdownWithHeader as jest.Mock).mockImplementation(() => { throw error; }); @@ -211,7 +230,10 @@ describe('MD to SP Sync - Complete Tests', () => { }, ]; - (parseMarkdown as jest.Mock).mockReturnValue(parsedTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: parsedTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockReturnValue([ { type: 'create', data: { title: 'test' } }, ]); @@ -238,7 +260,10 @@ describe('MD to SP Sync - Complete Tests', () => { }, })); - (parseMarkdown as jest.Mock).mockReturnValue(largeTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: largeTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockReturnValue(largeOperations); await mdToSp('large content', mockProjectId); @@ -277,7 +302,10 @@ describe('MD to SP Sync - Complete Tests', () => { }, ]; - (parseMarkdown as jest.Mock).mockReturnValue(orderedTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: orderedTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockImplementation((tasks) => { // Verify tasks are passed in the correct order expect(tasks).toEqual(orderedTasks); @@ -314,7 +342,10 @@ describe('MD to SP Sync - Complete Tests', () => { }, ]; - (parseMarkdown as jest.Mock).mockReturnValue(specialTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: specialTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockReturnValue([]); await mdToSp(mockMarkdownContent, mockProjectId); @@ -352,7 +383,10 @@ describe('MD to SP Sync - Complete Tests', () => { }, ]; - (parseMarkdown as jest.Mock).mockReturnValue(nestedTasks); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ + tasks: nestedTasks, + errors: [], + }); (generateTaskOperations as jest.Mock).mockReturnValue([]); await mdToSp(mockMarkdownContent, mockProjectId); @@ -361,7 +395,7 @@ describe('MD to SP Sync - Complete Tests', () => { }); it('should skip sync when no operations are needed', async () => { - (parseMarkdown as jest.Mock).mockReturnValue([]); + (parseMarkdownWithHeader as jest.Mock).mockReturnValue({ tasks: [], errors: [] }); (generateTaskOperations as jest.Mock).mockReturnValue([]); await mdToSp(mockMarkdownContent, mockProjectId); diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/sp-to-md.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/sp-to-md.test.ts index 848bc6335..9577ebf47 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/sp-to-md.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/sp-to-md.test.ts @@ -5,18 +5,43 @@ import { Task } from '@super-productivity/plugin-api'; // Mock dependencies jest.mock('../../helper/file-utils'); +jest.mock('../../../shared/logger', () => ({ + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); // Mock PluginAPI -global.PluginAPI = { +(global as any).PluginAPI = { getTasks: jest.fn(), getAllProjects: jest.fn(), + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, + persistDataSynced: jest.fn(), + loadSyncedData: jest.fn(), } as any; describe('spToMd', () => { const mockConfig: LocalUserCfg = { filePath: '/test/tasks.md', projectId: 'test-project', - enabled: true, }; beforeEach(() => { @@ -66,8 +91,10 @@ describe('spToMd', () => { }, ]; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue( + mockProjects, + ); await spToMd(mockConfig); @@ -131,8 +158,10 @@ describe('spToMd', () => { }, ]; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue( + mockProjects, + ); await spToMd(mockConfig); @@ -185,8 +214,10 @@ describe('spToMd', () => { }, ]; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue( + mockProjects, + ); await spToMd(mockConfig); diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-debounce.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-debounce.test.ts index d5f7bf36e..8d970a3ec 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-debounce.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-debounce.test.ts @@ -10,6 +10,19 @@ jest.mock('../../sync/sp-to-md'); jest.mock('../../sync/md-to-sp'); jest.mock('../../helper/file-utils'); jest.mock('../../sync/verify-sync'); +jest.mock('../../../shared/logger', () => ({ + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); // Import after mocking import { spToMd } from '../../sync/sp-to-md'; @@ -21,6 +34,23 @@ import { } from '../../helper/file-utils'; import { verifySyncState, logSyncVerification } from '../../sync/verify-sync'; +// Mock PluginAPI with log methods +(global as any).PluginAPI = { + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, + persistDataSynced: jest.fn(), + loadSyncedData: jest.fn(), +}; + // Mock console to reduce noise beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); @@ -55,8 +85,8 @@ describe('Sync Manager Debounce Behavior', () => { return Promise.resolve(); }); (mdToSp as jest.Mock).mockImplementation(() => { - // Return a resolved promise - return Promise.resolve(); + // Return a resolved promise with the expected structure + return Promise.resolve({ header: undefined }); }); (verifySyncState as jest.Mock).mockResolvedValue({ isInSync: true, differences: [] }); (logSyncVerification as jest.Mock).mockReturnValue(undefined); diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-edge-cases.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-edge-cases.test.ts index 85e0f3ff0..97f8b4a66 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-edge-cases.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/sync-manager-edge-cases.test.ts @@ -5,7 +5,6 @@ import * as fileUtils from '../../helper/file-utils'; import { spToMd } from '../../sync/sp-to-md'; import { mdToSp } from '../../sync/md-to-sp'; import { verifySyncState, logSyncVerification } from '../../sync/verify-sync'; -import { PluginHooks } from '@super-productivity/plugin-api'; // Mock dependencies jest.mock('../../sync/file-watcher'); @@ -13,6 +12,38 @@ jest.mock('../../helper/file-utils'); jest.mock('../../sync/sp-to-md'); jest.mock('../../sync/md-to-sp'); jest.mock('../../sync/verify-sync'); +jest.mock('../../../shared/logger', () => ({ + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +// Mock PluginAPI +(global as any).PluginAPI = { + registerHook: jest.fn(), + onWindowFocusChange: jest.fn(), + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, + persistDataSynced: jest.fn(), + loadSyncedData: jest.fn(), +}; describe('Sync Manager - Edge Cases', () => { const mockConfig: LocalUserCfg = { diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/sync/verify-sync.test.ts b/packages/plugin-dev/sync-md/src/background/__tests__/sync/verify-sync.test.ts index d1538213b..a12216256 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/sync/verify-sync.test.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/sync/verify-sync.test.ts @@ -7,7 +7,7 @@ import { Task } from '@super-productivity/plugin-api'; jest.mock('../../helper/file-utils'); // Mock PluginAPI -global.PluginAPI = { +(global as any).PluginAPI = { getTasks: jest.fn(), getAllProjects: jest.fn(), } as any; @@ -20,7 +20,6 @@ describe('verifySyncState', () => { const mockConfig: LocalUserCfg = { filePath: '/test/tasks.md', projectId: 'test-project', - enabled: true, }; beforeEach(() => { @@ -55,8 +54,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1 - [x] Task 2`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -85,8 +84,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -114,8 +113,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1 - [ ] Task 2`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -142,8 +141,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1 MD`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -171,8 +170,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -216,8 +215,10 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 2 - [ ] Task 1`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue(mockProjects); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue( + mockProjects, + ); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -261,8 +262,8 @@ describe('verifySyncState', () => { - [ ] Child 2 - [ ] Child 1`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -288,8 +289,8 @@ describe('verifySyncState', () => { } as Task, ]; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(''); const result = await verifySyncState(mockConfig); @@ -323,8 +324,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1 - [ ] Task 2`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); @@ -348,8 +349,8 @@ describe('verifySyncState', () => { const mockMarkdown = `- [ ] Task 1 MD notes`; - (global.PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); - (global.PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); + ((global as any).PluginAPI.getTasks as jest.Mock).mockResolvedValue(mockTasks); + ((global as any).PluginAPI.getAllProjects as jest.Mock).mockResolvedValue([]); (fileUtils.readTasksFile as jest.Mock).mockResolvedValue(mockMarkdown); const result = await verifySyncState(mockConfig); diff --git a/packages/plugin-dev/sync-md/src/background/__tests__/test-utils.ts b/packages/plugin-dev/sync-md/src/background/__tests__/test-utils.ts index 2fea1cd7d..0b302b6cb 100644 --- a/packages/plugin-dev/sync-md/src/background/__tests__/test-utils.ts +++ b/packages/plugin-dev/sync-md/src/background/__tests__/test-utils.ts @@ -1,5 +1,5 @@ import { Task } from '@super-productivity/plugin-api'; -import { ParsedTask } from '../sync/types'; +import { ParsedTask } from '../sync/markdown-parser'; import { LocalUserCfg } from '../local-config'; /** @@ -167,7 +167,7 @@ export const createMockConfig = (overrides?: Partial): LocalUserCf ...overrides, }); -export const createMockPluginAPI = () => { +export const createMockPluginAPI = (): any => { const mockAPI = { registerHook: jest.fn(), onWindowFocusChange: jest.fn(), @@ -176,6 +176,19 @@ export const createMockPluginAPI = () => { .fn() .mockResolvedValue([{ id: 'test-project', title: 'Test Project' }]), batchUpdateForProject: jest.fn().mockResolvedValue(undefined), + log: { + critical: jest.fn(), + err: jest.fn(), + error: jest.fn(), + log: jest.fn(), + normal: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, + persistDataSynced: jest.fn(), + loadSyncedData: jest.fn(), }; (global as any).PluginAPI = mockAPI; @@ -242,7 +255,8 @@ export const generateLargeMarkdown = (taskCount: number): string => { return builder.build(); }; -export const waitForAsync = () => new Promise((resolve) => setImmediate(resolve)); +export const waitForAsync = (): Promise => + new Promise((resolve) => setImmediate(resolve)); export const measureExecutionTime = async ( fn: () => void | Promise, @@ -256,7 +270,7 @@ export const measureExecutionTime = async ( * Custom Jest matchers */ export const customMatchers = { - toBeValidTask(received: any) { + toBeValidTask: (received: any) => { const pass = received && typeof received.id === 'string' && @@ -273,7 +287,7 @@ export const customMatchers = { }; }, - toBeValidParsedTask(received: any) { + toBeValidParsedTask: (received: any) => { const pass = received && typeof received.line === 'number' && diff --git a/packages/plugin-dev/sync-md/src/background/sync/header-preservation.integration.spec.ts b/packages/plugin-dev/sync-md/src/background/sync/header-preservation.integration.spec.ts new file mode 100644 index 000000000..69740b269 --- /dev/null +++ b/packages/plugin-dev/sync-md/src/background/sync/header-preservation.integration.spec.ts @@ -0,0 +1,180 @@ +import { mdToSp } from './md-to-sp'; +import { spToMd } from './sp-to-md'; +import { LocalUserCfg } from '../local-config'; +import { Task } from '@super-productivity/plugin-api'; +// Import mocked modules +import { + ensureDirectoryExists, + writeTasksFile, + readTasksFile, +} from '../helper/file-utils'; + +// Mock dependencies +jest.mock('../helper/file-utils'); + +// Mock the PluginAPI + +describe('Header Preservation Integration', () => { + const mockConfig: LocalUserCfg = { + filePath: '/test/tasks.md', + projectId: 'test-project', + }; + + let mockTasks: Task[] = []; + let mockProjects: any[] = []; + let mockSyncedData: Record = {}; + + beforeEach(() => { + jest.clearAllMocks(); + mockTasks = []; + mockProjects = [ + { + id: 'test-project', + title: 'Test Project', + taskIds: [], + }, + ]; + mockSyncedData = {}; + + // Setup PluginAPI mock + (global as any).PluginAPI = { + getTasks: jest.fn().mockResolvedValue(mockTasks), + getAllProjects: jest.fn().mockResolvedValue(mockProjects), + batchUpdateForProject: jest.fn().mockImplementation(({ operations }) => { + // Simulate batch operations + operations.forEach((op: any) => { + if (op.type === 'ADD_TASK') { + mockTasks.push(op.task); + } else if (op.type === 'UPDATE_TASK') { + const index = mockTasks.findIndex((t) => t.id === op.task.id); + if (index >= 0) { + mockTasks[index] = { ...mockTasks[index], ...op.task }; + } + } + }); + return Promise.resolve(); + }), + persistDataSynced: jest.fn().mockImplementation((data) => { + Object.assign(mockSyncedData, data); + return Promise.resolve(); + }), + loadSyncedData: jest.fn().mockResolvedValue(mockSyncedData), + }; + }); + + it('should preserve header content through full sync cycle', async () => { + const markdownWithHeader = `# Project Tasks +This is my project description. + +## Important Notes +- Remember to check dependencies +- Update documentation + +- [ ] Task 1 +- [ ] Task 2 + - [ ] Subtask 2.1`; + + // Step 1: Parse markdown and sync to SP + await mdToSp(markdownWithHeader, mockConfig.projectId); + + // Step 2: Mock the file read to return the original content with header + (readTasksFile as jest.Mock).mockResolvedValue(markdownWithHeader); + + // Mock file operations + (ensureDirectoryExists as jest.Mock).mockResolvedValue(undefined); + (writeTasksFile as jest.Mock).mockResolvedValue(undefined); + + // Setup mock tasks that would have been created from mdToSp + mockTasks = [ + { + id: 'task-1', + title: 'Task 1', + isDone: false, + projectId: 'test-project', + parentId: null, + subTaskIds: [], + }, + { + id: 'task-2', + title: 'Task 2', + isDone: false, + projectId: 'test-project', + parentId: null, + subTaskIds: ['subtask-1'], + }, + { + id: 'subtask-1', + title: 'Subtask 2.1', + isDone: false, + projectId: 'test-project', + parentId: 'task-2', + subTaskIds: [], + }, + ] as unknown as Task[]; + + // Update the mock to return our tasks + (global as any).PluginAPI.getTasks.mockResolvedValue(mockTasks); + + // Update project to have the task IDs + mockProjects[0].taskIds = ['task-1', 'task-2']; + + // Step 3: Run spToMd and verify header is preserved + await spToMd(mockConfig); + + // Verify the written content includes the header + expect(writeTasksFile).toHaveBeenCalled(); + const writtenContent = (writeTasksFile as jest.Mock).mock.calls[0][1]; + + // The content should start with the header + expect(writtenContent).toMatch(/^# Project Tasks/); + expect(writtenContent).toContain('This is my project description.'); + expect(writtenContent).toContain('## Important Notes'); + expect(writtenContent).toContain('- Remember to check dependencies'); + expect(writtenContent).toContain('- Update documentation'); + + // And should include the tasks + expect(writtenContent).toContain('- [ ] Task 1'); + expect(writtenContent).toContain('- [ ] Task 2'); + expect(writtenContent).toContain(' - [ ] Subtask 2.1'); + }); + + it('should handle empty header', async () => { + const markdownNoHeader = `- [ ] Task 1 +- [ ] Task 2`; + + // Mock file operations + (readTasksFile as jest.Mock).mockResolvedValue(markdownNoHeader); + (ensureDirectoryExists as jest.Mock).mockResolvedValue(undefined); + (writeTasksFile as jest.Mock).mockResolvedValue(undefined); + + // Setup mock tasks + mockTasks = [ + { + id: 'task-1', + title: 'Task 1', + isDone: false, + projectId: 'test-project', + parentId: null, + subTaskIds: [], + }, + { + id: 'task-2', + title: 'Task 2', + isDone: false, + projectId: 'test-project', + parentId: null, + subTaskIds: [], + }, + ] as unknown as Task[]; + + (global as any).PluginAPI.getTasks.mockResolvedValue(mockTasks); + + // Run spToMd + await spToMd(mockConfig); + + // Verify no header was added + const writtenContent = (writeTasksFile as jest.Mock).mock.calls[0][1]; + expect(writtenContent).not.toMatch(/^#/); + expect(writtenContent).toMatch(/^- \[ \] Task 1/); + }); +}); diff --git a/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.spec.ts b/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.spec.ts index 4d7d0084f..94b7c7ee8 100644 --- a/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.spec.ts +++ b/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.spec.ts @@ -1,4 +1,7 @@ -import { parseMarkdownWithErrors as parseTasks } from './markdown-parser'; +import { + parseMarkdownWithErrors as parseTasks, + parseMarkdownWithHeader, +} from './markdown-parser'; describe('markdown-parser', () => { describe('parseTasks', () => { @@ -238,4 +241,94 @@ describe('markdown-parser', () => { expect(result.tasks[4].notes).toBe('Note line 1\nNote line 2'); }); }); + + describe('parseMarkdownWithHeader', () => { + it('should extract header content before first task', () => { + const markdown = `# My Tasks +This is a description of my tasks. + +Some more text here. + +- [ ] Task 1 +- [ ] Task 2`; + + const result = parseMarkdownWithHeader(markdown); + + expect(result.header).toBe( + '# My Tasks\nThis is a description of my tasks.\n\nSome more text here.\n', + ); + expect(result.tasks.length).toBe(2); + expect(result.tasks[0].title).toBe('Task 1'); + expect(result.tasks[1].title).toBe('Task 2'); + }); + + it('should handle markdown with no header', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2`; + + const result = parseMarkdownWithHeader(markdown); + + expect(result.header).toBeUndefined(); + expect(result.tasks.length).toBe(2); + }); + + it('should handle markdown with only header and no tasks', () => { + const markdown = `# My Header +This is just a header with no tasks.`; + + const result = parseMarkdownWithHeader(markdown); + + expect(result.header).toBe('# My Header\nThis is just a header with no tasks.'); + expect(result.tasks.length).toBe(0); + }); + + it('should preserve complex header with metadata', () => { + const markdown = `--- +title: My Project Tasks +date: 2024-01-01 +tags: [project, tasks] +--- + +# Project Overview + +This document contains all the tasks for my project. + +## Important Notes +- Remember to check dependencies +- Update documentation + +- [ ] Task 1 +- [ ] Task 2`; + + const result = parseMarkdownWithHeader(markdown); + + expect(result.header).toBe(`--- +title: My Project Tasks +date: 2024-01-01 +tags: [project, tasks] +--- + +# Project Overview + +This document contains all the tasks for my project. + +## Important Notes +- Remember to check dependencies +- Update documentation +`); + expect(result.tasks.length).toBe(2); + }); + + it('should handle empty lines before first task', () => { + const markdown = `# Header + + +- [ ] Task 1`; + + const result = parseMarkdownWithHeader(markdown); + + expect(result.header).toBe('# Header\n\n'); + expect(result.tasks.length).toBe(1); + }); + }); }); diff --git a/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts b/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts index 922ce9c34..cc34ed0b0 100644 --- a/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts +++ b/packages/plugin-dev/sync-md/src/background/sync/markdown-parser.ts @@ -16,6 +16,7 @@ export interface ParsedTask { export interface TaskParseResult { tasks: ParsedTask[]; errors: string[]; + header?: string; } /** @@ -61,6 +62,14 @@ export const parseMarkdown = (content: string): ParsedTask[] => { return result.tasks; }; +/** + * Parse markdown content and return header content + * Returns everything before the first task + */ +export const parseMarkdownWithHeader = (content: string): TaskParseResult => { + return parseMarkdownWithErrors(content); +}; + /** * Parse markdown content into task objects with error collection * Handles tasks and subtasks (first two levels) and converts deeper levels to notes @@ -80,6 +89,8 @@ export const parseMarkdownWithErrors = (content: string): TaskParseResult => { console.log(`[sync-md] Detected indent size: ${detectedIndentSize} spaces`); let currentTaskIndex = -1; + let firstTaskLineIndex = -1; + let header: string | undefined; for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -88,6 +99,15 @@ export const parseMarkdownWithErrors = (content: string): TaskParseResult => { const taskMatch = line.match(/^(\s*)- \[([ x])\]\s*(?:\s*)?(.*)$/); if (taskMatch) { + // Mark the first task line if not already marked + if (firstTaskLineIndex === -1) { + firstTaskLineIndex = i; + // Extract header content (everything before the first task) + if (i > 0) { + header = lines.slice(0, i).join('\n'); + } + } + const [, indent, completed, id, title] = taskMatch; const indentLevel = indent.length; const depth = indentLevel === 0 ? 0 : Math.floor(indentLevel / detectedIndentSize); @@ -200,7 +220,12 @@ export const parseMarkdownWithErrors = (content: string): TaskParseResult => { } } - return { tasks, errors }; + // If no tasks were found, the entire content is considered header + if (firstTaskLineIndex === -1 && lines.length > 0) { + header = content; + } + + return { tasks, errors, header }; }; /** diff --git a/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts b/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts index 5b73f07c5..27d892409 100644 --- a/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts +++ b/packages/plugin-dev/sync-md/src/background/sync/md-to-sp.ts @@ -1,4 +1,4 @@ -import { parseMarkdown } from './markdown-parser'; +import { parseMarkdownWithHeader } from './markdown-parser'; import { generateTaskOperations } from './generate-task-operations'; // import { Task } from '@super-productivity/plugin-api'; @@ -10,7 +10,9 @@ export const mdToSp = async ( markdownContent: string, projectId: string, ): Promise => { - const parsedTasks = parseMarkdown(markdownContent); + // Use parseMarkdownWithHeader to get header, but handle backward compatibility + const parseResult = parseMarkdownWithHeader(markdownContent); + const parsedTasks = parseResult.tasks; // Get current state const currentTasks = await PluginAPI.getTasks(); diff --git a/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts b/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts index d789f0eb6..180daa097 100644 --- a/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts +++ b/packages/plugin-dev/sync-md/src/background/sync/sp-to-md.ts @@ -1,6 +1,11 @@ import { Task } from '@super-productivity/plugin-api'; -import { writeTasksFile, ensureDirectoryExists } from '../helper/file-utils'; +import { + writeTasksFile, + ensureDirectoryExists, + readTasksFile, +} from '../helper/file-utils'; import { LocalUserCfg } from '../local-config'; +import { parseMarkdownWithHeader } from './markdown-parser'; /** * Replicate Super Productivity tasks to markdown file @@ -44,8 +49,27 @@ export const spToMd = async (config: LocalUserCfg): Promise => { // Convert project tasks to markdown const markdown = convertTasksToMarkdown(orderedTasks); + // Get existing header from file + let header = ''; + try { + const existingContent = await readTasksFile(config.filePath); + if (existingContent) { + const parsed = parseMarkdownWithHeader(existingContent); + header = parsed.header || ''; + } + } catch (error) { + // File doesn't exist yet, no header to preserve + } + + // Combine header and tasks + let finalContent = markdown; + if (header) { + // Add newline between header and tasks if both exist + finalContent = header + (markdown ? '\n' + markdown : ''); + } + // Write to file - await writeTasksFile(config.filePath, markdown); + await writeTasksFile(config.filePath, finalContent); }; /** diff --git a/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts b/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts index aedfc075f..2f6d47bac 100644 --- a/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts +++ b/packages/plugin-dev/sync-md/src/background/sync/sync-manager.ts @@ -2,11 +2,7 @@ import { startFileWatcher, stopFileWatcher } from './file-watcher'; import { spToMd } from './sp-to-md'; import { mdToSp } from './md-to-sp'; import { getFileStats, readTasksFile } from '../helper/file-utils'; -import { - SYNC_DEBOUNCE_MS, - SYNC_DEBOUNCE_MS_UNFOCUSED, - SYNC_DEBOUNCE_MS_MD_TO_SP, -} from '../config.const'; +import { SYNC_DEBOUNCE_MS, SYNC_DEBOUNCE_MS_MD_TO_SP } from '../config.const'; import { PluginHooks } from '@super-productivity/plugin-api'; import { LocalUserCfg } from '../local-config'; import { logSyncVerification, verifySyncState } from './verify-sync'; @@ -26,7 +22,9 @@ export const initSyncManager = (config: LocalUserCfg): void => { setupWindowFocusTracking(); // Perform initial sync - performInitialSync(config).then((r) => log.log('SyncMD initial sync', r)); + performInitialSync(config) + .then(() => log.debug('SyncMD initial sync completed')) + .catch((error) => log.error('SyncMD initial sync failed', error)); // Set up file watcher for ongoing sync startFileWatcher(config.filePath, () => { diff --git a/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts b/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts index 9a841bfc3..b49f1b9b7 100644 --- a/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts +++ b/packages/plugin-dev/sync-md/src/background/sync/verify-sync.ts @@ -134,7 +134,6 @@ export const verifySyncState = async ( } // Check task order - const spParentTasks = spTasks.filter((t) => !t.parentId); const mdParentTasks = mdTasks.filter((t) => !t.parentId); // If project has taskIds, use them for ordering