Fix/post list excerpt rendering (#41)

* feat: use excerpts in post lists

* chore: made testing less noisy

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-07 10:15:57 +01:00
committed by GitHub
parent 9871cb827f
commit f1c9038803
8 changed files with 349 additions and 124 deletions

View File

@@ -6,6 +6,7 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { withCapturedConsole } from '../utils';
import { resetMockCounters } from '../utils/factories';
// Mock electron BEFORE importing MediaEngine (it uses require('electron') internally)
@@ -30,6 +31,8 @@ const mockMedia = new Map<string, any>();
const mockPostMedia = new Map<string, any>();
const mockFiles = new Map<string, Buffer | string>();
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
const sharpMetadataFailurePaths = new Set<string>();
const sharpThumbnailFailurePaths = new Set<string>();
// Track database operations for testing
let mediaDeleteCalled = false;
@@ -169,6 +172,36 @@ vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-media-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
vi.mock('sharp', () => ({
default: vi.fn((sourcePath: string) => {
const normalizedSourcePath = normalizePath(sourcePath);
const pipeline = {
metadata: vi.fn(async () => {
if (Array.from(sharpMetadataFailurePaths).some((failurePath) => normalizedSourcePath.startsWith(failurePath))) {
throw new Error(`sharp metadata failed for ${sourcePath}`);
}
return {
width: 640,
height: 480,
};
}),
resize: vi.fn(() => pipeline),
jpeg: vi.fn(() => pipeline),
webp: vi.fn(() => pipeline),
toFile: vi.fn(async (outputPath: string) => {
if (Array.from(sharpThumbnailFailurePaths).some((failurePath) => normalizedSourcePath.startsWith(failurePath))) {
throw new Error(`sharp thumbnail failed for ${sourcePath}`);
}
mockFiles.set(normalizePath(outputPath), Buffer.from('thumbnail-data'));
}),
};
return pipeline;
}),
}));
describe('MediaEngine', () => {
let mediaEngine: MediaEngine;
@@ -177,6 +210,8 @@ describe('MediaEngine', () => {
mockMedia.clear();
mockPostMedia.clear();
mockFiles.clear();
sharpMetadataFailurePaths.clear();
sharpThumbnailFailurePaths.clear();
mediaDeleteCalled = false;
postMediaDeleteCalled = false;
postMediaInserts = [];
@@ -249,20 +284,11 @@ describe('MediaEngine', () => {
});
describe('Media Import', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Spy on console.error to suppress expected error output (sharp can't read mock files)
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Setup a source file for import
const imageBuffer = Buffer.from('fake-image-data');
mockFiles.set('/source/image.jpg', imageBuffer);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should import media from source path', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
@@ -414,20 +440,40 @@ describe('MediaEngine', () => {
});
it('should store width and height when provided', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
width: 1920,
height: 1080,
});
sharpMetadataFailurePaths.add('/mock/userData/projects/default/media/');
expect(media.width).toBe(1920);
expect(media.height).toBe(1080);
await withCapturedConsole('error', async ({ spy, text }) => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
width: 1920,
height: 1080,
});
expect(media.width).toBe(1920);
expect(media.height).toBe(1080);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'Failed to get image dimensions:',
expect.any(Error)
);
expect(text()).toContain('sharp metadata failed');
});
});
it('should handle media without dimensions', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
sharpMetadataFailurePaths.add('/mock/userData/projects/default/media/');
expect(media.width).toBeUndefined();
expect(media.height).toBeUndefined();
await withCapturedConsole('error', async ({ spy, text }) => {
const media = await mediaEngine.importMedia('/source/image.jpg');
expect(media.width).toBeUndefined();
expect(media.height).toBeUndefined();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'Failed to get image dimensions:',
expect.any(Error)
);
expect(text()).toContain('sharp metadata failed');
});
});
});
@@ -1594,39 +1640,36 @@ linkedPostIds: ["post-x"]`);
describe('generateThumbnails', () => {
it('should skip non-image media', async () => {
const fs = await import('fs/promises');
sharpThumbnailFailurePaths.add('/mock/media/document.pdf');
// Spy on console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'pdf-id',
mimeType: 'application/pdf',
filePath: '/mock/media/document.pdf',
}),
await withCapturedConsole('error', async ({ spy, text }) => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'pdf-id',
mimeType: 'application/pdf',
filePath: '/mock/media/document.pdf',
}),
});
return chain;
});
return chain;
vi.mocked(fs.writeFile).mockClear();
await mediaEngine.generateThumbnails('pdf-id', '/mock/media/document.pdf');
const thumbnailWrites = vi.mocked(fs.writeFile).mock.calls.filter(
call => String(call[0]).includes('thumbnail')
);
expect(thumbnailWrites).toHaveLength(0);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'Failed to generate thumbnails:',
expect.any(Error)
);
expect(text()).toContain('sharp thumbnail failed for /mock/media/document.pdf');
});
vi.mocked(fs.writeFile).mockClear();
await mediaEngine.generateThumbnails('pdf-id');
// Should not write any thumbnail files for non-images
const thumbnailWrites = vi.mocked(fs.writeFile).mock.calls.filter(
call => String(call[0]).includes('thumbnail')
);
expect(thumbnailWrites).toHaveLength(0);
// Verify error was logged (graceful degradation behavior)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to generate thumbnails:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
@@ -1671,17 +1714,6 @@ linkedPostIds: ["post-x"]`);
});
describe('replaceMediaFile', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Spy on console.error to suppress expected error output (sharp can't read mock files)
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should return null for non-existent media', async () => {
// Mock database to return nothing
vi.mocked(mockLocalDb.select).mockImplementation(() => {
@@ -1700,9 +1732,6 @@ linkedPostIds: ["post-x"]`);
it('should replace file and update database when checksum differs', async () => {
const fs = await import('fs/promises');
// Spy on console.error to suppress expected error output (sharp can't read mock file)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Setup new source file with different content
const newImageBuffer = Buffer.from('new-image-data-different');
mockFiles.set('/source/new-image.jpg', newImageBuffer);
@@ -1736,21 +1765,21 @@ linkedPostIds: ["post-x"]`);
// Clear any previous mock calls
vi.mocked(fs.copyFile).mockClear();
sharpMetadataFailurePaths.add('/mock/media/2025/01/media-id-123.jpg');
const result = await mediaEngine.replaceMediaFile('media-id-123', '/source/new-image.jpg');
await withCapturedConsole('error', async ({ spy, text }) => {
const result = await mediaEngine.replaceMediaFile('media-id-123', '/source/new-image.jpg');
expect(result).not.toBeNull();
expect(result!.id).toBe('media-id-123');
// File should be copied to the existing location
expect(fs.copyFile).toHaveBeenCalledWith('/source/new-image.jpg', existingMedia.filePath);
// Verify error was logged (graceful degradation for image dimensions)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to get image dimensions:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
expect(result).not.toBeNull();
expect(result!.id).toBe('media-id-123');
expect(fs.copyFile).toHaveBeenCalledWith('/source/new-image.jpg', existingMedia.filePath);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'Failed to get image dimensions:',
expect.any(Error)
);
expect(text()).toContain('sharp metadata failed for /mock/media/2025/01/media-id-123.jpg');
});
});
it('should not replace file when checksum is the same', async () => {

View File

@@ -11,6 +11,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as path from 'path';
import { withCapturedConsole } from '../utils';
// Mock data stores
const mockFiles = new Map<string, string>();
@@ -561,22 +562,19 @@ describe('MetaEngine', () => {
});
it('should throw non-ENOENT errors when loading project metadata', async () => {
// Spy on console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock readFile to throw a non-ENOENT error
const originalReadFile = vi.mocked(fs.readFile);
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
await expect(metaEngine.loadProjectMetadata()).rejects.toThrow('Permission denied');
// Verify error was logged before rethrowing
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[MetaEngine] Failed to load project metadata:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
await withCapturedConsole('error', async ({ spy, text }) => {
const originalReadFile = vi.mocked(fs.readFile);
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
await expect(metaEngine.loadProjectMetadata()).rejects.toThrow('Permission denied');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'[MetaEngine] Failed to load project metadata:',
expect.any(Error)
);
expect(text()).toContain('Permission denied');
});
});
it('should set and get defaultAuthor in project metadata', async () => {
@@ -854,22 +852,19 @@ describe('MetaEngine', () => {
});
it('should throw non-ENOENT errors when loading categories', async () => {
// Spy on console.error to suppress expected error output
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock readFile to throw a non-ENOENT error
const originalReadFile = vi.mocked(fs.readFile);
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Disk full'), { code: 'ENOSPC' }));
await expect(metaEngine.loadCategories()).rejects.toThrow('Disk full');
// Verify error was logged before rethrowing
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[MetaEngine] Failed to load categories:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
await withCapturedConsole('error', async ({ spy, text }) => {
const originalReadFile = vi.mocked(fs.readFile);
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Disk full'), { code: 'ENOSPC' }));
await expect(metaEngine.loadCategories()).rejects.toThrow('Disk full');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'[MetaEngine] Failed to load categories:',
expect.any(Error)
);
expect(text()).toContain('Disk full');
});
});
it('should emit projectMetadataChanged event when metadata is modified', async () => {
@@ -971,7 +966,16 @@ describe('MetaEngine', () => {
{ categories: JSON.stringify(['db-cat']) },
];
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
await withCapturedConsole('warn', async ({ spy, text }) => {
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'[MetaEngine] Failed to parse categories JSON, treating as empty and rebuilding from DB/defaults:',
expect.any(Error)
);
expect(text()).toContain('Unexpected end of JSON input');
});
const categories = await metaEngine.getCategories();
expect(categories).toContain('db-cat');
@@ -988,7 +992,16 @@ describe('MetaEngine', () => {
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news']));
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), '{"news":');
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
await withCapturedConsole('warn', async ({ spy, text }) => {
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'[MetaEngine] Failed to parse category metadata JSON, using default metadata merge:',
expect.any(Error)
);
expect(text()).toContain('Unexpected end of JSON input');
});
const metadata = await metaEngine.getProjectMetadata() as any;
expect(metadata?.name).toBe('Synced Project');
@@ -1293,7 +1306,16 @@ describe('MetaEngine', () => {
}));
mockFiles.set(normalizePath(`${metaDir}/publishing.json`), '{"sshHost":');
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
await withCapturedConsole('warn', async ({ spy, text }) => {
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
'[MetaEngine] Failed to parse publishing preferences JSON, using null:',
expect.any(Error)
);
expect(text()).toContain('Unexpected end of JSON input');
});
const prefs = await metaEngine.getPublishingPreferences();
expect(prefs).toBeNull();

View File

@@ -1,11 +1,31 @@
import { describe, expect, it } from 'vitest';
import { PageRenderer, type HtmlRewriteContext } from '../../src/main/engine/PageRenderer';
import type { PostData } from '../../src/main/engine/PostEngine';
const rewriteContext: HtmlRewriteContext = {
canonicalPostPathBySlug: new Map<string, string>(),
canonicalMediaPathBySourcePath: new Map<string, string>(),
};
function makePost(overrides: Partial<PostData> = {}): PostData {
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'project-1',
title: overrides.title ?? 'Test Post',
slug: overrides.slug ?? 'test-post',
excerpt: overrides.excerpt,
content: overrides.content ?? 'Default body content.',
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language ?? 'en',
createdAt: overrides.createdAt ?? new Date('2026-02-22T10:00:00Z'),
updatedAt: overrides.updatedAt ?? new Date('2026-02-22T10:00:00Z'),
publishedAt: overrides.publishedAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
describe('PageRenderer.renderPostList', () => {
it('renders base framework for empty day archive pages instead of returning empty html', async () => {
const renderer = new PageRenderer(
@@ -31,4 +51,88 @@ describe('PageRenderer.renderPostList', () => {
expect(html).toContain('<section class="post-list"');
expect(html).toContain('<h1 class="archive-heading">Archive 22. February 2026</h1>');
});
it('shows excerpt only for titled posts that have one, but keeps full body otherwise', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{
getLinkedMediaDataForPost: async () => [],
setProjectContext: () => {},
},
);
const html = await renderer.renderPostList([
makePost({
id: 'with-excerpt',
slug: 'with-excerpt',
title: 'With Excerpt',
excerpt: 'EXCERPT ONLY CONTENT',
content: 'FULL BODY SHOULD NOT APPEAR',
}),
makePost({
id: 'without-excerpt',
slug: 'without-excerpt',
title: 'Without Excerpt',
content: 'FULL BODY WITHOUT EXCERPT',
}),
makePost({
id: 'aside-post',
slug: 'aside-post',
title: 'Hidden Title',
excerpt: 'ASIDE EXCERPT SHOULD NOT APPEAR',
content: 'ASIDE FULL BODY SHOULD APPEAR',
categories: ['aside'],
}),
], rewriteContext, {
archiveGrouping: false,
routeKind: 'non-date',
basePathname: '/',
page_title: 'Test Blog',
language: 'en',
menu_items: [],
categorySettings: {
aside: {
renderInLists: true,
showTitle: false,
},
},
});
expect(html).toContain('EXCERPT ONLY CONTENT');
expect(html).not.toContain('FULL BODY SHOULD NOT APPEAR');
expect(html).toContain('FULL BODY WITHOUT EXCERPT');
expect(html).toContain('ASIDE FULL BODY SHOULD APPEAR');
expect(html).not.toContain('ASIDE EXCERPT SHOULD NOT APPEAR');
});
});
describe('PageRenderer.renderSinglePost', () => {
it('always renders the full body even when an excerpt exists', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{
getLinkedMediaDataForPost: async () => [],
setProjectContext: () => {},
},
);
const html = await renderer.renderSinglePost(
makePost({
id: 'single-post',
slug: 'single-post',
title: 'Single Post',
excerpt: 'SINGLE EXCERPT SHOULD NOT REPLACE BODY',
content: 'SINGLE FULL BODY SHOULD APPEAR',
}),
rewriteContext,
{
page_title: 'Single Post',
language: 'en',
menu_items: [],
},
);
expect(html).toContain('SINGLE FULL BODY SHOULD APPEAR');
expect(html).not.toContain('SINGLE EXCERPT SHOULD NOT REPLACE BODY');
});
});