mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Follow-up hardening applied during review of PR #120, addressing the adversarial multi-agent findings: - fix(preview): render auto-detected (workspace, unregistered) DOCX/PPTX via the file-preview route and PDFs via file-raw in openFilePreview. Previously the Preview button fell through to file-content, dumping the binary Office/PDF bytes as mojibake, and the new file-preview route was unreachable dead code. (MAJOR: file-preview-route-unreachable-detected-office) - perf(convert): add a global converter-concurrency limiter (document-conversion-limiter.ts) wrapping every pdftoppm / soffice / powershell spawn, so N simultaneous preview/thumbnail requests can no longer fork unbounded converter processes. Default cap 3, CODEMAN_MAX_DOCUMENT_CONVERSIONS. (MAJOR: no-converter-concurrency-limit) - fix(cache): bound the converted-PDF disk cache with LRU-by-mtime eviction (pruneDocumentPreviewCache, default 100 files, CODEMAN_MAX_PREVIEW_CACHE_FILES), run after each successful conversion. Was unbounded. (MAJOR/MINOR: preview-cache-unbounded-disk-growth) Tests: document-conversion-limiter.test.ts, document-preview-cache-eviction.test.ts, and route coverage for the four new endpoints in routes/file-routes-preview-thumbnail.test.ts (closes the missing-route-test gap). Verified end-to-end against real pdftoppm (thumbnail render + concurrency cap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
56 lines
2.1 KiB
TypeScript
56 lines
2.1 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import { pruneDocumentPreviewCache } from '../src/document-preview-cache.js';
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readdir: vi.fn(),
|
|
stat: vi.fn(),
|
|
rm: vi.fn(async () => undefined),
|
|
},
|
|
}));
|
|
|
|
const mockedReaddir = vi.mocked(fs.readdir);
|
|
const mockedStat = vi.mocked(fs.stat);
|
|
const mockedRm = vi.mocked(fs.rm);
|
|
|
|
describe('pruneDocumentPreviewCache', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// mtime encoded in the filename (f0 oldest ... f101 newest)
|
|
mockedStat.mockImplementation(async (p) => {
|
|
const m = /f(\d+)\.pdf$/.exec(String(p));
|
|
return { mtimeMs: m ? Number(m[1]) : 0, isFile: () => true } as never;
|
|
});
|
|
});
|
|
|
|
it('evicts the oldest *.pdf files once the cache exceeds the cap (default 100)', async () => {
|
|
const names = Array.from({ length: 102 }, (_, i) => `f${i}.pdf`);
|
|
mockedReaddir.mockResolvedValue(names as never);
|
|
|
|
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
|
|
|
// 102 - 100 = 2 oldest removed
|
|
expect(mockedRm).toHaveBeenCalledTimes(2);
|
|
const removed = mockedRm.mock.calls.map((c) => String(c[0]));
|
|
expect(removed.some((p) => p.endsWith('f0.pdf'))).toBe(true);
|
|
expect(removed.some((p) => p.endsWith('f1.pdf'))).toBe(true);
|
|
expect(removed.some((p) => p.endsWith('f101.pdf'))).toBe(false); // newest kept
|
|
});
|
|
|
|
it('ignores non-pdf entries (e.g. transient work-* dirs) when counting', async () => {
|
|
const names = [...Array.from({ length: 50 }, (_, i) => `f${i}.pdf`), 'work-abc', 'work-def'];
|
|
mockedReaddir.mockResolvedValue(names as never);
|
|
|
|
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
|
|
|
expect(mockedRm).not.toHaveBeenCalled(); // 50 pdfs <= cap
|
|
});
|
|
|
|
it('never throws when the cache dir cannot be read', async () => {
|
|
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
|
await expect(pruneDocumentPreviewCache('/tmp/missing')).resolves.toBeUndefined();
|
|
expect(mockedRm).not.toHaveBeenCalled();
|
|
});
|
|
});
|