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:
@@ -1269,6 +1269,17 @@ export class PageRenderer {
|
|||||||
return true;
|
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[] = [];
|
const dayBlocks: DayBlockContext[] = [];
|
||||||
|
|
||||||
if (!options.archiveGrouping) {
|
if (!options.archiveGrouping) {
|
||||||
@@ -1280,7 +1291,7 @@ export class PageRenderer {
|
|||||||
id: post.id,
|
id: post.id,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
content: post.content,
|
content: resolveListContent(post),
|
||||||
show_title: shouldShowListTitle(post),
|
show_title: shouldShowListTitle(post),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -1306,7 +1317,7 @@ export class PageRenderer {
|
|||||||
id: post.id,
|
id: post.id,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
content: post.content,
|
content: resolveListContent(post),
|
||||||
show_title: shouldShowListTitle(post),
|
show_title: shouldShowListTitle(post),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { withCapturedConsole } from '../utils';
|
||||||
import { resetMockCounters } from '../utils/factories';
|
import { resetMockCounters } from '../utils/factories';
|
||||||
|
|
||||||
// Mock electron BEFORE importing MediaEngine (it uses require('electron') internally)
|
// 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 mockPostMedia = new Map<string, any>();
|
||||||
const mockFiles = new Map<string, Buffer | string>();
|
const mockFiles = new Map<string, Buffer | string>();
|
||||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
|
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
|
||||||
|
const sharpMetadataFailurePaths = new Set<string>();
|
||||||
|
const sharpThumbnailFailurePaths = new Set<string>();
|
||||||
|
|
||||||
// Track database operations for testing
|
// Track database operations for testing
|
||||||
let mediaDeleteCalled = false;
|
let mediaDeleteCalled = false;
|
||||||
@@ -169,6 +172,36 @@ vi.mock('uuid', () => ({
|
|||||||
v4: vi.fn(() => 'mock-media-uuid-' + Math.random().toString(36).substr(2, 9)),
|
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', () => {
|
describe('MediaEngine', () => {
|
||||||
let mediaEngine: MediaEngine;
|
let mediaEngine: MediaEngine;
|
||||||
|
|
||||||
@@ -177,6 +210,8 @@ describe('MediaEngine', () => {
|
|||||||
mockMedia.clear();
|
mockMedia.clear();
|
||||||
mockPostMedia.clear();
|
mockPostMedia.clear();
|
||||||
mockFiles.clear();
|
mockFiles.clear();
|
||||||
|
sharpMetadataFailurePaths.clear();
|
||||||
|
sharpThumbnailFailurePaths.clear();
|
||||||
mediaDeleteCalled = false;
|
mediaDeleteCalled = false;
|
||||||
postMediaDeleteCalled = false;
|
postMediaDeleteCalled = false;
|
||||||
postMediaInserts = [];
|
postMediaInserts = [];
|
||||||
@@ -249,21 +284,12 @@ describe('MediaEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Media Import', () => {
|
describe('Media Import', () => {
|
||||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
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
|
// Setup a source file for import
|
||||||
const imageBuffer = Buffer.from('fake-image-data');
|
const imageBuffer = Buffer.from('fake-image-data');
|
||||||
mockFiles.set('/source/image.jpg', imageBuffer);
|
mockFiles.set('/source/image.jpg', imageBuffer);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should import media from source path', async () => {
|
it('should import media from source path', async () => {
|
||||||
const media = await mediaEngine.importMedia('/source/image.jpg');
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
||||||
|
|
||||||
@@ -414,20 +440,40 @@ describe('MediaEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should store width and height when provided', async () => {
|
it('should store width and height when provided', async () => {
|
||||||
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
sharpMetadataFailurePaths.add('/mock/userData/projects/default/media/');
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.width).toBe(1920);
|
await withCapturedConsole('error', async ({ spy, text }) => {
|
||||||
expect(media.height).toBe(1080);
|
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 () => {
|
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();
|
await withCapturedConsole('error', async ({ spy, text }) => {
|
||||||
expect(media.height).toBeUndefined();
|
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', () => {
|
describe('generateThumbnails', () => {
|
||||||
it('should skip non-image media', async () => {
|
it('should skip non-image media', async () => {
|
||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
|
sharpThumbnailFailurePaths.add('/mock/media/document.pdf');
|
||||||
|
|
||||||
// Spy on console.error to suppress expected error output
|
await withCapturedConsole('error', async ({ spy, text }) => {
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
chain.where = vi.fn().mockReturnValue({
|
||||||
const chain = createSelectChain();
|
...chain,
|
||||||
chain.where = vi.fn().mockReturnValue({
|
get: vi.fn().mockResolvedValue({
|
||||||
...chain,
|
id: 'pdf-id',
|
||||||
get: vi.fn().mockResolvedValue({
|
mimeType: 'application/pdf',
|
||||||
id: 'pdf-id',
|
filePath: '/mock/media/document.pdf',
|
||||||
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', () => {
|
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 () => {
|
it('should return null for non-existent media', async () => {
|
||||||
// Mock database to return nothing
|
// Mock database to return nothing
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
@@ -1700,9 +1732,6 @@ linkedPostIds: ["post-x"]`);
|
|||||||
it('should replace file and update database when checksum differs', async () => {
|
it('should replace file and update database when checksum differs', async () => {
|
||||||
const fs = await import('fs/promises');
|
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
|
// Setup new source file with different content
|
||||||
const newImageBuffer = Buffer.from('new-image-data-different');
|
const newImageBuffer = Buffer.from('new-image-data-different');
|
||||||
mockFiles.set('/source/new-image.jpg', newImageBuffer);
|
mockFiles.set('/source/new-image.jpg', newImageBuffer);
|
||||||
@@ -1736,21 +1765,21 @@ linkedPostIds: ["post-x"]`);
|
|||||||
|
|
||||||
// Clear any previous mock calls
|
// Clear any previous mock calls
|
||||||
vi.mocked(fs.copyFile).mockClear();
|
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).not.toBeNull();
|
||||||
expect(result!.id).toBe('media-id-123');
|
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);
|
||||||
expect(fs.copyFile).toHaveBeenCalledWith('/source/new-image.jpg', existingMedia.filePath);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
// Verify error was logged (graceful degradation for image dimensions)
|
'Failed to get image dimensions:',
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect.any(Error)
|
||||||
'Failed to get image dimensions:',
|
);
|
||||||
expect.any(Error)
|
expect(text()).toContain('sharp metadata failed for /mock/media/2025/01/media-id-123.jpg');
|
||||||
);
|
});
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not replace file when checksum is the same', async () => {
|
it('should not replace file when checksum is the same', async () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { withCapturedConsole } from '../utils';
|
||||||
|
|
||||||
// Mock data stores
|
// Mock data stores
|
||||||
const mockFiles = new Map<string, string>();
|
const mockFiles = new Map<string, string>();
|
||||||
@@ -561,22 +562,19 @@ describe('MetaEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw non-ENOENT errors when loading project metadata', async () => {
|
it('should throw non-ENOENT errors when loading project metadata', async () => {
|
||||||
// Spy on console.error to suppress expected error output
|
await withCapturedConsole('error', async ({ spy, text }) => {
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const originalReadFile = vi.mocked(fs.readFile);
|
||||||
|
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
|
||||||
|
|
||||||
// Mock readFile to throw a non-ENOENT error
|
await expect(metaEngine.loadProjectMetadata()).rejects.toThrow('Permission denied');
|
||||||
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(
|
||||||
// Verify error was logged before rethrowing
|
'[MetaEngine] Failed to load project metadata:',
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect.any(Error)
|
||||||
'[MetaEngine] Failed to load project metadata:',
|
);
|
||||||
expect.any(Error)
|
expect(text()).toContain('Permission denied');
|
||||||
);
|
});
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set and get defaultAuthor in project metadata', async () => {
|
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 () => {
|
it('should throw non-ENOENT errors when loading categories', async () => {
|
||||||
// Spy on console.error to suppress expected error output
|
await withCapturedConsole('error', async ({ spy, text }) => {
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const originalReadFile = vi.mocked(fs.readFile);
|
||||||
|
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Disk full'), { code: 'ENOSPC' }));
|
||||||
|
|
||||||
// Mock readFile to throw a non-ENOENT error
|
await expect(metaEngine.loadCategories()).rejects.toThrow('Disk full');
|
||||||
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(
|
||||||
// Verify error was logged before rethrowing
|
'[MetaEngine] Failed to load categories:',
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect.any(Error)
|
||||||
'[MetaEngine] Failed to load categories:',
|
);
|
||||||
expect.any(Error)
|
expect(text()).toContain('Disk full');
|
||||||
);
|
});
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit projectMetadataChanged event when metadata is modified', async () => {
|
it('should emit projectMetadataChanged event when metadata is modified', async () => {
|
||||||
@@ -971,7 +966,16 @@ describe('MetaEngine', () => {
|
|||||||
{ categories: JSON.stringify(['db-cat']) },
|
{ 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();
|
const categories = await metaEngine.getCategories();
|
||||||
expect(categories).toContain('db-cat');
|
expect(categories).toContain('db-cat');
|
||||||
@@ -988,7 +992,16 @@ describe('MetaEngine', () => {
|
|||||||
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news']));
|
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news']));
|
||||||
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), '{"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;
|
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||||
expect(metadata?.name).toBe('Synced Project');
|
expect(metadata?.name).toBe('Synced Project');
|
||||||
@@ -1293,7 +1306,16 @@ describe('MetaEngine', () => {
|
|||||||
}));
|
}));
|
||||||
mockFiles.set(normalizePath(`${metaDir}/publishing.json`), '{"sshHost":');
|
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();
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
expect(prefs).toBeNull();
|
expect(prefs).toBeNull();
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { PageRenderer, type HtmlRewriteContext } from '../../src/main/engine/PageRenderer';
|
import { PageRenderer, type HtmlRewriteContext } from '../../src/main/engine/PageRenderer';
|
||||||
|
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||||
|
|
||||||
const rewriteContext: HtmlRewriteContext = {
|
const rewriteContext: HtmlRewriteContext = {
|
||||||
canonicalPostPathBySlug: new Map<string, string>(),
|
canonicalPostPathBySlug: new Map<string, string>(),
|
||||||
canonicalMediaPathBySourcePath: 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', () => {
|
describe('PageRenderer.renderPostList', () => {
|
||||||
it('renders base framework for empty day archive pages instead of returning empty html', async () => {
|
it('renders base framework for empty day archive pages instead of returning empty html', async () => {
|
||||||
const renderer = new PageRenderer(
|
const renderer = new PageRenderer(
|
||||||
@@ -31,4 +51,88 @@ describe('PageRenderer.renderPostList', () => {
|
|||||||
expect(html).toContain('<section class="post-list"');
|
expect(html).toContain('<section class="post-list"');
|
||||||
expect(html).toContain('<h1 class="archive-heading">Archive 22. February 2026</h1>');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { withCapturedConsole } from '../../utils';
|
||||||
import {
|
import {
|
||||||
registerMacro,
|
registerMacro,
|
||||||
getMacro,
|
getMacro,
|
||||||
@@ -52,18 +53,18 @@ describe('Macro Registry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should overwrite existing macro with warning', () => {
|
it('should overwrite existing macro with warning', () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
return withCapturedConsole('warn', ({ spy, text }) => {
|
||||||
|
const macro1 = createTestMacro({ name: 'dupe', description: 'First' });
|
||||||
|
const macro2 = createTestMacro({ name: 'dupe', description: 'Second' });
|
||||||
|
|
||||||
const macro1 = createTestMacro({ name: 'dupe', description: 'First' });
|
registerMacro(macro1);
|
||||||
const macro2 = createTestMacro({ name: 'dupe', description: 'Second' });
|
registerMacro(macro2);
|
||||||
|
|
||||||
registerMacro(macro1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
registerMacro(macro2);
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('already registered'));
|
||||||
|
expect(text()).toContain('Macro "dupe" is already registered. Overwriting.');
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already registered'));
|
expect(getMacro('dupe')?.description).toBe('Second');
|
||||||
expect(getMacro('dupe')?.description).toBe('Second');
|
});
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
17
tests/utils/consoleCapture.test.ts
Normal file
17
tests/utils/consoleCapture.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/utils/consoleCapture.ts
Normal file
40
tests/utils/consoleCapture.ts
Normal file
@@ -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<T>(
|
||||||
|
method: ConsoleMethod,
|
||||||
|
run: (captured: {
|
||||||
|
spy: ReturnType<typeof vi.spyOn>;
|
||||||
|
calls: () => unknown[][];
|
||||||
|
text: () => string;
|
||||||
|
}) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@
|
|||||||
* Re-exports all test utilities for convenient importing
|
* Re-exports all test utilities for convenient importing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from './consoleCapture';
|
||||||
export * from './factories';
|
export * from './factories';
|
||||||
|
|||||||
Reference in New Issue
Block a user