feat: more work on mcp server integration
This commit is contained in:
@@ -86,7 +86,7 @@ describe('GenerationRouteRendererFactory', () => {
|
||||
}
|
||||
|
||||
if (typeof filter.month === 'number') {
|
||||
filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month);
|
||||
filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month - 1);
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
|
||||
339
tests/engine/MCPConfigEngine.test.ts
Normal file
339
tests/engine/MCPConfigEngine.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MCPAgentConfigEngine, type MCPAgentId, type AgentConfigResult } from '../../src/main/engine/MCPAgentConfigEngine';
|
||||
|
||||
// Mock fs and os
|
||||
const mockReadFileSync = vi.fn();
|
||||
const mockWriteFileSync = vi.fn();
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockMkdirSync = vi.fn();
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
||||
},
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('MCPAgentConfigEngine', () => {
|
||||
let engine: MCPAgentConfigEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
engine = new MCPAgentConfigEngine({
|
||||
homeDir: '/home/testuser',
|
||||
platform: 'darwin',
|
||||
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgents', () => {
|
||||
it('returns all supported agent definitions', () => {
|
||||
const agents = engine.getAgents();
|
||||
expect(agents).toHaveLength(4);
|
||||
const ids = agents.map((a) => a.id);
|
||||
expect(ids).toContain('claude-code');
|
||||
expect(ids).toContain('github-copilot');
|
||||
expect(ids).toContain('gemini-cli');
|
||||
expect(ids).toContain('opencode');
|
||||
});
|
||||
|
||||
it('includes display labels for each agent', () => {
|
||||
const agents = engine.getAgents();
|
||||
for (const agent of agents) {
|
||||
expect(agent.label).toBeTruthy();
|
||||
expect(typeof agent.label).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigPath', () => {
|
||||
it('returns ~/.claude.json for claude-code', () => {
|
||||
expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json');
|
||||
});
|
||||
|
||||
it('returns macOS VS Code user mcp.json for github-copilot on darwin', () => {
|
||||
expect(engine.getConfigPath('github-copilot')).toBe(
|
||||
'/home/testuser/Library/Application Support/Code/User/mcp.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns Linux VS Code user mcp.json for github-copilot on linux', () => {
|
||||
const linuxEngine = new MCPAgentConfigEngine({
|
||||
homeDir: '/home/user',
|
||||
platform: 'linux',
|
||||
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||
});
|
||||
expect(linuxEngine.getConfigPath('github-copilot')).toBe(
|
||||
'/home/user/.config/Code/User/mcp.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns ~/.gemini/settings.json for gemini-cli', () => {
|
||||
expect(engine.getConfigPath('gemini-cli')).toBe('/home/testuser/.gemini/settings.json');
|
||||
});
|
||||
|
||||
it('returns ~/.opencode.json for opencode', () => {
|
||||
expect(engine.getConfigPath('opencode')).toBe('/home/testuser/.opencode.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToConfig (claude-code)', () => {
|
||||
it('creates new config when file does not exist', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = engine.addToConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.configPath).toBe('/home/testuser/.claude.json');
|
||||
expect(mockWriteFileSync).toHaveBeenCalledOnce();
|
||||
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
||||
});
|
||||
|
||||
it('merges into existing config without overwriting other servers', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
mcpServers: { other: { type: 'stdio', command: 'npx' } },
|
||||
someOtherKey: 'keep',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = engine.addToConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.other).toEqual({ type: 'stdio', command: 'npx' });
|
||||
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
||||
expect(written.someOtherKey).toBe('keep');
|
||||
});
|
||||
|
||||
it('overwrites existing bDS entry to update URL', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
mcpServers: { bDS: { type: 'http', url: 'http://old:1234/mcp' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = engine.addToConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToConfig (github-copilot)', () => {
|
||||
it('creates new .vscode/mcp.json with correct servers key', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = engine.addToConfig('github-copilot');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.servers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
||||
// Should NOT have mcpServers key
|
||||
expect(written.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates parent directory if needed', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
engine.addToConfig('github-copilot');
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Code/User'),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('merges into existing VS Code config preserving other servers', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
servers: { github: { type: 'http', url: 'https://api.githubcopilot.com/mcp' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = engine.addToConfig('github-copilot');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.servers.github.url).toBe('https://api.githubcopilot.com/mcp');
|
||||
expect(written.servers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToConfig (gemini-cli)', () => {
|
||||
it('creates new settings.json with httpUrl entry', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = engine.addToConfig('gemini-cli');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
|
||||
});
|
||||
|
||||
it('creates ~/.gemini directory if needed', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
engine.addToConfig('gemini-cli');
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.gemini'),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing settings when adding MCP server', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
theme: 'dark',
|
||||
mcpServers: { existing: { command: 'python' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = engine.addToConfig('gemini-cli');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.theme).toBe('dark');
|
||||
expect(written.mcpServers.existing).toEqual({ command: 'python' });
|
||||
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToConfig (opencode)', () => {
|
||||
it('creates new .opencode.json with sse type', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = engine.addToConfig('opencode');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS).toEqual({ type: 'sse', url: 'http://127.0.0.1:4124/mcp' });
|
||||
});
|
||||
|
||||
it('merges into existing opencode config', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
providers: { openai: { apiKey: 'key' } },
|
||||
mcpServers: { debugger: { type: 'stdio', command: 'debug' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = engine.addToConfig('opencode');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.providers.openai.apiKey).toBe('key');
|
||||
expect(written.mcpServers.debugger.command).toBe('debug');
|
||||
expect(written.mcpServers.bDS.type).toBe('sse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('returns error result when read fails', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const result = engine.addToConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Permission denied');
|
||||
});
|
||||
|
||||
it('returns error result when write fails', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockWriteFileSync.mockImplementation(() => {
|
||||
throw new Error('Disk full');
|
||||
});
|
||||
|
||||
const result = engine.addToConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Disk full');
|
||||
});
|
||||
|
||||
it('returns error for invalid existing JSON', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('not valid json{{{');
|
||||
|
||||
const result = engine.addToConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('returns true when bDS entry exists in config', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(engine.isConfigured('claude-code')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when config file does not exist', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
expect(engine.isConfigured('claude-code')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when bDS entry is missing', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ mcpServers: { other: {} } }),
|
||||
);
|
||||
|
||||
expect(engine.isConfigured('claude-code')).toBe(false);
|
||||
});
|
||||
|
||||
it('checks VS Code servers key for github-copilot', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }),
|
||||
);
|
||||
|
||||
expect(engine.isConfigured('github-copilot')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic port', () => {
|
||||
it('uses the provided mcpUrl in server entries', () => {
|
||||
const customEngine = new MCPAgentConfigEngine({
|
||||
homeDir: '/tmp',
|
||||
platform: 'darwin',
|
||||
mcpUrl: 'http://127.0.0.1:9999/mcp',
|
||||
});
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
customEngine.addToConfig('claude-code');
|
||||
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,11 @@ function createMockDeps(): MCPServerDependencies {
|
||||
}),
|
||||
getScriptEngine: () => ({
|
||||
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
|
||||
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||
}),
|
||||
getTemplateEngine: () => ({
|
||||
createTemplate: vi.fn().mockResolvedValue({ id: 't1' }),
|
||||
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||
}),
|
||||
getMetaEngine: () => ({
|
||||
getProjectMetadata: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -1380,7 +1380,7 @@ tags: ["nature", "sunset"]`;
|
||||
return chain;
|
||||
});
|
||||
|
||||
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 5 }); // June (0-indexed)
|
||||
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 6 }); // June (1-indexed)
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1452,9 +1452,9 @@ tags: ["nature", "sunset"]`;
|
||||
|
||||
const result = await mediaEngine.getMediaByYearMonth();
|
||||
|
||||
// Note: month is 0-indexed from Date.getMonth()
|
||||
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
|
||||
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
|
||||
// month is 1-indexed
|
||||
expect(result).toContainEqual({ year: 2024, month: 1, count: 2 }); // January
|
||||
expect(result).toContainEqual({ year: 2024, month: 2, count: 1 }); // February
|
||||
});
|
||||
|
||||
it('should sort by year and month descending', async () => {
|
||||
@@ -1479,7 +1479,7 @@ tags: ["nature", "sunset"]`;
|
||||
const result = await mediaEngine.getMediaByYearMonth();
|
||||
|
||||
expect(result[0].year).toBe(2024);
|
||||
expect(result[0].month).toBe(2); // March is month 2 (0-indexed)
|
||||
expect(result[0].month).toBe(3); // March is month 3 (1-indexed)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2653,10 +2653,10 @@ Published snapshot content`);
|
||||
|
||||
const result = await postEngine.getPostsByYearMonth();
|
||||
|
||||
// Note: getMonth() returns 0-11, so January is 0, February is 1, etc.
|
||||
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
|
||||
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
|
||||
expect(result).toContainEqual({ year: 2023, month: 11, count: 1 }); // December
|
||||
// months are 1-indexed (January=1, February=2, etc.)
|
||||
expect(result).toContainEqual({ year: 2024, month: 1, count: 2 }); // January
|
||||
expect(result).toContainEqual({ year: 2024, month: 2, count: 1 }); // February
|
||||
expect(result).toContainEqual({ year: 2023, month: 12, count: 1 }); // December
|
||||
});
|
||||
|
||||
it('should sort by year and month descending', async () => {
|
||||
@@ -2677,7 +2677,7 @@ Published snapshot content`);
|
||||
const result = await postEngine.getPostsByYearMonth();
|
||||
|
||||
expect(result[0].year).toBe(2024);
|
||||
expect(result[0].month).toBe(2); // March (0-indexed)
|
||||
expect(result[0].month).toBe(3); // March (1-indexed)
|
||||
expect(result[result.length - 1].year).toBe(2023);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ function makeEngine(posts: PostData[]): PostEngineLike {
|
||||
}
|
||||
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month);
|
||||
result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month - 1);
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
|
||||
@@ -59,7 +59,7 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
|
||||
}
|
||||
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
result = result.filter((post) => post.createdAt.getMonth() === filter.month);
|
||||
result = result.filter((post) => post.createdAt.getMonth() === filter.month - 1);
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
@@ -110,7 +110,7 @@ describe('SharedSnapshotService', () => {
|
||||
engine,
|
||||
'my-post',
|
||||
{ useDraftContent: true, draftPostId: 'draft-1' },
|
||||
{ year: 2025, month: 2, day: 21 },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(result?.id).toBe('draft-1');
|
||||
@@ -125,7 +125,7 @@ describe('SharedSnapshotService', () => {
|
||||
findPublishedBySlug,
|
||||
};
|
||||
|
||||
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 0, day: 2 });
|
||||
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 1, day: 2 });
|
||||
|
||||
expect(result?.id).toBe('x1');
|
||||
expect(findPublishedBySlug).toHaveBeenCalled();
|
||||
|
||||
@@ -468,11 +468,16 @@ describe('main bootstrap preview behavior', () => {
|
||||
PreviewServer: MockPreviewServer,
|
||||
}));
|
||||
|
||||
vi.doMock('fs', () => ({
|
||||
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
||||
readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
vi.doMock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
const mocked = {
|
||||
...actual,
|
||||
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
||||
readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
return { ...mocked, default: mocked };
|
||||
});
|
||||
|
||||
vi.doMock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
@@ -593,11 +598,16 @@ describe('main bootstrap preview behavior', () => {
|
||||
PreviewServer: MockPreviewServer,
|
||||
}));
|
||||
|
||||
vi.doMock('fs', () => ({
|
||||
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
||||
readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
vi.doMock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
const mocked = {
|
||||
...actual,
|
||||
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
||||
readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
return { ...mocked, default: mocked };
|
||||
});
|
||||
|
||||
vi.doMock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
|
||||
@@ -1,40 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
reviewPostHtml,
|
||||
reviewScriptHtml,
|
||||
reviewTemplateHtml,
|
||||
reviewMetadataHtml,
|
||||
resolveMcpViewsDirs,
|
||||
loadViewHtml,
|
||||
} from '../../src/main/engine/mcp-views';
|
||||
|
||||
const viewOpts = {
|
||||
moduleDir: path.resolve(__dirname, '../../src/main/engine'),
|
||||
};
|
||||
|
||||
describe('mcp-views', () => {
|
||||
describe('resolveMcpViewsDirs', () => {
|
||||
it('returns candidate directories', () => {
|
||||
const dirs = resolveMcpViewsDirs(viewOpts);
|
||||
expect(dirs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(dirs.some(d => d.includes('mcp-views'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadViewHtml', () => {
|
||||
it('loads an existing view file', () => {
|
||||
const html = loadViewHtml('review-post.html', viewOpts);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('throws for a non-existent view', () => {
|
||||
expect(() => loadViewHtml('does-not-exist.html', viewOpts)).toThrow(
|
||||
/not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewPostHtml', () => {
|
||||
it('returns valid HTML document', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('contains App import from ext-apps', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||
expect(html).toContain('new App(');
|
||||
});
|
||||
|
||||
it('contains accept and discard buttons', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('acceptProposal()');
|
||||
expect(html).toContain('discardProposal()');
|
||||
});
|
||||
|
||||
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('app.callServerTool');
|
||||
expect(html).toContain('"accept_proposal"');
|
||||
expect(html).toContain('"discard_proposal"');
|
||||
});
|
||||
|
||||
it('contains post-specific UI elements', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('Review Post');
|
||||
expect(html).toContain('Publish');
|
||||
expect(html).toContain('badge-draft');
|
||||
@@ -42,13 +70,13 @@ describe('mcp-views', () => {
|
||||
});
|
||||
|
||||
it('renders tool result data via ontoolresult handler', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('app.ontoolresult');
|
||||
expect(html).toContain('renderReview');
|
||||
});
|
||||
|
||||
it('uses XSS-safe escaping function', () => {
|
||||
const html = reviewPostHtml();
|
||||
const html = reviewPostHtml(viewOpts);
|
||||
expect(html).toContain('function esc(');
|
||||
expect(html).toContain('document.createElement("div")');
|
||||
});
|
||||
@@ -56,24 +84,24 @@ describe('mcp-views', () => {
|
||||
|
||||
describe('reviewScriptHtml', () => {
|
||||
it('returns valid HTML document', () => {
|
||||
const html = reviewScriptHtml();
|
||||
const html = reviewScriptHtml(viewOpts);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('contains App import from ext-apps', () => {
|
||||
const html = reviewScriptHtml();
|
||||
const html = reviewScriptHtml(viewOpts);
|
||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||
});
|
||||
|
||||
it('contains accept and discard buttons', () => {
|
||||
const html = reviewScriptHtml();
|
||||
const html = reviewScriptHtml(viewOpts);
|
||||
expect(html).toContain('acceptProposal()');
|
||||
expect(html).toContain('discardProposal()');
|
||||
});
|
||||
|
||||
it('contains script-specific UI elements', () => {
|
||||
const html = reviewScriptHtml();
|
||||
const html = reviewScriptHtml(viewOpts);
|
||||
expect(html).toContain('Review Script');
|
||||
expect(html).toContain('Create Script');
|
||||
expect(html).toContain('Python Code');
|
||||
@@ -82,24 +110,24 @@ describe('mcp-views', () => {
|
||||
|
||||
describe('reviewTemplateHtml', () => {
|
||||
it('returns valid HTML document', () => {
|
||||
const html = reviewTemplateHtml();
|
||||
const html = reviewTemplateHtml(viewOpts);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('contains App import from ext-apps', () => {
|
||||
const html = reviewTemplateHtml();
|
||||
const html = reviewTemplateHtml(viewOpts);
|
||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||
});
|
||||
|
||||
it('contains accept and discard buttons', () => {
|
||||
const html = reviewTemplateHtml();
|
||||
const html = reviewTemplateHtml(viewOpts);
|
||||
expect(html).toContain('acceptProposal()');
|
||||
expect(html).toContain('discardProposal()');
|
||||
});
|
||||
|
||||
it('contains template-specific UI elements', () => {
|
||||
const html = reviewTemplateHtml();
|
||||
const html = reviewTemplateHtml(viewOpts);
|
||||
expect(html).toContain('Review Template');
|
||||
expect(html).toContain('Create Template');
|
||||
expect(html).toContain('Liquid Template');
|
||||
@@ -108,24 +136,24 @@ describe('mcp-views', () => {
|
||||
|
||||
describe('reviewMetadataHtml', () => {
|
||||
it('returns valid HTML document', () => {
|
||||
const html = reviewMetadataHtml();
|
||||
const html = reviewMetadataHtml(viewOpts);
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('contains App import from ext-apps', () => {
|
||||
const html = reviewMetadataHtml();
|
||||
const html = reviewMetadataHtml(viewOpts);
|
||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||
});
|
||||
|
||||
it('contains accept and discard buttons', () => {
|
||||
const html = reviewMetadataHtml();
|
||||
const html = reviewMetadataHtml(viewOpts);
|
||||
expect(html).toContain('acceptProposal()');
|
||||
expect(html).toContain('discardProposal()');
|
||||
});
|
||||
|
||||
it('contains metadata-diff UI elements', () => {
|
||||
const html = reviewMetadataHtml();
|
||||
const html = reviewMetadataHtml(viewOpts);
|
||||
expect(html).toContain('Metadata Changes');
|
||||
expect(html).toContain('Apply Changes');
|
||||
expect(html).toContain('diff-table');
|
||||
@@ -134,7 +162,7 @@ describe('mcp-views', () => {
|
||||
});
|
||||
|
||||
it('contains diff formatting function', () => {
|
||||
const html = reviewMetadataHtml();
|
||||
const html = reviewMetadataHtml(viewOpts);
|
||||
expect(html).toContain('function fmt(');
|
||||
expect(html).toContain('diff-old');
|
||||
expect(html).toContain('diff-new');
|
||||
@@ -143,10 +171,10 @@ describe('mcp-views', () => {
|
||||
|
||||
describe('shared behavior', () => {
|
||||
const allViews = [
|
||||
{ name: 'reviewPostHtml', fn: reviewPostHtml },
|
||||
{ name: 'reviewScriptHtml', fn: reviewScriptHtml },
|
||||
{ name: 'reviewTemplateHtml', fn: reviewTemplateHtml },
|
||||
{ name: 'reviewMetadataHtml', fn: reviewMetadataHtml },
|
||||
{ name: 'reviewPostHtml', fn: () => reviewPostHtml(viewOpts) },
|
||||
{ name: 'reviewScriptHtml', fn: () => reviewScriptHtml(viewOpts) },
|
||||
{ name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml(viewOpts) },
|
||||
{ name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml(viewOpts) },
|
||||
];
|
||||
|
||||
it.each(allViews)('$name connects the App on load', ({ fn }) => {
|
||||
|
||||
Reference in New Issue
Block a user