diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 9fc218b..f5c1afa 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -1269,6 +1269,17 @@ export class PageRenderer { return true; }; + const resolveListContent = (post: PostData): string => { + const showTitle = shouldShowListTitle(post); + const excerpt = typeof post.excerpt === 'string' ? post.excerpt.trim() : ''; + + if (showTitle && excerpt.length > 0) { + return post.excerpt as string; + } + + return post.content; + }; + const dayBlocks: DayBlockContext[] = []; if (!options.archiveGrouping) { @@ -1280,7 +1291,7 @@ export class PageRenderer { id: post.id, slug: post.slug, title: post.title, - content: post.content, + content: resolveListContent(post), show_title: shouldShowListTitle(post), })), }); @@ -1306,7 +1317,7 @@ export class PageRenderer { id: post.id, slug: post.slug, title: post.title, - content: post.content, + content: resolveListContent(post), show_title: shouldShowListTitle(post), }); } diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index ebb9221..50d92ad 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -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(); const mockPostMedia = new Map(); const mockFiles = new Map(); const normalizePath = (value: string): string => value.replace(/\\/g, '/'); +const sharpMetadataFailurePaths = new Set(); +const sharpThumbnailFailurePaths = new Set(); // 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; - 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; - - 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 () => { diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index 913bc81..61cfb49 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -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(); @@ -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(); diff --git a/tests/engine/PageRenderer.renderPostList.test.ts b/tests/engine/PageRenderer.renderPostList.test.ts index 3c7cb21..aa6b660 100644 --- a/tests/engine/PageRenderer.renderPostList.test.ts +++ b/tests/engine/PageRenderer.renderPostList.test.ts @@ -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(), canonicalMediaPathBySourcePath: new Map(), }; +function makePost(overrides: Partial = {}): 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('
Archive 22. February 2026'); }); + + 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'); + }); }); diff --git a/tests/renderer/macros/registry.test.ts b/tests/renderer/macros/registry.test.ts index 63bc264..5b3bfc8 100644 --- a/tests/renderer/macros/registry.test.ts +++ b/tests/renderer/macros/registry.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { withCapturedConsole } from '../../utils'; import { registerMacro, getMacro, @@ -52,18 +53,18 @@ describe('Macro Registry', () => { }); it('should overwrite existing macro with warning', () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const macro1 = createTestMacro({ name: 'dupe', description: 'First' }); - const macro2 = createTestMacro({ name: 'dupe', description: 'Second' }); - - registerMacro(macro1); - registerMacro(macro2); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already registered')); - expect(getMacro('dupe')?.description).toBe('Second'); - - consoleSpy.mockRestore(); + return withCapturedConsole('warn', ({ spy, text }) => { + const macro1 = createTestMacro({ name: 'dupe', description: 'First' }); + const macro2 = createTestMacro({ name: 'dupe', description: 'Second' }); + + registerMacro(macro1); + registerMacro(macro2); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('already registered')); + expect(text()).toContain('Macro "dupe" is already registered. Overwriting.'); + expect(getMacro('dupe')?.description).toBe('Second'); + }); }); }); diff --git a/tests/utils/consoleCapture.test.ts b/tests/utils/consoleCapture.test.ts new file mode 100644 index 0000000..e63cc0d --- /dev/null +++ b/tests/utils/consoleCapture.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { withCapturedConsole } from './consoleCapture'; + +describe('withCapturedConsole', () => { + it('captures expected console output without leaking it to the test run', async () => { + await withCapturedConsole('error', async (captured) => { + const error = new Error('expected failure'); + + console.error('[Test]', error); + + expect(captured.spy).toHaveBeenCalledWith('[Test]', error); + expect(captured.text()).toContain('[Test]'); + expect(captured.text()).toContain('expected failure'); + }); + }); +}); \ No newline at end of file diff --git a/tests/utils/consoleCapture.ts b/tests/utils/consoleCapture.ts new file mode 100644 index 0000000..1643970 --- /dev/null +++ b/tests/utils/consoleCapture.ts @@ -0,0 +1,40 @@ +import { vi } from 'vitest'; + +type ConsoleMethod = 'error' | 'warn' | 'log' | 'info' | 'debug'; + +function stringifyConsoleArg(arg: unknown): string { + if (arg instanceof Error) { + return arg.stack ?? arg.message; + } + + if (typeof arg === 'string') { + return arg; + } + + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } +} + +export async function withCapturedConsole( + method: ConsoleMethod, + run: (captured: { + spy: ReturnType; + calls: () => unknown[][]; + text: () => string; + }) => Promise | T, +): Promise { + const spy = vi.spyOn(console, method).mockImplementation(() => {}); + + try { + return await run({ + spy, + calls: () => spy.mock.calls, + text: () => spy.mock.calls.map((call) => call.map(stringifyConsoleArg).join(' ')).join('\n'), + }); + } finally { + spy.mockRestore(); + } +} \ No newline at end of file diff --git a/tests/utils/index.ts b/tests/utils/index.ts index e9d0b43..84014a0 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -3,4 +3,5 @@ * Re-exports all test utilities for convenient importing */ +export * from './consoleCapture'; export * from './factories';