feat: more on mcp server
This commit is contained in:
@@ -14,60 +14,94 @@ import {
|
|||||||
reviewTemplateHtml,
|
reviewTemplateHtml,
|
||||||
reviewMetadataHtml,
|
reviewMetadataHtml,
|
||||||
} from './mcp-views';
|
} from './mcp-views';
|
||||||
|
import type {
|
||||||
|
PostData,
|
||||||
|
PostFilter,
|
||||||
|
SearchResult,
|
||||||
|
PaginatedResult,
|
||||||
|
PaginationOptions,
|
||||||
|
} from './PostEngine';
|
||||||
|
import type { MediaData } from './MediaEngine';
|
||||||
|
import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine';
|
||||||
|
import type { CreateTemplateInput, TemplateData, TemplateValidationResult } from './TemplateEngine';
|
||||||
|
import type { ProjectMetadata } from './MetaEngine';
|
||||||
|
import type { PostMediaLinkData } from './PostMediaEngine';
|
||||||
|
import type { TagWithCount } from './TagEngine';
|
||||||
|
|
||||||
|
// ── Pagination helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export function encodeCursor(offset: number): string {
|
||||||
|
return Buffer.from(String(offset)).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCursor(cursor: string): number {
|
||||||
|
try {
|
||||||
|
const offset = parseInt(Buffer.from(cursor, 'base64url').toString(), 10);
|
||||||
|
return Number.isNaN(offset) || offset < 0 ? 0 : offset;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dependency contracts ──────────────────────────────────────────────
|
// ── Dependency contracts ──────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PostFilter {
|
|
||||||
status?: 'draft' | 'published' | 'archived';
|
|
||||||
tags?: string[];
|
|
||||||
categories?: string[];
|
|
||||||
year?: number;
|
|
||||||
month?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PostEngineContract {
|
interface PostEngineContract {
|
||||||
getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>;
|
||||||
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
getPost: (id: string) => Promise<PostData | null>;
|
||||||
searchPosts: (query: string) => Promise<Array<{ id: string; title: string; slug: string; excerpt?: string }>>;
|
searchPosts: (query: string) => Promise<SearchResult[]>;
|
||||||
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise<Array<Record<string, unknown>>>;
|
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<PostData[]>;
|
||||||
createPost: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
createPost: (data: Partial<PostData>) => Promise<PostData>;
|
||||||
updatePost: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
||||||
publishPost: (id: string) => Promise<Record<string, unknown> | null>;
|
publishPost: (id: string) => Promise<PostData | null>;
|
||||||
deletePost: (id: string) => Promise<boolean>;
|
deletePost: (id: string) => Promise<boolean>;
|
||||||
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
|
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
|
||||||
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
|
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
|
||||||
getBlogStats: () => Promise<Record<string, unknown>>;
|
getBlogStats: () => Promise<{
|
||||||
|
totalPosts: number;
|
||||||
|
draftCount: number;
|
||||||
|
publishedCount: number;
|
||||||
|
archivedCount: number;
|
||||||
|
oldestPostDate: Date | null;
|
||||||
|
newestPostDate: Date | null;
|
||||||
|
postsPerYear: Record<number, number>;
|
||||||
|
tagCount: number;
|
||||||
|
categoryCount: number;
|
||||||
|
}>;
|
||||||
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
getPostsFiltered: (filter: PostFilter) => Promise<Array<Record<string, unknown>>>;
|
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaEngineContract {
|
interface MediaEngineContract {
|
||||||
getAllMedia: () => Promise<Array<Record<string, unknown>>>;
|
getAllMedia: () => Promise<MediaData[]>;
|
||||||
getMedia: (id: string) => Promise<Record<string, unknown> | null>;
|
getMedia: (id: string) => Promise<MediaData | null>;
|
||||||
updateMedia: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
updateMedia: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
|
||||||
getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise<string | null>;
|
getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScriptEngineContract {
|
interface ScriptEngineContract {
|
||||||
createScript: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
createScript: (input: CreateScriptInput) => Promise<ScriptData>;
|
||||||
|
validateScript: (content: string) => Promise<ScriptValidationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TemplateEngineContract {
|
interface TemplateEngineContract {
|
||||||
createTemplate: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
createTemplate: (input: CreateTemplateInput) => Promise<TemplateData>;
|
||||||
|
validateTemplate: (content: string) => Promise<TemplateValidationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetaEngineContract {
|
interface MetaEngineContract {
|
||||||
getProjectMetadata: () => Promise<Record<string, unknown> | null>;
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PostMediaEngineContract {
|
interface PostMediaEngineContract {
|
||||||
getLinkedMediaDataForPost: (postId: string) => Promise<Array<Record<string, unknown>>>;
|
getLinkedMediaDataForPost: (postId: string) => Promise<Array<PostMediaLinkData & { media: MediaData }>>;
|
||||||
getLinkedPostsForMedia: (mediaId: string) => Promise<Array<Record<string, unknown>>>;
|
getLinkedPostsForMedia: (mediaId: string) => Promise<PostMediaLinkData[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagEngineContract {
|
interface TagEngineContract {
|
||||||
getTagsWithCounts: () => Promise<Array<Record<string, unknown>>>;
|
getTagsWithCounts: () => Promise<TagWithCount[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServerDependencies {
|
export interface MCPServerDependencies {
|
||||||
@@ -80,6 +114,23 @@ export interface MCPServerDependencies {
|
|||||||
getTagEngine: () => TagEngineContract;
|
getTagEngine: () => TagEngineContract;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps each proposal type to the shape of its stored data.
|
||||||
|
* Used to recover type safety when reading from the generic ProposalStore.
|
||||||
|
*/
|
||||||
|
export interface ProposalDataMap {
|
||||||
|
draftPost: { postId: string };
|
||||||
|
proposeScript: CreateScriptInput;
|
||||||
|
proposeTemplate: CreateTemplateInput;
|
||||||
|
proposeMediaMetadata: { mediaId: string; changes: Partial<MediaData> };
|
||||||
|
proposePostMetadata: { postId: string; changes: Partial<PostData> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type-safe accessor for proposal data (bridges the generic ProposalStore). */
|
||||||
|
function proposalData<T extends keyof ProposalDataMap>(proposal: { data: Record<string, unknown> }): ProposalDataMap[T] {
|
||||||
|
return proposal.data as unknown as ProposalDataMap[T];
|
||||||
|
}
|
||||||
|
|
||||||
// ── MCPServer engine ──────────────────────────────────────────────────
|
// ── MCPServer engine ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MCPServer {
|
export class MCPServer {
|
||||||
@@ -228,27 +279,25 @@ export class MCPServer {
|
|||||||
try {
|
try {
|
||||||
switch (proposal.type) {
|
switch (proposal.type) {
|
||||||
case 'draftPost': {
|
case 'draftPost': {
|
||||||
const postId = proposal.data.postId as string;
|
const { postId } = proposalData<'draftPost'>(proposal);
|
||||||
await this.deps.getPostEngine().publishPost(postId);
|
await this.deps.getPostEngine().publishPost(postId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposeScript': {
|
case 'proposeScript': {
|
||||||
await this.deps.getScriptEngine().createScript(proposal.data);
|
await this.deps.getScriptEngine().createScript(proposalData<'proposeScript'>(proposal));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposeTemplate': {
|
case 'proposeTemplate': {
|
||||||
await this.deps.getTemplateEngine().createTemplate(proposal.data);
|
await this.deps.getTemplateEngine().createTemplate(proposalData<'proposeTemplate'>(proposal));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposeMediaMetadata': {
|
case 'proposeMediaMetadata': {
|
||||||
const mediaId = proposal.data.mediaId as string;
|
const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal);
|
||||||
const changes = proposal.data.changes as Record<string, unknown>;
|
|
||||||
await this.deps.getMediaEngine().updateMedia(mediaId, changes);
|
await this.deps.getMediaEngine().updateMedia(mediaId, changes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposePostMetadata': {
|
case 'proposePostMetadata': {
|
||||||
const postId = proposal.data.postId as string;
|
const { postId, changes } = proposalData<'proposePostMetadata'>(proposal);
|
||||||
const changes = proposal.data.changes as Record<string, unknown>;
|
|
||||||
await this.deps.getPostEngine().updatePost(postId, changes);
|
await this.deps.getPostEngine().updatePost(postId, changes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -268,7 +317,7 @@ export class MCPServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (proposal.type === 'draftPost') {
|
if (proposal.type === 'draftPost') {
|
||||||
const postId = proposal.data.postId as string;
|
const { postId } = proposalData<'draftPost'>(proposal);
|
||||||
await this.deps.getPostEngine().deletePost(postId);
|
await this.deps.getPostEngine().deletePost(postId);
|
||||||
}
|
}
|
||||||
this.proposalStore.remove(proposalId);
|
this.proposalStore.remove(proposalId);
|
||||||
@@ -281,14 +330,25 @@ export class MCPServer {
|
|||||||
// ── Resource registration ──────────────────────────────────────────
|
// ── Resource registration ──────────────────────────────────────────
|
||||||
|
|
||||||
private registerResources(server: McpServer): void {
|
private registerResources(server: McpServer): void {
|
||||||
server.registerResource('posts', 'bds://posts', { description: 'All blog posts' }, async () => {
|
server.registerResource('posts', 'bds://posts', { description: 'All blog posts (first page)' }, async () => {
|
||||||
const result = await this.deps.getPostEngine().getAllPosts();
|
const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE });
|
||||||
return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
const response: Record<string, unknown> = { ...result };
|
||||||
|
if (result.hasMore) {
|
||||||
|
response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(response) }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerResource('media', 'bds://media', { description: 'All media files' }, async () => {
|
server.registerResource('media', 'bds://media', { description: 'All media files (first page)' }, async () => {
|
||||||
const result = await this.deps.getMediaEngine().getAllMedia();
|
const allMedia = await this.deps.getMediaEngine().getAllMedia();
|
||||||
return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
const items = allMedia.slice(0, DEFAULT_PAGE_SIZE);
|
||||||
|
const total = allMedia.length;
|
||||||
|
const hasMore = DEFAULT_PAGE_SIZE < total;
|
||||||
|
const response: Record<string, unknown> = { items, total, hasMore };
|
||||||
|
if (hasMore) {
|
||||||
|
response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(response) }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => {
|
server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => {
|
||||||
@@ -308,6 +368,31 @@ export class MCPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerResourceTemplates(server: McpServer): void {
|
private registerResourceTemplates(server: McpServer): void {
|
||||||
|
// ── Pagination templates ──
|
||||||
|
server.registerResource('posts-page', new ResourceTemplate('bds://posts{?cursor}', { list: undefined }), { description: 'Paginated blog posts (use cursor from previous page)' }, async (uri, { cursor }) => {
|
||||||
|
const offset = decodeCursor(cursor as string);
|
||||||
|
const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE, offset });
|
||||||
|
const response: Record<string, unknown> = { ...result };
|
||||||
|
if (result.hasMore) {
|
||||||
|
response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerResource('media-page', new ResourceTemplate('bds://media{?cursor}', { list: undefined }), { description: 'Paginated media files (use cursor from previous page)' }, async (uri, { cursor }) => {
|
||||||
|
const offset = decodeCursor(cursor as string);
|
||||||
|
const allMedia = await this.deps.getMediaEngine().getAllMedia();
|
||||||
|
const items = allMedia.slice(offset, offset + DEFAULT_PAGE_SIZE);
|
||||||
|
const total = allMedia.length;
|
||||||
|
const hasMore = offset + DEFAULT_PAGE_SIZE < total;
|
||||||
|
const response: Record<string, unknown> = { items, total, hasMore };
|
||||||
|
if (hasMore) {
|
||||||
|
response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Entity templates ──
|
||||||
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
||||||
const result = await this.deps.getPostEngine().getPost(id as string);
|
const result = await this.deps.getPostEngine().getPost(id as string);
|
||||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||||
@@ -341,7 +426,7 @@ export class MCPServer {
|
|||||||
server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => {
|
server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => {
|
||||||
const mediaId = id as string;
|
const mediaId = id as string;
|
||||||
const media = await this.deps.getMediaEngine().getMedia(mediaId);
|
const media = await this.deps.getMediaEngine().getMedia(mediaId);
|
||||||
if (!media || !(media as Record<string, unknown>).mimeType || !String((media as Record<string, unknown>).mimeType).startsWith('image/')) {
|
if (!media || !media.mimeType.startsWith('image/')) {
|
||||||
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] };
|
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] };
|
||||||
}
|
}
|
||||||
const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium');
|
const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium');
|
||||||
@@ -433,7 +518,7 @@ export class MCPServer {
|
|||||||
author: args.author,
|
author: args.author,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
});
|
});
|
||||||
const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record<string, unknown>).id });
|
const proposalId = this.proposalStore.create('draftPost', { postId: post.id });
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
||||||
};
|
};
|
||||||
@@ -458,6 +543,7 @@ export class MCPServer {
|
|||||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
_meta: { ui: { resourceUri: 'ui://bds/review-script' } },
|
_meta: { ui: { resourceUri: 'ui://bds/review-script' } },
|
||||||
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
|
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
|
||||||
|
const validation = await this.deps.getScriptEngine().validateScript(args.content);
|
||||||
const proposalId = this.proposalStore.create('proposeScript', {
|
const proposalId = this.proposalStore.create('proposeScript', {
|
||||||
title: args.title,
|
title: args.title,
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
@@ -465,7 +551,7 @@ export class MCPServer {
|
|||||||
entrypoint: args.entrypoint,
|
entrypoint: args.entrypoint,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }],
|
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -481,13 +567,14 @@ export class MCPServer {
|
|||||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
_meta: { ui: { resourceUri: 'ui://bds/review-template' } },
|
_meta: { ui: { resourceUri: 'ui://bds/review-template' } },
|
||||||
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
|
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
|
||||||
|
const validation = await this.deps.getTemplateEngine().validateTemplate(args.content);
|
||||||
const proposalId = this.proposalStore.create('proposeTemplate', {
|
const proposalId = this.proposalStore.create('proposeTemplate', {
|
||||||
title: args.title,
|
title: args.title,
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }],
|
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export interface ScriptReconcileResult {
|
|||||||
processedFiles: number;
|
processedFiles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScriptValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ParsedScriptFile {
|
interface ParsedScriptFile {
|
||||||
metadata: {
|
metadata: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -86,6 +91,45 @@ export class ScriptEngine extends EventEmitter {
|
|||||||
return this.currentProjectId;
|
return this.currentProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validateScript(content: string): Promise<ScriptValidationResult> {
|
||||||
|
const script = [
|
||||||
|
'import ast, sys, json',
|
||||||
|
'code = sys.stdin.read()',
|
||||||
|
'try:',
|
||||||
|
' ast.parse(code)',
|
||||||
|
' json.dump({"valid": True, "errors": []}, sys.stdout)',
|
||||||
|
'except SyntaxError as e:',
|
||||||
|
' msg = e.msg or "invalid syntax"',
|
||||||
|
' line = e.lineno or 1',
|
||||||
|
' col = e.offset or 1',
|
||||||
|
' json.dump({"valid": False, "errors": [f"{msg} (line {line}, col {col})"]}, sys.stdout)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { execFile } = await import('node:child_process');
|
||||||
|
return await new Promise<ScriptValidationResult>((resolve) => {
|
||||||
|
const proc = execFile('python3', ['-c', script], { timeout: 5000 }, (error, stdout) => {
|
||||||
|
if (error && !stdout) {
|
||||||
|
// python3 not available or timed out — assume valid (can't check)
|
||||||
|
resolve({ valid: true, errors: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(stdout);
|
||||||
|
resolve({ valid: !!result.valid, errors: Array.isArray(result.errors) ? result.errors : [] });
|
||||||
|
} catch {
|
||||||
|
resolve({ valid: true, errors: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.stdin?.write(content);
|
||||||
|
proc.stdin?.end();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Dynamic import failed — assume valid
|
||||||
|
return { valid: true, errors: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createScript(input: CreateScriptInput): Promise<ScriptData> {
|
async createScript(input: CreateScriptInput): Promise<ScriptData> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allScripts = await this.getAllScriptRows();
|
const allScripts = await this.getAllScriptRows();
|
||||||
|
|||||||
@@ -871,13 +871,13 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const mcpServer = getMCPServer({
|
const mcpServer = getMCPServer({
|
||||||
getPostEngine: () => getPostEngine() as never,
|
getPostEngine: () => getPostEngine(),
|
||||||
getMediaEngine: () => getMediaEngine() as never,
|
getMediaEngine: () => getMediaEngine(),
|
||||||
getScriptEngine: () => getScriptEngine() as never,
|
getScriptEngine: () => getScriptEngine(),
|
||||||
getTemplateEngine: () => getTemplateEngine() as never,
|
getTemplateEngine: () => getTemplateEngine(),
|
||||||
getMetaEngine: () => getMetaEngine() as never,
|
getMetaEngine: () => getMetaEngine(),
|
||||||
getPostMediaEngine: () => getPostMediaEngine() as never,
|
getPostMediaEngine: () => getPostMediaEngine(),
|
||||||
getTagEngine: () => getTagEngine() as never,
|
getTagEngine: () => getTagEngine(),
|
||||||
});
|
});
|
||||||
await mcpServer.start(MCP_SERVER_PORT);
|
await mcpServer.start(MCP_SERVER_PORT);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer';
|
import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer';
|
||||||
|
|
||||||
// Mock all engine singletons
|
// Mock all engine singletons
|
||||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||||
@@ -65,6 +65,7 @@ function createMockScriptEngine() {
|
|||||||
entrypoint: 'main.py', content: '', enabled: true, version: 1,
|
entrypoint: 'main.py', content: '', enabled: true, version: 1,
|
||||||
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
|
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
|
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ function createMockTemplateEngine() {
|
|||||||
enabled: true, version: 1, filePath: '/test', content: '',
|
enabled: true, version: 1, filePath: '/test', content: '',
|
||||||
createdAt: new Date(), updatedAt: new Date(),
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
|
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +272,34 @@ describe('MCPServer', () => {
|
|||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true);
|
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers posts-page resource template for pagination', () => {
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'posts-page')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers media-page resource template for pagination', () => {
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-page')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cursor encoding', () => {
|
||||||
|
it('round-trips offset through encode/decode', () => {
|
||||||
|
expect(decodeCursor(encodeCursor(0))).toBe(0);
|
||||||
|
expect(decodeCursor(encodeCursor(50))).toBe(50);
|
||||||
|
expect(decodeCursor(encodeCursor(999))).toBe(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for invalid cursor', () => {
|
||||||
|
expect(decodeCursor('!!!invalid!!!')).toBe(0);
|
||||||
|
expect(decodeCursor('')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for negative offset', () => {
|
||||||
|
// Encoding a negative offset then decoding should clamp to 0
|
||||||
|
expect(decodeCursor(encodeCursor(-5))).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registered prompts', () => {
|
describe('registered prompts', () => {
|
||||||
@@ -490,16 +520,71 @@ describe('MCPServer', () => {
|
|||||||
return (mcpServer as Record<string, Record<string, { readCallback: (...args: unknown[]) => Promise<unknown> }>>)._registeredResourceTemplates[name];
|
return (mcpServer as Record<string, Record<string, { readCallback: (...args: unknown[]) => Promise<unknown> }>>)._registeredResourceTemplates[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
it('bds://posts calls getAllPosts and returns JSON', async () => {
|
it('bds://posts calls getAllPosts with pagination limit and returns JSON', async () => {
|
||||||
const postsData = { items: [{ id: 'p1', title: 'Hello' }], hasMore: false, total: 1 };
|
const postsData = { items: [{ id: 'p1', title: 'Hello' }], hasMore: false, total: 1 };
|
||||||
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
|
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
|
||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
const resource = getResource(mcpServer, 'bds://posts');
|
const resource = getResource(mcpServer, 'bds://posts');
|
||||||
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
|
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
|
||||||
expect(mockPostEngine.getAllPosts).toHaveBeenCalled();
|
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE });
|
||||||
expect(JSON.parse(result.contents[0].text)).toEqual(postsData);
|
expect(JSON.parse(result.contents[0].text)).toEqual(postsData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bds://posts includes nextCursor when hasMore is true', async () => {
|
||||||
|
const postsData = {
|
||||||
|
items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${i}` })),
|
||||||
|
hasMore: true,
|
||||||
|
total: 120,
|
||||||
|
};
|
||||||
|
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const resource = getResource(mcpServer, 'bds://posts');
|
||||||
|
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.nextCursor).toBeTruthy();
|
||||||
|
expect(parsed.hasMore).toBe(true);
|
||||||
|
expect(parsed.total).toBe(120);
|
||||||
|
// Cursor should decode to the next offset
|
||||||
|
expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bds://posts omits nextCursor when hasMore is false', async () => {
|
||||||
|
const postsData = { items: [{ id: 'p1' }], hasMore: false, total: 1 };
|
||||||
|
mockPostEngine.getAllPosts.mockResolvedValue(postsData);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const resource = getResource(mcpServer, 'bds://posts');
|
||||||
|
const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.nextCursor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bds://media returns paginated response with items, total, hasMore', async () => {
|
||||||
|
const allMedia = Array.from({ length: 3 }, (_, i) => ({ id: `m${i}` }));
|
||||||
|
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const resource = getResource(mcpServer, 'bds://media');
|
||||||
|
const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.items).toHaveLength(3);
|
||||||
|
expect(parsed.total).toBe(3);
|
||||||
|
expect(parsed.hasMore).toBe(false);
|
||||||
|
expect(parsed.nextCursor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bds://media includes nextCursor when more items than page size', async () => {
|
||||||
|
const allMedia = Array.from({ length: DEFAULT_PAGE_SIZE + 10 }, (_, i) => ({ id: `m${i}` }));
|
||||||
|
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const resource = getResource(mcpServer, 'bds://media');
|
||||||
|
const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE);
|
||||||
|
expect(parsed.total).toBe(DEFAULT_PAGE_SIZE + 10);
|
||||||
|
expect(parsed.hasMore).toBe(true);
|
||||||
|
expect(parsed.nextCursor).toBeTruthy();
|
||||||
|
expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
it('bds://stats calls getBlogStats and returns JSON', async () => {
|
it('bds://stats calls getBlogStats and returns JSON', async () => {
|
||||||
const stats = { totalPosts: 42 };
|
const stats = { totalPosts: 42 };
|
||||||
mockPostEngine.getBlogStats.mockResolvedValue(stats);
|
mockPostEngine.getBlogStats.mockResolvedValue(stats);
|
||||||
@@ -519,6 +604,61 @@ describe('MCPServer', () => {
|
|||||||
expect(JSON.parse(result.contents[0].text)).toEqual(post);
|
expect(JSON.parse(result.contents[0].text)).toEqual(post);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('posts-page template decodes cursor and passes offset to getAllPosts', async () => {
|
||||||
|
const cursor = encodeCursor(50);
|
||||||
|
mockPostEngine.getAllPosts.mockResolvedValue({ items: [{ id: 'p50' }], hasMore: false, total: 60 });
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tpl = getResourceTemplate(mcpServer, 'posts-page');
|
||||||
|
const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
|
||||||
|
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE, offset: 50 });
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.items).toEqual([{ id: 'p50' }]);
|
||||||
|
expect(parsed.nextCursor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts-page template includes nextCursor for subsequent pages', async () => {
|
||||||
|
const cursor = encodeCursor(50);
|
||||||
|
mockPostEngine.getAllPosts.mockResolvedValue({
|
||||||
|
items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${50 + i}` })),
|
||||||
|
hasMore: true,
|
||||||
|
total: 200,
|
||||||
|
});
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tpl = getResourceTemplate(mcpServer, 'posts-page');
|
||||||
|
const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.nextCursor).toBeTruthy();
|
||||||
|
expect(decodeCursor(parsed.nextCursor)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('media-page template decodes cursor and returns correct slice', async () => {
|
||||||
|
const allMedia = Array.from({ length: 80 }, (_, i) => ({ id: `m${i}` }));
|
||||||
|
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
|
||||||
|
const cursor = encodeCursor(50);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tpl = getResourceTemplate(mcpServer, 'media-page');
|
||||||
|
const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.items).toHaveLength(30);
|
||||||
|
expect(parsed.items[0].id).toBe('m50');
|
||||||
|
expect(parsed.total).toBe(80);
|
||||||
|
expect(parsed.hasMore).toBe(false);
|
||||||
|
expect(parsed.nextCursor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('media-page template includes nextCursor when more pages remain', async () => {
|
||||||
|
const allMedia = Array.from({ length: 120 }, (_, i) => ({ id: `m${i}` }));
|
||||||
|
mockMediaEngine.getAllMedia.mockResolvedValue(allMedia);
|
||||||
|
const cursor = encodeCursor(0);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tpl = getResourceTemplate(mcpServer, 'media-page');
|
||||||
|
const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.contents[0].text);
|
||||||
|
expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE);
|
||||||
|
expect(parsed.hasMore).toBe(true);
|
||||||
|
expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => {
|
it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => {
|
||||||
const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }];
|
const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }];
|
||||||
mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia);
|
mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia);
|
||||||
@@ -693,6 +833,50 @@ describe('MCPServer', () => {
|
|||||||
expect(proposal!.data.content).toBe('print("hi")');
|
expect(proposal!.data.content).toBe('print("hi")');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('propose_script calls validateScript and includes validation result in preview', async () => {
|
||||||
|
mockScriptEngine.validateScript.mockResolvedValue({ valid: true, errors: [] });
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'propose_script');
|
||||||
|
const result = await tool.handler({ title: 'Valid Script', kind: 'macro', content: 'print("hello")' }, {}) as { content: Array<{ text: string }> };
|
||||||
|
expect(mockScriptEngine.validateScript).toHaveBeenCalledWith('print("hello")');
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed.preview.syntaxValid).toBe(true);
|
||||||
|
expect(parsed.preview.syntaxErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propose_script includes syntax errors in preview when validation fails', async () => {
|
||||||
|
mockScriptEngine.validateScript.mockResolvedValue({ valid: false, errors: ['invalid syntax (line 1, col 6)'] });
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'propose_script');
|
||||||
|
const result = await tool.handler({ title: 'Bad Script', kind: 'macro', content: 'def (' }, {}) as { content: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed.proposalId).toBeTruthy();
|
||||||
|
expect(parsed.preview.syntaxValid).toBe(false);
|
||||||
|
expect(parsed.preview.syntaxErrors).toEqual(['invalid syntax (line 1, col 6)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propose_template calls validateTemplate and includes validation result in preview', async () => {
|
||||||
|
mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: true, errors: [] });
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'propose_template');
|
||||||
|
const result = await tool.handler({ title: 'Valid Template', kind: 'post', content: '<h1>{{ title }}</h1>' }, {}) as { content: Array<{ text: string }> };
|
||||||
|
expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('<h1>{{ title }}</h1>');
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed.preview.syntaxValid).toBe(true);
|
||||||
|
expect(parsed.preview.syntaxErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propose_template includes syntax errors in preview when validation fails', async () => {
|
||||||
|
mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: false, errors: ['tag "{% invalid" not closed'] });
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'propose_template');
|
||||||
|
const result = await tool.handler({ title: 'Bad Template', kind: 'post', content: '{% invalid' }, {}) as { content: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed.proposalId).toBeTruthy();
|
||||||
|
expect(parsed.preview.syntaxValid).toBe(false);
|
||||||
|
expect(parsed.preview.syntaxErrors).toEqual(['tag "{% invalid" not closed']);
|
||||||
|
});
|
||||||
|
|
||||||
it('propose_media_metadata loads current media and stores proposal', async () => {
|
it('propose_media_metadata loads current media and stores proposal', async () => {
|
||||||
const currentMedia = { id: 'img-1', alt: 'Old alt', title: 'Old title' };
|
const currentMedia = { id: 'img-1', alt: 'Old alt', title: 'Old title' };
|
||||||
mockMediaEngine.getMedia.mockResolvedValue(currentMedia);
|
mockMediaEngine.getMedia.mockResolvedValue(currentMedia);
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { ScriptEngine } from '../../src/main/engine/ScriptEngine';
|
import { ScriptEngine } from '../../src/main/engine/ScriptEngine';
|
||||||
|
|
||||||
|
const { mockExecFile } = vi.hoisted(() => ({
|
||||||
|
mockExecFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: mockExecFile,
|
||||||
|
default: { execFile: mockExecFile },
|
||||||
|
}));
|
||||||
|
|
||||||
const mockScripts = new Map<string, any>();
|
const mockScripts = new Map<string, any>();
|
||||||
const mockFiles = new Map<string, string>();
|
const mockFiles = new Map<string, string>();
|
||||||
|
|
||||||
@@ -377,4 +386,63 @@ describe('ScriptEngine', () => {
|
|||||||
expect(found).toBeNull();
|
expect(found).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateScript', () => {
|
||||||
|
it('returns valid for correct Python syntax', async () => {
|
||||||
|
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
|
||||||
|
cb(null, JSON.stringify({ valid: true, errors: [] }));
|
||||||
|
return { stdin: { write: vi.fn(), end: vi.fn() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scriptEngine.validateScript('print("hello")');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid with errors for bad Python syntax', async () => {
|
||||||
|
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
|
||||||
|
cb(null, JSON.stringify({ valid: false, errors: ['invalid syntax (line 1, col 5)'] }));
|
||||||
|
return { stdin: { write: vi.fn(), end: vi.fn() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scriptEngine.validateScript('def (');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual(['invalid syntax (line 1, col 5)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid when python3 is not available', async () => {
|
||||||
|
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error, stdout: string) => void) => {
|
||||||
|
cb(new Error('spawn python3 ENOENT'), '');
|
||||||
|
return { stdin: { write: vi.fn(), end: vi.fn() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scriptEngine.validateScript('print("hello")');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid when python3 output is not parseable JSON', async () => {
|
||||||
|
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
|
||||||
|
cb(null, 'not json');
|
||||||
|
return { stdin: { write: vi.fn(), end: vi.fn() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scriptEngine.validateScript('print("hello")');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes script content via stdin', async () => {
|
||||||
|
const writeFn = vi.fn();
|
||||||
|
const endFn = vi.fn();
|
||||||
|
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
|
||||||
|
cb(null, JSON.stringify({ valid: true, errors: [] }));
|
||||||
|
return { stdin: { write: writeFn, end: endFn } };
|
||||||
|
});
|
||||||
|
|
||||||
|
await scriptEngine.validateScript('x = 42');
|
||||||
|
expect(writeFn).toHaveBeenCalledWith('x = 42');
|
||||||
|
expect(endFn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user