Skip to main content
Glama
file-utils.test.ts11.4 kB
/** * File Utilities Tests * * Tests for shared file operations used across repositories: * - atomicWriteJSON() for crash-safe writes * - loadJSON() for file reading * * TDD Markers: REVIEW (all tests verified) */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { atomicWriteJSON, loadJSON } from '../../src/infrastructure/repositories/file/file-utils'; describe('file-utils', () => { let testDir: string; beforeEach(async () => { testDir = path.join(os.tmpdir(), `file-utils-test-${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); await fs.mkdir(testDir, { recursive: true }); }); afterEach(async () => { await fs.rm(testDir, { recursive: true, force: true }); }); // ============================================================================ // REVIEW: atomicWriteJSON // ============================================================================ /* eslint-disable @typescript-eslint/no-unsafe-call */ describe('atomicWriteJSON', () => { it('should write JSON file atomically', async () => { const filePath = path.join(testDir, 'test.json'); const data = { key: 'value', nested: { arr: [1, 2, 3] } }; await atomicWriteJSON(filePath, data); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual(data); }); it('should format JSON with 2-space indentation', async () => { const filePath = path.join(testDir, 'formatted.json'); const data = { key: 'value' }; await atomicWriteJSON(filePath, data); const content = await fs.readFile(filePath, 'utf-8'); expect(content).toBe('{\n "key": "value"\n}'); }); it('should overwrite existing file', async () => { const filePath = path.join(testDir, 'overwrite.json'); // Write initial content await atomicWriteJSON(filePath, { version: 1 }); // Overwrite await atomicWriteJSON(filePath, { version: 2 }); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual({ version: 2 }); }); it('should create parent directories if they do not exist', async () => { const filePath = path.join(testDir, 'nested', 'deep', 'file.json'); const data = { created: true }; // Parent directories don't exist yet - atomicWriteJSON should handle this // Note: atomicWriteJSON doesn't create parent dirs, caller must ensure they exist await fs.mkdir(path.dirname(filePath), { recursive: true }); await atomicWriteJSON(filePath, data); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual(data); }); it('should clean up temp file on write error', async () => { // Create a directory where we expect a file - this will cause write to fail const filePath = path.join(testDir, 'conflict'); await fs.mkdir(filePath); // Create directory instead of file // Attempting to write should fail await expect(atomicWriteJSON(filePath, { data: 'test' })).rejects.toThrow(); // Verify no temp files left behind const files = await fs.readdir(testDir); const tempFiles = files.filter((f) => f.includes('.tmp.')); expect(tempFiles).toHaveLength(0); }); it('should handle empty object', async () => { const filePath = path.join(testDir, 'empty.json'); await atomicWriteJSON(filePath, {}); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual({}); }); it('should handle array data', async () => { const filePath = path.join(testDir, 'array.json'); const data = [1, 2, { nested: true }]; await atomicWriteJSON(filePath, data); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual(data); }); it('should handle special characters in values', async () => { const filePath = path.join(testDir, 'special.json'); const data = { unicode: '\u0000\u001f', quotes: '"quoted"', newlines: 'line1\nline2', tabs: 'col1\tcol2', }; await atomicWriteJSON(filePath, data); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual(data); }); it('should handle null value', async () => { const filePath = path.join(testDir, 'null.json'); await atomicWriteJSON(filePath, null); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toBeNull(); }); it('should handle sequential writes to same file', async () => { const filePath = path.join(testDir, 'sequential.json'); // Sequential writes (realistic use case - concurrent writes to SAME file // should be protected by FileLockManager at higher level) for (let i = 0; i < 5; i++) { await atomicWriteJSON(filePath, { iteration: i }); } // File should contain last write const content = await fs.readFile(filePath, 'utf-8'); const parsed = JSON.parse(content); expect(parsed.iteration).toBe(4); }); it('should handle concurrent writes to different files', async () => { // Concurrent writes to DIFFERENT files is the supported pattern const writes = Array.from({ length: 10 }, (_, i): Promise<void> => { const filePath = path.join(testDir, `concurrent-${i.toString()}.json`); return atomicWriteJSON(filePath, { iteration: i }) as Promise<void>; }); await Promise.all(writes); // All files should exist with correct content for (let i = 0; i < 10; i++) { const filePath = path.join(testDir, `concurrent-${i.toString()}.json`); const content = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(content)).toEqual({ iteration: i }); } }); }); /* eslint-enable @typescript-eslint/no-unsafe-call */ // ============================================================================ // REVIEW: loadJSON // ============================================================================ /* eslint-disable @typescript-eslint/no-unsafe-call */ describe('loadJSON', () => { it('should load and parse JSON file', async () => { const filePath = path.join(testDir, 'load.json'); const data = { key: 'value', number: 42 }; await fs.writeFile(filePath, JSON.stringify(data), 'utf-8'); const result = await loadJSON<typeof data>(filePath); expect(result).toEqual(data); }); it('should throw ENOENT error for non-existent file', async () => { const filePath = path.join(testDir, 'nonexistent.json'); await expect(loadJSON(filePath)).rejects.toThrow(); try { await loadJSON(filePath); } catch (error: unknown) { expect((error as NodeJS.ErrnoException).code).toBe('ENOENT'); } }); it('should throw SyntaxError for invalid JSON', async () => { const filePath = path.join(testDir, 'invalid.json'); await fs.writeFile(filePath, 'not valid json { missing quotes }', 'utf-8'); await expect(loadJSON(filePath)).rejects.toThrow(SyntaxError); }); it('should handle empty JSON object', async () => { const filePath = path.join(testDir, 'empty-obj.json'); await fs.writeFile(filePath, '{}', 'utf-8'); const result = await loadJSON(filePath); expect(result).toEqual({}); }); it('should handle JSON array', async () => { const filePath = path.join(testDir, 'array.json'); const data = [1, 2, 3, { nested: true }]; await fs.writeFile(filePath, JSON.stringify(data), 'utf-8'); const result = await loadJSON<typeof data>(filePath); expect(result).toEqual(data); }); it('should handle JSON with whitespace', async () => { const filePath = path.join(testDir, 'whitespace.json'); await fs.writeFile(filePath, ' \n { "key" : "value" } \n ', 'utf-8'); const result = await loadJSON<{ key: string }>(filePath); expect(result).toEqual({ key: 'value' }); }); it('should handle JSON with BOM', async () => { const filePath = path.join(testDir, 'bom.json'); // UTF-8 BOM + JSON content const bom = Buffer.from([0xef, 0xbb, 0xbf]); const content = Buffer.concat([bom, Buffer.from('{"key":"value"}')]); await fs.writeFile(filePath, content); // Note: JSON.parse handles BOM in some environments, may throw in others // This test documents the behavior try { const result = await loadJSON<{ key: string }>(filePath); expect(result).toEqual({ key: 'value' }); } catch { // BOM not handled - this is acceptable, document the limitation expect(true).toBe(true); } }); it('should preserve type information with generics', async () => { interface TestType { id: string; count: number; active: boolean; } const filePath = path.join(testDir, 'typed.json'); const data: TestType = { id: 'test-1', count: 42, active: true }; await fs.writeFile(filePath, JSON.stringify(data), 'utf-8'); const result = await loadJSON<TestType>(filePath); // TypeScript type checking expect(result.id).toBe('test-1'); expect(result.count).toBe(42); expect(result.active).toBe(true); }); it('should handle large JSON files', async () => { const filePath = path.join(testDir, 'large.json'); // Create a large object (~1MB) const largeArray = Array.from({ length: 10000 }, (_, i) => ({ id: `item-${i.toString()}`, data: 'x'.repeat(100), })); await fs.writeFile(filePath, JSON.stringify(largeArray), 'utf-8'); const result = await loadJSON<typeof largeArray>(filePath); expect(result).toHaveLength(10000); expect(result[0].id).toBe('item-0'); expect(result[9999].id).toBe('item-9999'); }); }); /* eslint-enable @typescript-eslint/no-unsafe-call */ // ============================================================================ // REVIEW: Integration - atomicWriteJSON + loadJSON roundtrip // ============================================================================ /* eslint-disable @typescript-eslint/no-unsafe-call */ describe('roundtrip', () => { it('should write and read data consistently', async () => { const filePath = path.join(testDir, 'roundtrip.json'); const originalData = { string: 'hello', number: 123.456, boolean: true, null: null, array: [1, 'two', { three: 3 }], nested: { deep: { value: 'found', }, }, }; await atomicWriteJSON(filePath, originalData); const loadedData = await loadJSON(filePath); expect(loadedData).toEqual(originalData); }); it('should handle multiple write-read cycles', async () => { const filePath = path.join(testDir, 'cycles.json'); for (let i = 0; i < 5; i++) { const data = { cycle: i, timestamp: Date.now() }; await atomicWriteJSON(filePath, data); const loaded = await loadJSON<typeof data>(filePath); expect(loaded.cycle).toBe(i); } }); }); /* eslint-enable @typescript-eslint/no-unsafe-call */ });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server