import { workspace } from '@services/wiki/wikiWorker/services'; import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileSystemAdaptor } from '../FileSystemAdaptor'; // Mock the workspace service vi.mock('@services/wiki/wikiWorker/services', () => ({ workspace: { get: vi.fn(), getWorkspacesAsList: vi.fn(), }, })); // Mock TiddlyWiki global const mockLogger = { log: vi.fn(), alert: vi.fn(), }; const mockUtils = { Logger: vi.fn(() => mockLogger), createDirectory: vi.fn(), generateTiddlerFileInfo: vi.fn(), saveTiddlerToFile: vi.fn(), deleteTiddlerFile: vi.fn(), cleanupTiddlerFiles: vi.fn(), getFileExtensionInfo: vi.fn(() => ({ type: 'application/x-tiddler' })), }; // Setup TiddlyWiki global // @ts-expect-error - Setting up global for testing global.$tw = { node: true, boot: { wikiTiddlersPath: '/test/wiki/tiddlers', files: {} as Record, }, utils: mockUtils, }; describe('FileSystemAdaptor - Save Operations', () => { let adaptor: FileSystemAdaptor; let mockWiki: Wiki; beforeEach(() => { vi.clearAllMocks(); // @ts-expect-error - TiddlyWiki global global.$tw.boot.files = {}; mockWiki = { getTiddlerText: vi.fn(() => ''), tiddlerExists: vi.fn(() => false), addTiddler: vi.fn(), } as unknown as Wiki; vi.mocked(workspace.get).mockResolvedValue( { id: 'test-workspace', name: 'Test Workspace', wikiFolderLocation: '/test/wiki', } as Parameters[0] extends Promise ? T : never, ); vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([]); adaptor = new FileSystemAdaptor({ wiki: mockWiki, // @ts-expect-error - TiddlyWiki global boot: global.$tw.boot, }); }); describe('saveTiddler - Callback Mode', () => { it('should save tiddler and call callback on success', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler', text: 'Test content' }, } as Tiddler; const fileInfo: FileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }; mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(null, fileInfo); }); mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { cb(null, fileInfo); }); const callback = vi.fn(); await adaptor.saveTiddler(tiddler, callback); expect(callback).toHaveBeenCalledWith( null, expect.objectContaining({ filepath: '/test/wiki/tiddlers/test.tid', }), ); // @ts-expect-error - TiddlyWiki global expect(global.$tw.boot.files['TestTiddler']).toBeDefined(); // @ts-expect-error - TiddlyWiki global expect(global.$tw.boot.files['TestTiddler'].isEditableFile).toBe(true); }); it('should call callback with error when getTiddlerFileInfo returns null', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue(null); const callback = vi.fn(); await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow( 'No fileInfo returned from getTiddlerFileInfo', ); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ message: 'No fileInfo returned from getTiddlerFileInfo', }), ); }); it('should call callback with error when saveTiddlerToFile fails', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue({ filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }); const saveError = new Error('File write failed'); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(saveError); }); const callback = vi.fn(); await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow(); expect(callback).toHaveBeenCalledWith(expect.any(Error)); }); it('should handle cleanup errors', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; const fileInfo: FileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }; mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(null, fileInfo); }); mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { cb(new Error('Cleanup failed')); }); const callback = vi.fn(); await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow('Cleanup failed'); expect(callback).toHaveBeenCalledWith(expect.any(Error)); }); }); describe('saveTiddler - Async/Await Mode', () => { it('should resolve successfully without callback', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; const fileInfo: FileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }; mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(null, fileInfo); }); mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { cb(null, fileInfo); }); await expect(adaptor.saveTiddler(tiddler)).resolves.toBeUndefined(); // @ts-expect-error - TiddlyWiki global expect(global.$tw.boot.files['TestTiddler']).toBeDefined(); }); it('should reject when getTiddlerFileInfo returns null', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue(null); await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow( 'No fileInfo returned from getTiddlerFileInfo', ); }); it('should reject when saveTiddlerToFile fails', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue({ filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(new Error('Write failed')); }); await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow(); }); it('should preserve isEditableFile from existing fileInfo', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; const fileInfo: FileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, isEditableFile: false, }; mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(null, fileInfo); }); mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { cb(null, fileInfo); }); await adaptor.saveTiddler(tiddler); // @ts-expect-error - TiddlyWiki global expect(global.$tw.boot.files['TestTiddler'].isEditableFile).toBe(false); }); }); describe('saveTiddler - File Lock Retry', () => { it('should retry on EBUSY error', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; const fileInfo: FileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }; mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); let attemptCount = 0; mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { attemptCount++; if (attemptCount < 3) { const error: NodeJS.ErrnoException = new Error('File is busy'); error.code = 'EBUSY'; cb(error); } else { cb(null, fileInfo); } }); mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { cb(null, fileInfo); }); const callback = vi.fn(); await adaptor.saveTiddler(tiddler, callback); expect(attemptCount).toBeGreaterThan(1); expect(callback).toHaveBeenCalledWith(null, expect.any(Object)); expect(mockLogger.log).toHaveBeenCalledWith( expect.stringContaining('is locked'), ); }); it('should retry on EPERM error', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; const fileInfo: FileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }; mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); let attemptCount = 0; mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { attemptCount++; if (attemptCount < 2) { const error: NodeJS.ErrnoException = new Error('Permission denied'); error.code = 'EPERM'; cb(error); } else { cb(null, fileInfo); } }); mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { cb(null, fileInfo); }); await adaptor.saveTiddler(tiddler); expect(attemptCount).toBe(2); }); it('should give up after max retries on persistent lock', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue({ filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { const error: NodeJS.ErrnoException = new Error('File is locked'); error.code = 'EBUSY'; cb(error); }); const callback = vi.fn(); await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow(); expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Failed to save'), })); expect(mockUtils.saveTiddlerToFile).toHaveBeenCalledTimes(10); // Default max retries expect(mockWiki.addTiddler).toHaveBeenCalled(); // Error notification created }); it('should not retry on non-lock errors', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue({ filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }); const diskFullError = new Error('Disk full'); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(diskFullError); }); await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow(); // Should only try once for non-lock errors expect(mockUtils.saveTiddlerToFile).toHaveBeenCalledTimes(1); expect(mockWiki.addTiddler).toHaveBeenCalled(); // Error notification created }); it('should create error notification with correct details', async () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler' }, } as Tiddler; mockUtils.generateTiddlerFileInfo.mockReturnValue({ filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, }); mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { cb(new Error('Test error')); }); await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow(); expect(mockWiki.addTiddler).toHaveBeenCalledWith( expect.objectContaining({ title: '$:/temp/filesystem/error/TestTiddler', tags: ['$:/tags/Alert'], 'error-type': 'file-save-error', 'original-title': 'TestTiddler', text: expect.stringContaining('Failed to save tiddler "TestTiddler"'), }), ); }); }); });