- Backend: ProjectFileService with file CRUD, search, git status, file watcher - IPC: 12 editor channels with security validation and path containment - Store: editorSlice with multi-tab management, draft persistence, conflict detection - UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus - Move: fs.rename with EXDEV fallback, full path remapping across all caches - Tests: comprehensive coverage for services, IPC handlers, store, and utilities
153 lines
5.1 KiB
TypeScript
153 lines
5.1 KiB
TypeScript
/**
|
|
* Tests for atomicWriteAsync — tmp + fsync + rename atomic write pattern.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('fs', () => ({
|
|
promises: {
|
|
mkdir: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
open: vi.fn(),
|
|
rename: vi.fn(),
|
|
copyFile: vi.fn(),
|
|
unlink: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { atomicWriteAsync } from '../../../src/main/utils/atomicWrite';
|
|
|
|
// =============================================================================
|
|
// Setup
|
|
// =============================================================================
|
|
|
|
const mockMkdir = vi.mocked(fs.promises.mkdir);
|
|
const mockWriteFile = vi.mocked(fs.promises.writeFile);
|
|
const mockOpen = vi.mocked(fs.promises.open);
|
|
const mockRename = vi.mocked(fs.promises.rename);
|
|
const mockCopyFile = vi.mocked(fs.promises.copyFile);
|
|
const mockUnlink = vi.mocked(fs.promises.unlink);
|
|
|
|
const TARGET_PATH = '/Users/test/project/src/index.ts';
|
|
const TARGET_DIR = path.dirname(TARGET_PATH);
|
|
const CONTENT = 'export const hello = "world";';
|
|
|
|
/** Extract the tmp path from writeFile calls */
|
|
function getTmpPath(): string {
|
|
const call = mockWriteFile.mock.calls[0];
|
|
return String(call[0]);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
// Default happy path
|
|
mockMkdir.mockResolvedValue(undefined);
|
|
mockWriteFile.mockResolvedValue(undefined);
|
|
mockOpen.mockResolvedValue({
|
|
sync: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
} as unknown as fs.promises.FileHandle);
|
|
mockRename.mockResolvedValue(undefined);
|
|
mockUnlink.mockResolvedValue(undefined);
|
|
});
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('atomicWriteAsync', () => {
|
|
it('writes to tmp file in same directory then renames to target', async () => {
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
// writeFile should be called with a tmp path in the same directory
|
|
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
const tmpPath = getTmpPath();
|
|
expect(tmpPath).toMatch(new RegExp(`^${TARGET_DIR}/\\.tmp\\.[a-f0-9-]+$`));
|
|
|
|
// rename from tmp to target
|
|
expect(mockRename).toHaveBeenCalledWith(tmpPath, TARGET_PATH);
|
|
});
|
|
|
|
it('creates parent directories recursively', async () => {
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
expect(mockMkdir).toHaveBeenCalledWith(TARGET_DIR, { recursive: true });
|
|
});
|
|
|
|
it('writes content with utf8 encoding', async () => {
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), CONTENT, 'utf8');
|
|
});
|
|
|
|
it('calls fsync on tmp file before rename', async () => {
|
|
const mockSync = vi.fn().mockResolvedValue(undefined);
|
|
const mockClose = vi.fn().mockResolvedValue(undefined);
|
|
mockOpen.mockResolvedValue({
|
|
sync: mockSync,
|
|
close: mockClose,
|
|
} as unknown as fs.promises.FileHandle);
|
|
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
const tmpPath = getTmpPath();
|
|
expect(mockOpen).toHaveBeenCalledWith(tmpPath, 'r+');
|
|
expect(mockSync).toHaveBeenCalled();
|
|
expect(mockClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('still renames even if fsync fails (best-effort)', async () => {
|
|
mockOpen.mockRejectedValue(new Error('fsync not supported'));
|
|
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
expect(mockRename).toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to copyFile + unlink on EXDEV error', async () => {
|
|
const exdevError = Object.assign(new Error('Cross-device link'), { code: 'EXDEV' });
|
|
mockRename.mockRejectedValue(exdevError);
|
|
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
const tmpPath = getTmpPath();
|
|
expect(mockCopyFile).toHaveBeenCalledWith(tmpPath, TARGET_PATH);
|
|
expect(mockUnlink).toHaveBeenCalledWith(tmpPath);
|
|
});
|
|
|
|
it('still succeeds EXDEV fallback even if tmp cleanup fails', async () => {
|
|
const exdevError = Object.assign(new Error('Cross-device link'), { code: 'EXDEV' });
|
|
mockRename.mockRejectedValue(exdevError);
|
|
mockUnlink.mockRejectedValue(new Error('permission denied'));
|
|
|
|
// Should not throw
|
|
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
|
|
|
expect(mockCopyFile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('re-throws non-EXDEV rename errors and cleans tmp', async () => {
|
|
const permError = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
|
mockRename.mockRejectedValue(permError);
|
|
|
|
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Permission denied');
|
|
expect(mockUnlink).toHaveBeenCalled();
|
|
});
|
|
|
|
it('cleans up tmp file on writeFile failure', async () => {
|
|
mockWriteFile.mockRejectedValue(new Error('Disk full'));
|
|
|
|
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Disk full');
|
|
expect(mockUnlink).toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates parent directories for deeply nested paths', async () => {
|
|
const deepPath = '/Users/test/project/src/deep/nested/file.ts';
|
|
await atomicWriteAsync(deepPath, CONTENT);
|
|
|
|
expect(mockMkdir).toHaveBeenCalledWith(path.dirname(deepPath), { recursive: true });
|
|
});
|
|
});
|