feat: first round of mcp standalone server

This commit is contained in:
2026-02-28 21:23:22 +01:00
parent 1fc2003260
commit c358e1b11c
67 changed files with 3426 additions and 901 deletions

View File

@@ -8,7 +8,7 @@ const { mockProjectEngine, mockDatabase, mockReadFile } = vi.hoisted(() => ({
getDefaultProjectBaseDir: vi.fn().mockResolvedValue('/home/user/bDS/p1'),
},
mockDatabase: {
getDataPaths: vi.fn().mockReturnValue({ database: '/data/bds.db' }),
getDbPath: vi.fn(() => '/data/bds.db'),
},
mockReadFile: vi.fn(),
}));
@@ -38,8 +38,7 @@ describe('AppApiAdapter', () => {
mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' });
mockProjectEngine.getProjectPaths.mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' });
mockProjectEngine.getDefaultProjectBaseDir.mockResolvedValue('/home/user/bDS/p1');
mockDatabase.getDataPaths.mockReturnValue({ database: '/data/bds.db' });
adapter = new AppApiAdapter();
adapter = new AppApiAdapter(mockProjectEngine as any);
});
it('getDataPaths returns database, posts, and media paths', async () => {

View File

@@ -154,6 +154,7 @@ describe('BlogGenerationEngine', () => {
let tempDir: string;
let mockPostEngine: any;
let mockMediaEngine: any;
let mockPostMediaEngine: any;
beforeEach(async () => {
vi.clearAllMocks();
@@ -165,6 +166,8 @@ describe('BlogGenerationEngine', () => {
mockPostEngine = __mockPostEngine;
const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any;
mockMediaEngine = __mockMediaEngine;
const { __mockPostMediaEngine } = await import('../../src/main/engine/PostMediaEngine') as any;
mockPostMediaEngine = __mockPostMediaEngine;
});
afterEach(async () => {
@@ -210,7 +213,7 @@ describe('BlogGenerationEngine', () => {
) {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const onProgress = vi.fn();
return engine.generate({
projectId: 'test',
@@ -726,7 +729,7 @@ describe('BlogGenerationEngine', () => {
});
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const result = await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
@@ -759,7 +762,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
@@ -789,7 +792,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -816,7 +819,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const onProgress = vi.fn();
await engine.generate({
@@ -848,7 +851,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const onProgress = vi.fn();
await engine.generate({
@@ -878,7 +881,7 @@ describe('BlogGenerationEngine', () => {
const canonicalPathSpy = vi.spyOn(pageRendererModule, 'buildCanonicalPostPath');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -902,7 +905,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -933,7 +936,7 @@ describe('BlogGenerationEngine', () => {
const filterSpy = vi.spyOn(Array.prototype, 'filter');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -975,7 +978,7 @@ describe('BlogGenerationEngine', () => {
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const report = await engine.validateSite({
projectId: 'test',
@@ -1006,7 +1009,7 @@ describe('BlogGenerationEngine', () => {
setupPosts([post]);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -1049,7 +1052,7 @@ describe('BlogGenerationEngine', () => {
setupPosts([post]);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -1112,7 +1115,7 @@ describe('BlogGenerationEngine', () => {
await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), '<html>obsolete</html>', 'utf-8');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const report = await engine.validateSite({
projectId: 'test',
@@ -1158,7 +1161,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
@@ -1237,7 +1240,7 @@ describe('BlogGenerationEngine', () => {
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const generateSpy = vi.spyOn(engine, 'generate');
@@ -1273,7 +1276,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.applyValidation({
projectId: 'test',
@@ -1303,7 +1306,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.applyValidation({
projectId: 'test',
@@ -1335,7 +1338,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.applyValidation({
projectId: 'test',
@@ -1371,7 +1374,7 @@ describe('BlogGenerationEngine', () => {
await writeFile(path.join(tempDir, 'html', 'index.html'), '<html><body>stale-root</body></html>', 'utf-8');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.applyValidation({
projectId: 'test',
@@ -1401,7 +1404,7 @@ describe('BlogGenerationEngine', () => {
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
@@ -1454,7 +1457,7 @@ describe('BlogGenerationEngine', () => {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.applyValidation({
projectId: 'test',
@@ -1497,7 +1500,7 @@ describe('BlogGenerationEngine', () => {
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
const engine = new BlogGenerationEngine();
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');

View File

@@ -31,7 +31,7 @@ describe('GitApiAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
adapter = new GitApiAdapter();
adapter = new GitApiAdapter(mockGitEngine as any, mockProjectEngine as any);
});
it('checkAvailability delegates directly (no projectPath)', async () => {

View File

@@ -233,7 +233,12 @@ describe('ImportExecutionEngine E2E Tests', () => {
vi.clearAllMocks();
// Create engine instance
engine = new ImportExecutionEngine();
engine = new ImportExecutionEngine({
tagEngine: mockTagEngine as any,
postEngine: mockPostEngine as any,
mediaEngine: mockMediaEngine as any,
postMediaEngine: mockPostMediaEngine as any,
});
engine.setProjectContext('test-project', '/mock/test/data');
// Parse the WXR content (mocked readFile will return our pre-loaded content)

View File

@@ -108,6 +108,17 @@ const mockMediaEngine = {
updateMedia: vi.fn().mockResolvedValue({}),
};
// Mock the PostMediaEngine
const mockPostMediaEngine = {
setProjectContext: vi.fn(),
linkMediaToPost: vi.fn().mockResolvedValue(undefined),
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
};
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
PostMediaEngine: vi.fn(() => mockPostMediaEngine),
}));
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => mockMediaEngine),
}));
@@ -275,7 +286,12 @@ describe('ImportExecutionEngine', () => {
insertedPosts.length = 0;
insertedMedia.length = 0;
updatedPosts.length = 0;
engine = new ImportExecutionEngine();
engine = new ImportExecutionEngine({
tagEngine: mockTagEngine as any,
postEngine: mockPostEngine as any,
mediaEngine: mockMediaEngine as any,
postMediaEngine: mockPostMediaEngine as any,
});
engine.setProjectContext('test-project', '/mock/project/data');
});

View File

@@ -29,7 +29,7 @@ describe('MCPAgentConfigEngine', () => {
let engine: MCPAgentConfigEngine;
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
engine = new MCPAgentConfigEngine({
homeDir: '/home/testuser',
platform: 'darwin',
@@ -40,9 +40,10 @@ describe('MCPAgentConfigEngine', () => {
describe('getAgents', () => {
it('returns all supported agent definitions', () => {
const agents = engine.getAgents();
expect(agents).toHaveLength(4);
expect(agents).toHaveLength(5);
const ids = agents.map((a) => a.id);
expect(ids).toContain('claude-code');
expect(ids).toContain('claude-desktop');
expect(ids).toContain('github-copilot');
expect(ids).toContain('gemini-cli');
expect(ids).toContain('opencode');
@@ -336,4 +337,160 @@ describe('MCPAgentConfigEngine', () => {
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
});
});
describe('claude-desktop', () => {
let desktopEngine: MCPAgentConfigEngine;
beforeEach(() => {
desktopEngine = new MCPAgentConfigEngine({
homeDir: '/home/testuser',
platform: 'darwin',
mcpUrl: 'http://127.0.0.1:4124/mcp',
execPath: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server',
scriptPath: '/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs',
});
});
it('includes claude-desktop in getAgents()', () => {
const agents = desktopEngine.getAgents();
expect(agents.map((a) => a.id)).toContain('claude-desktop');
});
it('returns correct config path for claude-desktop on macOS', () => {
expect(desktopEngine.getConfigPath('claude-desktop')).toBe(
'/home/testuser/Library/Application Support/Claude/claude_desktop_config.json',
);
});
it('returns correct config path for claude-desktop on Windows', () => {
const winEngine = new MCPAgentConfigEngine({
homeDir: 'C:\\Users\\testuser',
platform: 'win32',
mcpUrl: 'http://127.0.0.1:4124/mcp',
execPath: 'C:\\path\\to\\app.exe',
scriptPath: 'C:\\path\\to\\bds-mcp.cjs',
});
const configPath = winEngine.getConfigPath('claude-desktop');
// On Windows path.join uses backslashes; on macOS it uses forward slashes
// so normalise for cross-platform CI
const normalised = configPath.replace(/[\\/]/g, '/');
expect(normalised).toBe(
'C:/Users/testuser/AppData/Roaming/Claude/claude_desktop_config.json',
);
});
it('returns correct config path for claude-desktop on Linux', () => {
const linuxEngine = new MCPAgentConfigEngine({
homeDir: '/home/user',
platform: 'linux',
mcpUrl: 'http://127.0.0.1:4124/mcp',
});
expect(linuxEngine.getConfigPath('claude-desktop')).toBe(
'/home/user/.config/Claude/claude_desktop_config.json',
);
});
it('adds stdio entry with command/args/env to claude_desktop_config.json', () => {
mockExistsSync.mockReturnValue(false);
const result = desktopEngine.addToConfig('claude-desktop');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS).toEqual({
command: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server',
args: ['/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs'],
env: { ELECTRON_RUN_AS_NODE: '1' },
});
});
it('throws descriptive error if execPath/scriptPath missing for claude-desktop', () => {
const noPathEngine = new MCPAgentConfigEngine({
homeDir: '/home/testuser',
platform: 'darwin',
mcpUrl: 'http://127.0.0.1:4124/mcp',
});
mockExistsSync.mockReturnValue(false);
const result = noPathEngine.addToConfig('claude-desktop');
expect(result.success).toBe(false);
expect(result.error).toContain('execPath');
});
});
describe('removeFromConfig', () => {
it('removes bDS entry from config and returns success', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
mcpServers: {
bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' },
other: { type: 'http', url: 'http://other' },
},
}),
);
const result = engine.removeFromConfig('claude-code');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS).toBeUndefined();
expect(written.mcpServers.other).toBeDefined();
});
it('removes the mcpServers key entirely when bDS was the only entry', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
}),
);
engine.removeFromConfig('claude-code');
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers).toBeUndefined();
});
it('no-ops gracefully when file does not exist', () => {
mockExistsSync.mockReturnValue(false);
const result = engine.removeFromConfig('claude-code');
expect(result.success).toBe(true);
expect(mockWriteFileSync).not.toHaveBeenCalled();
});
it('no-ops gracefully when bDS entry is not in config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify({ mcpServers: { other: {} } }));
const result = engine.removeFromConfig('claude-code');
expect(result.success).toBe(true);
expect(mockWriteFileSync).not.toHaveBeenCalled();
});
it('uses the servers key for github-copilot', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }),
);
const result = engine.removeFromConfig('github-copilot');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.servers).toBeUndefined();
});
it('returns success with configPath', () => {
mockExistsSync.mockReturnValue(false);
const result = engine.removeFromConfig('claude-code');
expect(result.success).toBe(true);
expect(result.configPath).toBe('/home/testuser/.claude.json');
});
});
});

View File

@@ -60,22 +60,34 @@ function createMockMediaEngine() {
function createMockScriptEngine() {
return {
createScript: vi.fn().mockResolvedValue({
createDraftScript: vi.fn().mockResolvedValue({
id: 'script-1', title: 'Test', slug: 'test', kind: 'macro',
entrypoint: 'main.py', content: '', enabled: true, version: 1,
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
}),
publishScript: vi.fn().mockResolvedValue({
id: 'script-1', title: 'Test', slug: 'test', kind: 'macro',
entrypoint: 'main.py', content: '', enabled: true, version: 1,
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
}),
deleteDraftScript: vi.fn().mockResolvedValue(true),
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
};
}
function createMockTemplateEngine() {
return {
createTemplate: vi.fn().mockResolvedValue({
createDraftTemplate: vi.fn().mockResolvedValue({
id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post',
enabled: true, version: 1, filePath: '/test', content: '',
createdAt: new Date(), updatedAt: new Date(),
}),
publishTemplate: vi.fn().mockResolvedValue({
id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post',
enabled: true, version: 1, filePath: '/test', content: '',
createdAt: new Date(), updatedAt: new Date(),
}),
deleteDraftTemplate: vi.fn().mockResolvedValue(true),
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
};
}
@@ -110,13 +122,13 @@ function createDependencies() {
const mockTagEngine = createMockTagEngine();
const deps: MCPServerDependencies = {
getPostEngine: () => mockPostEngine,
getMediaEngine: () => mockMediaEngine,
getScriptEngine: () => mockScriptEngine,
getTemplateEngine: () => mockTemplateEngine,
getMetaEngine: () => mockMetaEngine,
getPostMediaEngine: () => mockPostMediaEngine,
getTagEngine: () => mockTagEngine,
postEngine: mockPostEngine,
mediaEngine: mockMediaEngine,
scriptEngine: mockScriptEngine,
templateEngine: mockTemplateEngine,
metaEngine: mockMetaEngine,
postMediaEngine: mockPostMediaEngine,
tagEngine: mockTagEngine,
};
return { deps, mockPostEngine, mockMediaEngine, mockScriptEngine, mockTemplateEngine, mockMetaEngine, mockPostMediaEngine, mockTagEngine };
@@ -358,25 +370,21 @@ describe('MCPServer', () => {
it('accepts a proposeScript proposal by creating script', async () => {
const proposalId = server.proposalStore.create('proposeScript', {
title: 'My Script', kind: 'macro', content: 'print("hello")',
scriptId: 'script-1',
});
const result = await server.acceptProposal(proposalId);
expect(result.success).toBe(true);
expect(mockScriptEngine.createScript).toHaveBeenCalledWith({
title: 'My Script', kind: 'macro', content: 'print("hello")',
});
expect(mockScriptEngine.publishScript).toHaveBeenCalledWith('script-1');
expect(server.proposalStore.get(proposalId)).toBeUndefined();
});
it('accepts a proposeTemplate proposal by creating template', async () => {
const proposalId = server.proposalStore.create('proposeTemplate', {
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
templateId: 'tpl-1',
});
const result = await server.acceptProposal(proposalId);
expect(result.success).toBe(true);
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith({
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
});
expect(mockTemplateEngine.publishTemplate).toHaveBeenCalledWith('tpl-1');
});
it('accepts a proposeMediaMetadata proposal by updating media', async () => {
@@ -415,9 +423,10 @@ describe('MCPServer', () => {
});
it('discards a proposeScript proposal by removing from store', async () => {
const proposalId = server.proposalStore.create('proposeScript', { title: 'Script' });
const proposalId = server.proposalStore.create('proposeScript', { scriptId: 'script-1' });
const result = await server.discardProposal(proposalId);
expect(result.success).toBe(true);
expect(mockScriptEngine.deleteDraftScript).toHaveBeenCalledWith('script-1');
expect(server.proposalStore.get(proposalId)).toBeUndefined();
});
@@ -830,7 +839,10 @@ describe('MCPServer', () => {
const proposal = server.proposalStore.get(parsed.proposalId);
expect(proposal).toBeDefined();
expect(proposal!.type).toBe('proposeScript');
expect(proposal!.data.content).toBe('print("hi")');
expect(mockScriptEngine.createDraftScript).toHaveBeenCalledWith({
title: 'My Script', kind: 'macro', content: 'print("hi")', entrypoint: undefined,
});
expect(proposal!.data.scriptId).toBe('script-1');
});
it('propose_script calls validateScript and includes validation result in preview', async () => {

View File

@@ -173,7 +173,7 @@ describe('MetadataDiffEngine', () => {
mockAllPostsRows = [];
mockSyncPublishedPostFile.mockClear();
resetMockCounters();
engine = new MetadataDiffEngine();
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile } as any);
engine.setProjectContext('test-project');
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction } from 'vitest';
// ── Chokidar mock ────────────────────────────────────────────────────────────
interface MockFSWatcher {
on: MockedFunction<(event: string, handler: () => void) => MockFSWatcher>;
close: MockedFunction<() => Promise<void>>;
}
let mockWatcher: MockFSWatcher;
let capturedWatchPaths: string[] = [];
let capturedWatchOptions: Record<string, unknown> = {};
vi.mock('chokidar', () => ({
default: {
watch: (paths: string[], options: Record<string, unknown>) => {
capturedWatchPaths = paths;
capturedWatchOptions = options;
return mockWatcher;
},
},
}));
// ── Imports (after mocks) ────────────────────────────────────────────────────
import {
NotificationWatcher,
type WatchableEngines,
} from '../../src/main/engine/NotificationWatcher';
// ── DB mock helpers ─────────────────────────────────────────────────────────
type MockDb = {
select: MockedFunction<() => {
from: (table: unknown) => { where: MockedFunction<() => Promise<unknown[]>> };
}>;
update: MockedFunction<(table: unknown) => {
set: (values: unknown) => { where: MockedFunction<() => Promise<void>> };
}>;
delete: MockedFunction<(table: unknown) => { where: MockedFunction<() => Promise<void>> }>;
};
function makeSelectChain(rows: unknown[]): ReturnType<MockDb['select']> {
const whereSelect = vi.fn().mockResolvedValue(rows);
return {
from: (_table: unknown) => ({ where: whereSelect }),
};
}
function makeUpdateChain(): ReturnType<MockDb['update']> {
const whereUpdate = vi.fn().mockResolvedValue(undefined);
return {
set: (_values: unknown) => ({ where: whereUpdate }),
};
}
function makeDeleteChain(): ReturnType<MockDb['delete']> {
return { where: vi.fn().mockResolvedValue(undefined) };
}
// ── Test suite ───────────────────────────────────────────────────────────────
describe('NotificationWatcher', () => {
const DB_PATH = '/home/user/.config/bDS/bds.db';
let db: MockDb;
let engines: WatchableEngines;
let mockSend: MockedFunction<(channel: string, payload: unknown) => void>;
let mainWindow: { webContents: { send: typeof mockSend } };
let watcher: NotificationWatcher;
beforeEach(() => {
vi.useFakeTimers();
mockWatcher = {
on: vi.fn().mockReturnThis(),
close: vi.fn().mockResolvedValue(undefined),
};
capturedWatchPaths = [];
capturedWatchOptions = {};
db = {
select: vi.fn().mockReturnValue(makeSelectChain([])),
update: vi.fn().mockReturnValue(makeUpdateChain()),
delete: vi.fn().mockReturnValue(makeDeleteChain()),
};
engines = {
post: { invalidate: vi.fn() },
media: { invalidate: vi.fn() },
script: { invalidate: vi.fn() },
template: { invalidate: vi.fn() },
};
mockSend = vi.fn();
mainWindow = { webContents: { send: mockSend } };
watcher = new NotificationWatcher(DB_PATH, db as any, engines, mainWindow as any, 100);
});
afterEach(() => {
vi.useRealTimers();
});
// ── start() ───────────────────────────────────────────────────────────────
describe('start()', () => {
it('watches both db and wal paths', () => {
watcher.start();
expect(capturedWatchPaths).toEqual([DB_PATH, `${DB_PATH}-wal`]);
});
it('sets persistent:false and ignoreInitial:true', () => {
watcher.start();
expect(capturedWatchOptions.persistent).toBe(false);
expect(capturedWatchOptions.ignoreInitial).toBe(true);
});
it('registers change and add handlers', () => {
watcher.start();
const events = mockWatcher.on.mock.calls.map((c) => c[0]);
expect(events).toContain('change');
expect(events).toContain('add');
});
it('debounces rapid file-change events', async () => {
watcher.start();
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
db.select.mockReturnValue(makeSelectChain([]));
changeHandler();
changeHandler();
changeHandler();
expect(db.select).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
// Only one process() call despite three change events
expect(db.select).toHaveBeenCalledTimes(1);
});
});
// ── process() ─────────────────────────────────────────────────────────────
describe('process()', () => {
async function triggerProcess(rows: unknown[] = []): Promise<void> {
db.select.mockReturnValue(makeSelectChain(rows));
watcher.start();
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
changeHandler();
await vi.advanceTimersByTimeAsync(100);
}
it('queries db_notifications for unprocessed CLI rows', async () => {
await triggerProcess([]);
expect(db.select).toHaveBeenCalledTimes(1);
});
it('calls invalidate on the matching engine for each row', async () => {
const rows = [
{ id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() },
{ id: 2, entity: 'media', entityId: 'm1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() },
];
await triggerProcess(rows);
expect(engines.post.invalidate).toHaveBeenCalledWith('p1');
expect(engines.media.invalidate).toHaveBeenCalledWith('m1');
});
it('sends entity:changed IPC event for each row', async () => {
const rows = [
{ id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() },
{ id: 2, entity: 'script', entityId: 's1', action: 'deleted', fromCli: 1, seenAt: null, createdAt: Date.now() },
];
await triggerProcess(rows);
expect(mockSend).toHaveBeenCalledWith('entity:changed', {
entity: 'post',
entityId: 'p1',
action: 'created',
});
expect(mockSend).toHaveBeenCalledWith('entity:changed', {
entity: 'script',
entityId: 's1',
action: 'deleted',
});
});
it('stamps seenAt on each processed row', async () => {
const rows = [
{ id: 42, entity: 'post', entityId: 'p1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() },
];
db.select.mockReturnValue(makeSelectChain(rows));
const updateChain = makeUpdateChain();
db.update.mockReturnValue(updateChain);
watcher.start();
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
changeHandler();
await vi.advanceTimersByTimeAsync(100);
expect(db.update).toHaveBeenCalledTimes(1);
});
it('prunes old seen rows (>1h) and old unprocessed rows (>24h)', async () => {
await triggerProcess([]);
// delete is called twice: once for seenAt > 1h, once for unprocessed > 24h
expect(db.delete).toHaveBeenCalledTimes(2);
});
it('skips unknown entity types gracefully', async () => {
const rows = [
{ id: 1, entity: 'unknown_entity', entityId: 'x1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() },
];
await expect(triggerProcess(rows)).resolves.not.toThrow();
// No IPC send for unknown entities, but the watcher finishes without error
});
});
// ── stop() ────────────────────────────────────────────────────────────────
describe('stop()', () => {
it('closes the file watcher', () => {
watcher.start();
watcher.stop();
expect(mockWatcher.close).toHaveBeenCalled();
});
it('cancels a pending debounce timer', async () => {
watcher.start();
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
changeHandler();
watcher.stop();
await vi.advanceTimersByTimeAsync(200);
// process() must NOT have run after stop
expect(db.select).not.toHaveBeenCalled();
});
it('does not throw if called before start()', () => {
expect(() => watcher.stop()).not.toThrow();
});
});
});

View File

@@ -31,6 +31,14 @@ const mockUpdateMedia = vi.fn();
const mockGetAllMedia = vi.fn();
const mockImportMedia = vi.fn();
// Aggregated mock MediaEngine object for constructor injection
const mockMediaEngineForPostMedia = {
getMedia: mockGetMedia,
updateMedia: mockUpdateMedia,
getAllMedia: mockGetAllMedia,
importMedia: mockImportMedia,
};
// Mock MediaEngine
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
@@ -144,7 +152,7 @@ describe('PostMediaEngine', () => {
mockGetAllMedia.mockResolvedValue([]);
mockImportMedia.mockResolvedValue({ id: 'imported-media-id' });
engine = new PostMediaEngine();
engine = new PostMediaEngine(mockMediaEngineForPostMedia as any);
engine.setProjectContext('test-project');
});

View File

@@ -159,6 +159,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -200,6 +203,8 @@ describe('PreviewServer', () => {
},
],
}),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -242,6 +247,8 @@ describe('PreviewServer', () => {
{ id: 'dev', title: 'Dev', kind: 'category-archive', categoryName: 'news', children: [] },
],
}),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -278,6 +285,8 @@ describe('PreviewServer', () => {
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
],
}),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -293,6 +302,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -369,6 +381,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir ?? undefined }),
});
@@ -397,6 +412,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([postWithCode]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -432,6 +450,7 @@ describe('PreviewServer', () => {
postMediaEngine,
settingsEngine: settingsEngine as any,
menuEngine,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
});
@@ -474,6 +493,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -520,6 +542,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -557,6 +582,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([publishedPost, draftPost]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -589,6 +617,9 @@ describe('PreviewServer', () => {
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -618,6 +649,9 @@ describe('PreviewServer', () => {
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -645,6 +679,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -665,6 +702,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([matchingDay, sameMonth, sameYear, differentYear]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -696,6 +736,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -728,6 +771,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -753,6 +799,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -766,6 +815,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([makePost({ content: '```js\nconst line = "x".repeat(1000);\n```' })]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -789,6 +841,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -829,6 +884,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir || undefined }),
});
@@ -904,6 +962,9 @@ describe('PreviewServer', () => {
};
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -933,6 +994,9 @@ describe('PreviewServer', () => {
};
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -959,6 +1023,9 @@ describe('PreviewServer', () => {
};
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -988,6 +1055,9 @@ describe('PreviewServer', () => {
};
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1015,6 +1085,9 @@ describe('PreviewServer', () => {
return { description: 'Beschreibung', maxPostsPerPage: 2 };
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1043,6 +1116,9 @@ describe('PreviewServer', () => {
return { description: 'Beschreibung', maxPostsPerPage: 2 };
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1075,6 +1151,9 @@ describe('PreviewServer', () => {
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1111,6 +1190,9 @@ describe('PreviewServer', () => {
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1147,6 +1229,9 @@ describe('PreviewServer', () => {
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1166,6 +1251,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([tagged, categorized, page, regular]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1215,6 +1303,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([tagDayOneA, tagDayOneB, tagDayTwo]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1247,6 +1338,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1302,6 +1396,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(7),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1326,6 +1423,9 @@ describe('PreviewServer', () => {
};
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1353,6 +1453,9 @@ describe('PreviewServer', () => {
};
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1381,6 +1484,9 @@ describe('PreviewServer', () => {
: null;
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1401,6 +1507,9 @@ describe('PreviewServer', () => {
return null;
},
},
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({
projectId: 'default',
dataDir: '/tmp/default',
@@ -1465,7 +1574,9 @@ describe('PreviewServer', () => {
createdAt: new Date('2025-02-03T10:00:00.000Z'),
},
]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
settingsEngine: makeSettings(50),
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1495,6 +1606,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1546,7 +1660,11 @@ describe('PreviewServer', () => {
linkedPostIds: [],
} as any,
]) as any,
postMediaEngine: makePostMediaEngine({
'macro-1': [{ media: { id: 'media-1' } }, { media: { id: 'media-2' } }],
}) as any,
settingsEngine: makeSettings(50),
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1587,6 +1705,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1624,6 +1745,7 @@ describe('PreviewServer', () => {
'macro-junction-1': [{ media: { id: 'junction-media-1' } }],
}) as any,
settingsEngine: makeSettings(50),
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
} as any);
@@ -1646,6 +1768,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({
projectId: 'default',
dataDir: tempDir!,
@@ -1691,6 +1816,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1738,6 +1866,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1790,6 +1921,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
@@ -1805,6 +1939,9 @@ describe('PreviewServer', () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});

View File

@@ -43,7 +43,7 @@ describe('PublishApiAdapter', () => {
mockTaskManager.runTask.mockImplementation((opts: { execute: (onProgress: () => void) => Promise<unknown> }) => {
return opts.execute(() => {});
});
adapter = new PublishApiAdapter();
adapter = new PublishApiAdapter(mockProjectEngine as any, mockPublishEngine as any, mockTaskManager as any);
});
it('sets project context before uploading', async () => {

View File

@@ -99,13 +99,6 @@ describe('PublishEngine', () => {
});
describe('constructor and project context', () => {
it('should be instantiated via getPublishEngine singleton', async () => {
const { getPublishEngine } = await import('../../src/main/engine/PublishEngine');
const e1 = getPublishEngine();
const e2 = getPublishEngine();
expect(e1).toBe(e2);
});
it('should throw if no project context is set', async () => {
const noContextEngine = new PublishEngine();
await expect(

View File

@@ -157,7 +157,7 @@ describe('TagEngine', () => {
mockSelectDataDefault = [];
mockPostEngine.syncPublishedPostFile.mockClear();
resetMockCounters();
tagEngine = new TagEngine();
tagEngine = new TagEngine(mockPostEngine as any);
});
afterEach(() => {

View File

@@ -351,7 +351,12 @@ describe('WXR Reference Comparison E2E Tests', () => {
vi.clearAllMocks();
// Create engine instances
executionEngine = new ImportExecutionEngine();
executionEngine = new ImportExecutionEngine({
tagEngine: mockTagEngine as any,
postEngine: mockPostEngine as any,
mediaEngine: mockMediaEngine as any,
postMediaEngine: mockPostMediaEngine as any,
});
executionEngine.setProjectContext('test-project', '/mock/test/data');
analysisEngine = new ImportAnalysisEngine();

View File

@@ -102,7 +102,12 @@ describe('chatHandlers', () => {
it('streams sendMessage callbacks through main window events', async () => {
const mod = await import('../../src/main/ipc/chatHandlers');
mod.initializeChatHandlers(() => mainWindowMock as never);
const mockBundle = {
postEngine: {},
mediaEngine: {},
postMediaEngine: {},
};
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
mod.registerChatHandlers();
const handler = registeredHandlers.get('chat:sendMessage');

View File

@@ -152,6 +152,7 @@ const mockPostMediaEngine = {
linkManyToPost: vi.fn(),
unlinkManyFromPost: vi.fn(),
getLinkedMediaForPost: vi.fn(),
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn(),
reorderMediaForPost: vi.fn(),
isMediaLinkedToPost: vi.fn(),
@@ -257,6 +258,7 @@ const mockDatabase = {
posts: '/mock/data/posts',
media: '/mock/data/media',
})),
getDbPath: vi.fn(() => '/mock/data/bds.db'),
};
// Mock engine modules
@@ -350,16 +352,49 @@ async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[
}
describe('IPC Handlers', () => {
const mockBundle: Record<string, any> = {
postEngine: mockPostEngine,
mediaEngine: mockMediaEngine,
projectEngine: mockProjectEngine,
metaEngine: mockMetaEngine,
tagEngine: mockTagEngine,
menuEngine: mockMenuEngine,
postMediaEngine: mockPostMediaEngine,
scriptEngine: mockScriptEngine,
templateEngine: mockTemplateEngine,
gitEngine: mockGitEngine,
gitApiAdapter: {},
taskManager: mockTaskManager,
blogGenerationEngine: null, // set in beforeEach
publishEngine: { setProjectContext: vi.fn(), uploadHtml: vi.fn(), uploadThumbnails: vi.fn(), uploadMedia: vi.fn() },
metadataDiffEngine: { setProjectContext: vi.fn(), comparePostMetadata: vi.fn(), scanAllPublishedPosts: vi.fn(), syncDbToFile: vi.fn(), syncFileToDb: vi.fn(), groupDifferencesByField: vi.fn() },
blogmarkTransformService: {},
mcpServer: { getPort: vi.fn(() => 4124), startCli: vi.fn(), cleanup: vi.fn() },
blogmarkPythonWorkerRuntime: {},
pythonMacroWorkerRuntime: {},
publishApiAdapter: {},
appApiAdapter: {},
};
beforeEach(async () => {
// Clear all mocks
vi.clearAllMocks();
registeredHandlers.clear();
mockGeneratedFileHashStore.clear();
resetMockCounters();
// Create a real BlogGenerationEngine with mock engines for blog handler tests
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
mockBundle.blogGenerationEngine = new BlogGenerationEngine(
mockPostEngine as any,
mockMediaEngine as any,
mockPostMediaEngine as any,
);
// Import and register handlers fresh for each test
const { registerIpcHandlers } = await import('../../src/main/ipc/handlers');
registerIpcHandlers();
registerIpcHandlers(mockBundle as any);
});
afterEach(() => {