mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Builds on the COD-37 registry: surfaces detected/registered attachments as dismissible cards with a first-page thumbnail and an inline preview — the consumer the registry PR deliberately deferred. Backend: - document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via pdftoppm; Office via the preview cache). - document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice / PowerShell COM), in-flight dedup, multi-converter fallback. - file-routes: serveConvertedPreview / serveThumbnail + four routes — GET .../attachments/:id/preview, .../thumbnail and the workspace-path file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe resolveServableAttachmentPath, so previews stream the freshly-resolved path. - server: enrich detected attachment events with a thumbnail route. - image-watcher: .png now routes to attachment:detected — this PR adds the card consumer, so the screenshot popup is no longer its only handler. Frontend: - panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all, per-session cleanup) plus a 3-arg openFilePreview that renders registered attachments inline (image/PDF) or via the server-converted PDF (docx/pptx). - app.js: wire attachment:detected -> _onAttachmentDetected and card state. - styles: attachment-card + stack styling. Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer + preview-cache unit tests pass; full test:ci green (2861 passed); card render + preview overlay + dismiss verified in-browser.
97 lines
3.6 KiB
TypeScript
97 lines
3.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { execFile } from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js';
|
|
import { clearDocumentPreviewCache } from '../src/document-preview-cache.js';
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
|
callback(null, { stdout: '', stderr: '' });
|
|
return {};
|
|
}),
|
|
}));
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
|
readFile: vi.fn(async () => Buffer.from('png')),
|
|
readdir: vi.fn(async () => ['converted.pdf']),
|
|
mkdir: vi.fn(async () => undefined),
|
|
mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'),
|
|
rename: vi.fn(async () => undefined),
|
|
rm: vi.fn(async () => undefined),
|
|
},
|
|
}));
|
|
|
|
const mockedExecFile = vi.mocked(execFile);
|
|
const mockedStat = vi.mocked(fs.stat);
|
|
const mockedReadFile = vi.mocked(fs.readFile);
|
|
const mockedReaddir = vi.mocked(fs.readdir);
|
|
const mockedMkdir = vi.mocked(fs.mkdir);
|
|
const mockedMkdtemp = vi.mocked(fs.mkdtemp);
|
|
const mockedRename = vi.mocked(fs.rename);
|
|
const mockedRm = vi.mocked(fs.rm);
|
|
|
|
describe('document-thumbnailer', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clearDocumentPreviewCache();
|
|
mockedStat.mockImplementation(async (path) => {
|
|
const pathText = String(path);
|
|
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
|
throw new Error('ENOENT');
|
|
}
|
|
|
|
return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never;
|
|
});
|
|
mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never);
|
|
mockedReaddir.mockResolvedValue(['converted.pdf'] as never);
|
|
mockedMkdir.mockResolvedValue(undefined as never);
|
|
mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never);
|
|
mockedRename.mockResolvedValue(undefined as never);
|
|
mockedRm.mockResolvedValue(undefined as never);
|
|
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
|
callback(null, { stdout: '', stderr: '' });
|
|
return {} as never;
|
|
});
|
|
});
|
|
|
|
it('renders first-page thumbnails for large documents without an app-level size cap', async () => {
|
|
const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf');
|
|
|
|
expect(result).toEqual({
|
|
content: Buffer.from('large thumbnail'),
|
|
contentType: 'image/png',
|
|
});
|
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
|
'pdftoppm',
|
|
expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']),
|
|
expect.any(Object),
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
|
|
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
|
mockedMkdtemp.mockImplementation(async (prefix) =>
|
|
String(prefix).includes('codeman-document-preview-cache')
|
|
? '/tmp/codeman-document-preview-cache/work-test'
|
|
: '/tmp/codeman-thumb-pdf-test'
|
|
);
|
|
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
|
callback(null, { stdout: '', stderr: '' });
|
|
return {} as never;
|
|
});
|
|
|
|
const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx');
|
|
|
|
expect(result).toEqual({
|
|
content: Buffer.from('large thumbnail'),
|
|
contentType: 'image/png',
|
|
});
|
|
const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm');
|
|
expect(pdftoppmCall?.[1]).toEqual(
|
|
expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')])
|
|
);
|
|
});
|
|
});
|