From 54a8ba5ceb20233fdb9b031c9ddbb377fa8e6ba9 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 21:28:16 +0100 Subject: [PATCH] feat: preview server startup directly --- src/main/engine/PreviewServer.ts | 35 ++++++++- src/main/main.ts | 13 ++++ tests/engine/PreviewServer.test.ts | 53 +++++++++++++ tests/engine/mainStartup.test.ts | 117 +++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 tests/engine/mainStartup.test.ts diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index c3c33e8..906f2b5 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -8,6 +8,8 @@ import { getProjectEngine } from './ProjectEngine'; interface ActiveProjectContext { projectId: string; dataDir?: string; + projectName?: string; + projectDescription?: string; } interface PostEngineContract { @@ -57,6 +59,30 @@ function clampMaxPostsPerPage(value: unknown): number { return normalized; } +function resolvePageTitle(metadata: ProjectMetadata | null, fallbackProjectName?: string, fallbackProjectDescription?: string): string { + const candidate = metadata?.description?.trim(); + if (candidate) { + return candidate; + } + + const metadataName = metadata?.name?.trim(); + if (metadataName) { + return metadataName; + } + + const descriptionFallback = fallbackProjectDescription?.trim(); + if (descriptionFallback) { + return descriptionFallback; + } + + const fallback = fallbackProjectName?.trim(); + if (fallback) { + return fallback; + } + + return 'Blog Preview'; +} + function escapeHtml(value: string): string { return value .replace(/&/g, '&') @@ -162,7 +188,12 @@ export class PreviewServer { const activeProject = await projectEngine.getActiveProject(); const projectId = activeProject?.id ?? 'default'; const dataDir = projectEngine.getDataDir(projectId, activeProject?.dataPath); - return { projectId, dataDir }; + return { + projectId, + dataDir, + projectName: activeProject?.name, + projectDescription: activeProject?.description ?? undefined, + }; }); } @@ -267,7 +298,7 @@ export class PreviewServer { return; } - this.respond(res, 200, getPageHtml(result, 'Blog Preview')); + this.respond(res, 200, getPageHtml(result, resolvePageTitle(metadata, context.projectName, context.projectDescription))); } catch (error) { console.error('[PreviewServer] Request failed:', error); this.respond(res, 500, 'Internal Server Error'); diff --git a/src/main/main.ts b/src/main/main.ts index 2bc0405..7af1f10 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -109,6 +109,14 @@ async function openPreviewInBrowser(): Promise { await shell.openExternal(`${previewServer.getBaseUrl()}/`); } +async function startPreviewServerOnAppStart(): Promise { + if (!previewServer) { + previewServer = new PreviewServer(); + } + + await previewServer.start(PREVIEW_SERVER_PORT); +} + function createApplicationMenu(): Menu { const template: MenuItemConstructorOptions[] = [ { @@ -445,6 +453,11 @@ async function initialize(): Promise { // App lifecycle app.whenReady().then(async () => { await initialize(); + try { + await startPreviewServerOnAppStart(); + } catch (error) { + console.error('Failed to start preview server on app startup:', error); + } createWindow(); app.on('activate', () => { diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 9e923b0..787a793 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -275,4 +275,57 @@ describe('PreviewServer', () => { const renderedPosts = (html.match(/
/g) || []).length; expect(renderedPosts).toBe(7); }); + + it('uses project description from metadata in page title', async () => { + server = new PreviewServer({ + postEngine: makeEngine([makePost()]), + settingsEngine: { + setProjectContext: vi.fn(), + async getProjectMetadata() { + return { + name: 'My Great Blog', + description: 'A wonderful publication', + maxPostsPerPage: 50, + }; + }, + }, + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const response = await fetch(`${server.getBaseUrl()}/`); + expect(response.status).toBe(200); + const html = await response.text(); + + expect(html).toContain('A wonderful publication'); + expect(html).not.toContain('My Great Blog'); + expect(html).not.toContain('Blog Preview'); + }); + + it('falls back to active project name in page title when metadata is unavailable', async () => { + server = new PreviewServer({ + postEngine: makeEngine([makePost()]), + settingsEngine: { + setProjectContext: vi.fn(), + async getProjectMetadata() { + return null; + }, + }, + getActiveProjectContext: async () => ({ + projectId: 'default', + dataDir: '/tmp/default', + projectName: 'Configured Project Name', + } as any), + }); + + await server.start(0); + + const response = await fetch(`${server.getBaseUrl()}/`); + expect(response.status).toBe(200); + const html = await response.text(); + + expect(html).toContain('Configured Project Name'); + expect(html).not.toContain('Blog Preview'); + }); }); \ No newline at end of file diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts new file mode 100644 index 0000000..048a3af --- /dev/null +++ b/tests/engine/mainStartup.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('main bootstrap preview behavior', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it('starts preview server during app startup', async () => { + const mockApp = { + name: 'bDS', + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn(), + quit: vi.fn(), + }; + + const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]); + + class MockBrowserWindow { + static getAllWindows = mockBrowserWindowGetAllWindows; + + loadURL = vi.fn(); + loadFile = vi.fn(); + on = vi.fn(); + isDestroyed = vi.fn(() => false); + webContents = { + on: vi.fn(), + send: vi.fn(), + openDevTools: vi.fn(), + toggleDevTools: vi.fn(), + }; + } + + vi.doMock('electron', () => ({ + app: mockApp, + BrowserWindow: MockBrowserWindow, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + ipcMain: { + on: vi.fn(), + handle: vi.fn(), + removeHandler: vi.fn(), + }, + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + }, + net: { + fetch: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + openPath: vi.fn(), + }, + })); + + const mockPreviewStart = vi.fn().mockResolvedValue(4123); + const mockPreviewStop = vi.fn().mockResolvedValue(undefined); + const mockPreviewGetBaseUrl = vi.fn(() => 'http://127.0.0.1:4123'); + + class MockPreviewServer { + start = mockPreviewStart; + stop = mockPreviewStop; + getBaseUrl = mockPreviewGetBaseUrl; + } + + vi.doMock('../../src/main/engine/PreviewServer', () => ({ + PreviewServer: MockPreviewServer, + })); + + vi.doMock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + initializeLocal: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + getLocal: vi.fn(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue(null), + })), + })), + })), + })), + getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })), + })), + })); + + vi.doMock('../../src/main/ipc', () => ({ + registerIpcHandlers: vi.fn(), + registerChatHandlers: vi.fn(), + initializeChatHandlers: vi.fn(), + cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), + })); + + vi.doMock('../../src/main/database/schema', () => ({ + media: {}, + })); + + vi.doMock('drizzle-orm', () => ({ + eq: vi.fn(), + })); + + vi.doMock('../../src/main/engine/MediaEngine', () => ({ + getMediaEngine: vi.fn(() => ({ + getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }), + })), + })); + + await import('../../src/main/main'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockPreviewStart).toHaveBeenCalledWith(4123); + expect(mockApp.whenReady).toHaveBeenCalled(); + }); +});