mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
/**
|
|
* Agentic QE v3 - File Reader Unit Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import {
|
|
FileReader,
|
|
FileReadError,
|
|
JsonParseError,
|
|
getFileReader,
|
|
readFile,
|
|
readJSON,
|
|
fileExists,
|
|
listFiles,
|
|
} from '../../../../src/shared/io/file-reader';
|
|
|
|
describe('FileReader', () => {
|
|
let testDir: string;
|
|
let fileReader: FileReader;
|
|
|
|
beforeEach(async () => {
|
|
// Create a temporary test directory
|
|
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-reader-test-'));
|
|
fileReader = new FileReader({ basePath: testDir });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up test directory
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ============================================================================
|
|
// readFile Tests
|
|
// ============================================================================
|
|
|
|
describe('readFile', () => {
|
|
it('should read file contents successfully', async () => {
|
|
const content = 'Hello, World!';
|
|
const filePath = path.join(testDir, 'test.txt');
|
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
|
|
const result = await fileReader.readFile(filePath);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(content);
|
|
}
|
|
});
|
|
|
|
it('should handle relative paths', async () => {
|
|
const content = 'Relative path content';
|
|
await fs.writeFile(path.join(testDir, 'relative.txt'), content, 'utf-8');
|
|
|
|
const result = await fileReader.readFile('relative.txt');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(content);
|
|
}
|
|
});
|
|
|
|
it('should return error for non-existent file', async () => {
|
|
const result = await fileReader.readFile('non-existent.txt');
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toBeInstanceOf(FileReadError);
|
|
expect(result.error.code).toBe('ENOENT');
|
|
expect(result.error.message).toContain('File not found');
|
|
}
|
|
});
|
|
|
|
it('should return error when reading a directory', async () => {
|
|
await fs.mkdir(path.join(testDir, 'subdir'));
|
|
|
|
const result = await fileReader.readFile('subdir');
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toBeInstanceOf(FileReadError);
|
|
expect(result.error.code).toBe('EISDIR');
|
|
}
|
|
});
|
|
|
|
it('should cache file contents on second read', async () => {
|
|
const content = 'Cached content';
|
|
const filePath = path.join(testDir, 'cached.txt');
|
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
|
|
// First read
|
|
await fileReader.readFile(filePath);
|
|
const statsAfterFirst = fileReader.getStats();
|
|
|
|
// Second read (should hit cache)
|
|
await fileReader.readFile(filePath);
|
|
const statsAfterSecond = fileReader.getStats();
|
|
|
|
expect(statsAfterFirst.cacheMisses).toBe(1);
|
|
expect(statsAfterFirst.cacheHits).toBe(0);
|
|
expect(statsAfterSecond.cacheHits).toBe(1);
|
|
expect(statsAfterSecond.cacheMisses).toBe(1);
|
|
});
|
|
|
|
it('should respect cache disabled option', async () => {
|
|
const noCacheReader = new FileReader({
|
|
basePath: testDir,
|
|
enableCache: false,
|
|
});
|
|
|
|
const content = 'No cache content';
|
|
const filePath = path.join(testDir, 'no-cache.txt');
|
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
|
|
await noCacheReader.readFile(filePath);
|
|
await noCacheReader.readFile(filePath);
|
|
|
|
const stats = noCacheReader.getStats();
|
|
expect(stats.cacheHits).toBe(0);
|
|
expect(stats.cacheEntries).toBe(0);
|
|
});
|
|
|
|
it('should read UTF-8 files with special characters', async () => {
|
|
const content = 'Unicode: \u00e9\u00e0\u00fc\u4e2d\u6587\ud83d\ude80';
|
|
const filePath = path.join(testDir, 'unicode.txt');
|
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
|
|
const result = await fileReader.readFile(filePath);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(content);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// readJSON Tests
|
|
// ============================================================================
|
|
|
|
describe('readJSON', () => {
|
|
it('should parse valid JSON file', async () => {
|
|
const data = { name: 'test', count: 42, nested: { value: true } };
|
|
const filePath = path.join(testDir, 'data.json');
|
|
await fs.writeFile(filePath, JSON.stringify(data), 'utf-8');
|
|
|
|
const result = await fileReader.readJSON<typeof data>(filePath);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toEqual(data);
|
|
}
|
|
});
|
|
|
|
it('should return error for invalid JSON', async () => {
|
|
const filePath = path.join(testDir, 'invalid.json');
|
|
await fs.writeFile(filePath, '{ invalid json }', 'utf-8');
|
|
|
|
const result = await fileReader.readJSON(filePath);
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toBeInstanceOf(JsonParseError);
|
|
expect(result.error.message).toContain('Invalid JSON');
|
|
}
|
|
});
|
|
|
|
it('should return error for non-existent JSON file', async () => {
|
|
const result = await fileReader.readJSON('non-existent.json');
|
|
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error).toBeInstanceOf(FileReadError);
|
|
}
|
|
});
|
|
|
|
it('should handle empty JSON object', async () => {
|
|
const filePath = path.join(testDir, 'empty.json');
|
|
await fs.writeFile(filePath, '{}', 'utf-8');
|
|
|
|
const result = await fileReader.readJSON<object>(filePath);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toEqual({});
|
|
}
|
|
});
|
|
|
|
it('should handle JSON arrays', async () => {
|
|
const data = [1, 2, 3, 'four', { five: 5 }];
|
|
const filePath = path.join(testDir, 'array.json');
|
|
await fs.writeFile(filePath, JSON.stringify(data), 'utf-8');
|
|
|
|
const result = await fileReader.readJSON<typeof data>(filePath);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toEqual(data);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// fileExists Tests
|
|
// ============================================================================
|
|
|
|
describe('fileExists', () => {
|
|
it('should return true for existing file', async () => {
|
|
const filePath = path.join(testDir, 'exists.txt');
|
|
await fs.writeFile(filePath, 'content', 'utf-8');
|
|
|
|
const result = await fileReader.fileExists(filePath);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should return false for non-existent file', async () => {
|
|
const result = await fileReader.fileExists('non-existent.txt');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should handle directories', async () => {
|
|
await fs.mkdir(path.join(testDir, 'subdir'));
|
|
|
|
const result = await fileReader.fileExists('subdir');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should handle relative paths', async () => {
|
|
await fs.writeFile(path.join(testDir, 'relative-exists.txt'), 'content', 'utf-8');
|
|
|
|
const result = await fileReader.fileExists('relative-exists.txt');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// listFiles Tests
|
|
// ============================================================================
|
|
|
|
describe('listFiles', () => {
|
|
beforeEach(async () => {
|
|
// Create test file structure
|
|
await fs.writeFile(path.join(testDir, 'file1.ts'), 'content', 'utf-8');
|
|
await fs.writeFile(path.join(testDir, 'file2.ts'), 'content', 'utf-8');
|
|
await fs.writeFile(path.join(testDir, 'file3.js'), 'content', 'utf-8');
|
|
await fs.mkdir(path.join(testDir, 'subdir'));
|
|
await fs.writeFile(path.join(testDir, 'subdir', 'nested.ts'), 'content', 'utf-8');
|
|
await fs.writeFile(path.join(testDir, 'subdir', 'nested.js'), 'content', 'utf-8');
|
|
});
|
|
|
|
it('should list files matching simple extension pattern', async () => {
|
|
const result = await fileReader.listFiles('*.ts');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.length).toBe(2);
|
|
expect(result.value.every((f) => f.endsWith('.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should list files matching globstar pattern', async () => {
|
|
const result = await fileReader.listFiles('**/*.ts');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.length).toBe(3);
|
|
expect(result.value.some((f) => f.includes('subdir'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should list files in specific subdirectory', async () => {
|
|
const result = await fileReader.listFiles('*.ts', path.join(testDir, 'subdir'));
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.length).toBe(1);
|
|
expect(result.value[0]).toContain('nested.ts');
|
|
}
|
|
});
|
|
|
|
it('should return empty array for no matches', async () => {
|
|
const result = await fileReader.listFiles('*.xyz');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('should return error for non-existent base path', async () => {
|
|
const result = await fileReader.listFiles('*.ts', '/non/existent/path');
|
|
|
|
expect(result.success).toBe(false);
|
|
// SEC-004: PathTraversalError is returned for paths escaping basePath
|
|
// FileReadError is returned for I/O errors on valid paths
|
|
// Both are valid error types for this scenario
|
|
if (!result.success) {
|
|
expect(
|
|
result.error instanceof FileReadError ||
|
|
result.error.name === 'PathTraversalError'
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should handle complex glob patterns', async () => {
|
|
await fs.writeFile(path.join(testDir, 'test.spec.ts'), 'content', 'utf-8');
|
|
|
|
const result = await fileReader.listFiles('*.spec.ts');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.length).toBe(1);
|
|
expect(result.value[0]).toContain('test.spec.ts');
|
|
}
|
|
});
|
|
|
|
it('should return sorted results', async () => {
|
|
const result = await fileReader.listFiles('*.ts');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
const sorted = [...result.value].sort();
|
|
expect(result.value).toEqual(sorted);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Cache Management Tests
|
|
// ============================================================================
|
|
|
|
describe('cache management', () => {
|
|
it('should invalidate specific cache entry', async () => {
|
|
const filePath = path.join(testDir, 'invalidate.txt');
|
|
await fs.writeFile(filePath, 'original', 'utf-8');
|
|
|
|
await fileReader.readFile(filePath);
|
|
expect(fileReader.getStats().cacheEntries).toBe(1);
|
|
|
|
fileReader.invalidateCache(filePath);
|
|
expect(fileReader.getStats().cacheEntries).toBe(0);
|
|
});
|
|
|
|
it('should clear all cache entries', async () => {
|
|
await fs.writeFile(path.join(testDir, 'a.txt'), 'a', 'utf-8');
|
|
await fs.writeFile(path.join(testDir, 'b.txt'), 'b', 'utf-8');
|
|
|
|
await fileReader.readFile(path.join(testDir, 'a.txt'));
|
|
await fileReader.readFile(path.join(testDir, 'b.txt'));
|
|
expect(fileReader.getStats().cacheEntries).toBe(2);
|
|
|
|
fileReader.clearCache();
|
|
expect(fileReader.getStats().cacheEntries).toBe(0);
|
|
});
|
|
|
|
it('should prune expired cache entries', async () => {
|
|
const shortTtlReader = new FileReader({
|
|
basePath: testDir,
|
|
cacheTtlMs: 10, // 10ms TTL
|
|
});
|
|
|
|
const filePath = path.join(testDir, 'expire.txt');
|
|
await fs.writeFile(filePath, 'content', 'utf-8');
|
|
|
|
await shortTtlReader.readFile(filePath);
|
|
expect(shortTtlReader.getStats().cacheEntries).toBe(1);
|
|
|
|
// Wait for TTL to expire
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
const pruned = shortTtlReader.pruneCache();
|
|
expect(pruned).toBe(1);
|
|
expect(shortTtlReader.getStats().cacheEntries).toBe(0);
|
|
});
|
|
|
|
it('should evict oldest entries when cache is full', async () => {
|
|
const smallCacheReader = new FileReader({
|
|
basePath: testDir,
|
|
maxCacheSize: 2,
|
|
});
|
|
|
|
await fs.writeFile(path.join(testDir, '1.txt'), '1', 'utf-8');
|
|
await fs.writeFile(path.join(testDir, '2.txt'), '2', 'utf-8');
|
|
await fs.writeFile(path.join(testDir, '3.txt'), '3', 'utf-8');
|
|
|
|
await smallCacheReader.readFile(path.join(testDir, '1.txt'));
|
|
await smallCacheReader.readFile(path.join(testDir, '2.txt'));
|
|
expect(smallCacheReader.getStats().cacheEntries).toBe(2);
|
|
|
|
// This should evict the oldest (1.txt)
|
|
await smallCacheReader.readFile(path.join(testDir, '3.txt'));
|
|
expect(smallCacheReader.getStats().cacheEntries).toBe(2);
|
|
|
|
// Reading 1.txt again should be a cache miss
|
|
const statsBeforeReread = smallCacheReader.getStats();
|
|
await smallCacheReader.readFile(path.join(testDir, '1.txt'));
|
|
const statsAfterReread = smallCacheReader.getStats();
|
|
|
|
expect(statsAfterReread.cacheMisses).toBe(statsBeforeReread.cacheMisses + 1);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Statistics Tests
|
|
// ============================================================================
|
|
|
|
describe('statistics', () => {
|
|
it('should track total reads', async () => {
|
|
await fs.writeFile(path.join(testDir, 'stats.txt'), 'content', 'utf-8');
|
|
|
|
expect(fileReader.getStats().totalReads).toBe(0);
|
|
|
|
await fileReader.readFile(path.join(testDir, 'stats.txt'));
|
|
expect(fileReader.getStats().totalReads).toBe(1);
|
|
|
|
await fileReader.readFile(path.join(testDir, 'stats.txt'));
|
|
expect(fileReader.getStats().totalReads).toBe(2);
|
|
});
|
|
|
|
it('should reset statistics', async () => {
|
|
await fs.writeFile(path.join(testDir, 'reset.txt'), 'content', 'utf-8');
|
|
|
|
await fileReader.readFile(path.join(testDir, 'reset.txt'));
|
|
await fileReader.readFile(path.join(testDir, 'reset.txt'));
|
|
|
|
expect(fileReader.getStats().totalReads).toBe(2);
|
|
expect(fileReader.getStats().cacheHits).toBe(1);
|
|
|
|
fileReader.resetStats();
|
|
|
|
expect(fileReader.getStats().totalReads).toBe(0);
|
|
expect(fileReader.getStats().cacheHits).toBe(0);
|
|
expect(fileReader.getStats().cacheMisses).toBe(0);
|
|
});
|
|
|
|
it('should track cache size in bytes', async () => {
|
|
const content = 'Hello, World!';
|
|
await fs.writeFile(path.join(testDir, 'size.txt'), content, 'utf-8');
|
|
|
|
await fileReader.readFile(path.join(testDir, 'size.txt'));
|
|
|
|
const stats = fileReader.getStats();
|
|
expect(stats.cacheSize).toBe(Buffer.byteLength(content, 'utf-8'));
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Convenience Functions Tests
|
|
// ============================================================================
|
|
|
|
describe('convenience functions', () => {
|
|
it('should read file using convenience function', async () => {
|
|
const content = 'Convenience test';
|
|
const filePath = path.join(testDir, 'convenience.txt');
|
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
|
|
// Need to reset default instance to use our test directory
|
|
const reader = getFileReader({ basePath: testDir });
|
|
const result = await reader.readFile('convenience.txt');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toBe(content);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Error Class Tests
|
|
// ============================================================================
|
|
|
|
describe('FileReadError', () => {
|
|
it('should have correct name and properties', () => {
|
|
const error = new FileReadError('Test error', '/path/to/file', 'ENOENT');
|
|
|
|
expect(error.name).toBe('FileReadError');
|
|
expect(error.message).toBe('Test error');
|
|
expect(error.filePath).toBe('/path/to/file');
|
|
expect(error.code).toBe('ENOENT');
|
|
});
|
|
|
|
it('should preserve cause', () => {
|
|
const cause = new Error('Original error');
|
|
const error = new FileReadError('Wrapped error', '/path', 'ERR', cause);
|
|
|
|
expect(error.cause).toBe(cause);
|
|
});
|
|
});
|
|
|
|
describe('JsonParseError', () => {
|
|
it('should have correct name and properties', () => {
|
|
const error = new JsonParseError('Parse failed', '/path/to/file.json');
|
|
|
|
expect(error.name).toBe('JsonParseError');
|
|
expect(error.message).toBe('Parse failed');
|
|
expect(error.filePath).toBe('/path/to/file.json');
|
|
});
|
|
});
|