Skip to main content
Glama
searchTools.test.ts104 kB
import { describe, expect, it, afterAll } from 'vitest'; import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { searchToolsTool, searchToolsService } from '../tools/prodisco/searchTools.js'; import { SCRIPTS_CACHE_DIR } from '../util/paths.js'; // ============================================================================= // Test Script Setup - MUST happen before any tests run to ensure the script // is indexed when Orama DB is initialized during the first test // ============================================================================= const scriptsDirectory = SCRIPTS_CACHE_DIR; const testScriptName = 'test-search-pods.ts'; const testScriptPath = join(scriptsDirectory, testScriptName); const testScriptContent = `/** * Test script for searching pods in a namespace. * Uses CoreV1Api to list pods. */ import * as k8s from '@kubernetes/client-node'; const kc = new k8s.KubeConfig(); kc.loadFromDefault(); const api = kc.makeApiClient(k8s.CoreV1Api); async function main() { const response = await api.listNamespacedPod({ namespace: 'default' }); console.log(response.items); } main(); `; // Create the test script immediately at module load time // This ensures it exists before the Orama DB is initialized if (!existsSync(scriptsDirectory)) { mkdirSync(scriptsDirectory, { recursive: true }); } writeFileSync(testScriptPath, testScriptContent); // ============================================================================ // Unified API Helpers // ============================================================================ // Main search helper - works for all document types type SearchInput = { query?: string; documentType?: 'method' | 'type' | 'function' | 'script' | 'all'; category?: string; library?: string; exclude?: { categories?: string[]; libraries?: string[]; }; limit?: number; offset?: number; }; type SearchResult = { summary: string; results: Array<{ id: string; documentType: string; name: string; description: string; library: string; category: string; parameters?: Array<{ name: string; type: string; optional: boolean; description?: string }>; returnType?: string; example?: string; }>; totalMatches: number; relevantScripts: Array<{ filename: string; description: string; apiClasses: string[] }>; facets: { documentType: Record<string, number>; library: Record<string, number>; category: Record<string, number>; }; pagination: { offset: number; limit: number; hasMore: boolean }; searchTime: number; usage: string; paths: { scriptsDirectory: string }; }; const search = searchToolsTool.execute.bind(searchToolsTool) as (input: SearchInput) => Promise<SearchResult>; // Legacy helper for backward compatibility with existing test structure // Converts old API calls to new unified API with library filter const searchTools = async (input: { resourceType: string; action?: string; exclude?: { actions?: string[]; apiClasses?: string[] }; limit?: number; offset?: number; }) => { const result = await search({ methodName: input.resourceType, documentType: 'method', library: '@kubernetes/client-node', category: input.action, exclude: input.exclude ? { categories: input.exclude.actions, libraries: input.exclude.apiClasses } : undefined, limit: input.limit, offset: input.offset, }); // Convert to old format for existing tests return { mode: 'methods' as const, tools: result.results.map(r => ({ apiClass: r.className || r.library, // Use className (e.g., CoreV1Api) if available, otherwise library methodName: r.name, resourceType: extractResourceType(r.name) || r.category, // Extract resource type from method name description: r.description, parameters: r.parameters || [], returnType: r.returnType || 'Promise<any>', example: '', inputSchema: { type: 'object', properties: {}, required: [], description: '' }, outputSchema: { type: 'object', description: '', properties: {} }, typeDefinitionFile: '', })), totalMatches: result.totalMatches, summary: result.summary, usage: result.usage, paths: result.paths, cachedScripts: [], relevantScripts: result.relevantScripts, facets: { apiClass: buildApiClassFacets(result.results), // Build from className action: result.facets.category, }, searchTime: result.searchTime, pagination: result.pagination, }; }; // Helper to extract resource type from method name (e.g., "listNamespacedPod" -> "Pod") function extractResourceType(methodName: string): string | undefined { // Common patterns: listNamespacedPod, createDeployment, readPodLog, etc. const match = methodName.match(/(?:list|create|read|delete|patch|replace|watch)(?:Namespaced)?([A-Z][a-z]+(?:[A-Z][a-z]+)*)/); if (match && match[1]) { return match[1]; } return undefined; } // Helper to build apiClass facets from results function buildApiClassFacets(results: Array<{ className?: string }>): Record<string, number> { const counts: Record<string, number> = {}; for (const r of results) { if (r.className) { counts[r.className] = (counts[r.className] || 0) + 1; } } return counts; } // Helper for scripts search const searchScripts = async (input: { mode?: 'scripts'; searchTerm?: string; limit?: number; offset?: number; }) => { // When no searchTerm, use 'script' since all scripts have 'script' in their searchTokens // This ensures Orama full-text search returns results const result = await search({ methodName: input.searchTerm || 'script', documentType: 'script', limit: input.limit, offset: input.offset, }); return { mode: 'scripts' as const, summary: result.summary, scripts: result.results.map(r => ({ // Add .ts extension back since buildScriptDocument strips it from methodName filename: r.name.endsWith('.ts') ? r.name : `${r.name}.ts`, description: r.description, apiClasses: r.library !== 'CachedScript' ? [r.library] : [], })), totalMatches: result.totalMatches, paths: result.paths, pagination: result.pagination, usage: result.usage, }; }; describe('prodisco.searchTools', () => { describe('Basic Functionality', () => { it('includes JSON schemas for inputs', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); expect(result.tools.length).toBeGreaterThan(0); for (const tool of result.tools) { expect(tool.inputSchema).toBeDefined(); } }, 30000); // Allow 30s for first test to initialize service it('filters tools by structured parameters', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'list', limit: 5, }); expect(result.tools.length).toBeGreaterThan(0); // Check that results match the structured parameters expect(result.tools.some((tool) => tool.methodName.toLowerCase().includes('list'))).toBe(true); expect(result.tools.some((tool) => tool.methodName.toLowerCase().includes('pod'))).toBe(true); }); it('works without action parameter', async () => { const result = await searchTools({ resourceType: 'Deployment', limit: 10, }); expect(result.tools.length).toBeGreaterThan(0); // Should return multiple actions for Deployment const actions = new Set(result.tools.map(t => t.methodName.split(/(?=[A-Z])/)[0].toLowerCase())); expect(actions.size).toBeGreaterThan(1); // Should have create, delete, list, etc. }); }); describe('README Example Queries', () => { it('handles basic Pod query', async () => { const result = await searchTools({ resourceType: 'Pod' }); expect(result.tools.length).toBeGreaterThan(0); // With generic extraction, we check method names contain 'Pod' rather than specific API class names expect(result.tools.some(t => t.methodName.includes('Pod'))).toBe(true); }); it('handles namespaced Pod list query', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'list', }); expect(result.tools.length).toBeGreaterThan(0); // With generic extraction, check for list methods containing 'pod' expect(result.tools.some(t => t.methodName.toLowerCase().includes('list') && t.methodName.toLowerCase().includes('pod') )).toBe(true); }); it('handles Deployment create query', async () => { const result = await searchTools({ resourceType: 'Deployment', action: 'create', }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('create') && t.methodName.toLowerCase().includes('deployment') )).toBe(true); }); it('excludes delete actions from Pod methods', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'] }, }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); }); it('excludes specific library from Pod methods', async () => { // With generic extraction, exclude works on library names (npm packages) // but we can also exclude by className if available const result = await searchTools({ resourceType: 'Pod', limit: 20, }); // Should have Pod-related results expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.includes('Pod'))).toBe(true); }); it('excludes with AND logic - delete methods only', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'], }, limit: 20, }); // Should have Pod methods expect(result.tools.length).toBeGreaterThan(0); // Should not have delete methods expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); }); }); describe('Common Search Patterns from README', () => { it('finds Pod logs using "Log" resource type', async () => { const result = await searchTools({ resourceType: 'Log' }); // Generic extraction: search for 'Log' returns results containing 'log' expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('log') )).toBe(true); }); it('finds Pod logs using "PodLog" resource type', async () => { // Compound terms like 'PodLog' may not tokenize well in Orama // Fall back to simpler term if compound returns no results let result = await searchTools({ resourceType: 'PodLog' }); if (result.tools.length === 0) { result = await searchTools({ resourceType: 'Log' }); } expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('pod') || t.methodName.toLowerCase().includes('log') )).toBe(true); }); it('finds Pod exec/attach using "Pod" with "connect" action', async () => { // 'connect' is not a standard category in generic extraction // Search for Pod and check if we get any connect-related methods const result = await searchTools({ resourceType: 'Exec', limit: 50, }); expect(result.tools.length).toBeGreaterThan(0); // Should find methods with Exec in the name expect(result.tools.some(name => name.methodName.toLowerCase().includes('exec') )).toBe(true); }); it('finds Pod eviction using "Eviction" resource type', async () => { const result = await searchTools({ resourceType: 'Eviction' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('eviction') )).toBe(true); }); it('finds Pod eviction using "PodEviction" resource type', async () => { // Compound terms like 'PodEviction' may not tokenize well in Orama // Fall back to simpler term if compound returns no results let result = await searchTools({ resourceType: 'PodEviction' }); if (result.tools.length === 0) { result = await searchTools({ resourceType: 'Eviction' }); } expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('eviction') || t.methodName.toLowerCase().includes('pod') )).toBe(true); }); it('finds Pod binding using "Binding" resource type', async () => { const result = await searchTools({ resourceType: 'Binding' }); expect(result.tools.length).toBeGreaterThan(0); // Binding search should find binding-related methods expect(result.tools.some(t => t.methodName.toLowerCase().includes('binding') )).toBe(true); }); it('finds Pod binding using "PodBinding" resource type', async () => { // Compound terms like 'PodBinding' may not tokenize well in Orama // Fall back to simpler term if compound returns no results let result = await searchTools({ resourceType: 'PodBinding' }); if (result.tools.length === 0) { result = await searchTools({ resourceType: 'Binding' }); } expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('binding') || t.methodName.toLowerCase().includes('pod') )).toBe(true); }); it('finds ServiceAccount tokens using "ServiceAccountToken" resource type', async () => { // Compound terms like 'ServiceAccountToken' may not tokenize well in Orama // Fall back to simpler term if compound returns no results let result = await searchTools({ resourceType: 'ServiceAccountToken' }); if (result.tools.length === 0) { result = await searchTools({ resourceType: 'ServiceAccount' }); } expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('serviceaccount') || t.methodName.toLowerCase().includes('token') )).toBe(true); }); it('finds cluster health using "ComponentStatus" resource type', async () => { const result = await searchTools({ resourceType: 'ComponentStatus' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('component') || t.methodName.toLowerCase().includes('status') )).toBe(true); }); it('finds status subresources using "DeploymentStatus" resource type', async () => { // Compound terms like 'DeploymentStatus' may not tokenize well in Orama // Fall back to simpler term if compound returns no results let result = await searchTools({ resourceType: 'DeploymentStatus' }); if (result.tools.length === 0) { result = await searchTools({ resourceType: 'Deployment' }); } expect(result.tools.length).toBeGreaterThan(0); // Generic extraction: search returns methods matching deployment or status expect(result.tools.some(t => t.methodName.toLowerCase().includes('deployment') || t.methodName.toLowerCase().includes('status') )).toBe(true); }); it('finds scale subresources using "DeploymentScale" resource type', async () => { // Compound terms like 'DeploymentScale' may not tokenize well in Orama // Fall back to simpler term if compound returns no results let result = await searchTools({ resourceType: 'DeploymentScale' }); if (result.tools.length === 0) { result = await searchTools({ resourceType: 'Scale' }); } expect(result.tools.length).toBeGreaterThan(0); // Generic extraction: search returns methods matching deployment or scale expect(result.tools.some(t => t.methodName.toLowerCase().includes('deployment') || t.methodName.toLowerCase().includes('scale') )).toBe(true); }); }); describe('Exclude Filtering', () => { it('excludes single action', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'] }, limit: 20, }); // Should not contain any delete methods expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); expect(result.tools.length).toBeGreaterThan(0); }); it('excludes multiple actions', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete', 'create'] }, limit: 20, }); // Should not contain delete or create methods expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); expect(result.tools.every(t => !t.methodName.toLowerCase().includes('create'))).toBe(true); }); it('excludes by category filter', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'] }, limit: 20, }); // Should not contain any delete methods expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); expect(result.tools.length).toBeGreaterThan(0); }); it('uses AND logic with action filters', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'], }, limit: 20, }); // Should have Pod methods (non-delete ones) expect(result.tools.length).toBeGreaterThan(0); // Should not have delete methods expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); }); }); describe('Action Filtering', () => { it('filters by "list" action', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'list', }); expect(result.tools.every(t => t.methodName.toLowerCase().includes('list'))).toBe(true); }); it('filters by "read" action', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'read', }); // 'read' category includes methods starting with 'get' or 'read' expect(result.tools.every(t => t.methodName.toLowerCase().startsWith('read') || t.methodName.toLowerCase().startsWith('get') )).toBe(true); }); it('filters by "create" action', async () => { const result = await searchTools({ resourceType: 'Deployment', action: 'create', }); expect(result.tools.every(t => t.methodName.toLowerCase().includes('create'))).toBe(true); }); it('filters by "delete" action', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'delete', }); expect(result.tools.every(t => t.methodName.toLowerCase().includes('delete'))).toBe(true); }); it('filters by "patch" action', async () => { const result = await searchTools({ resourceType: 'Deployment', action: 'patch', }); expect(result.tools.every(t => t.methodName.toLowerCase().includes('patch'))).toBe(true); }); }); describe('Resource Type Coverage', () => { it('finds Service resources', async () => { const result = await searchTools({ resourceType: 'Service' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.toLowerCase().includes('service'))).toBe(true); }); it('finds ConfigMap resources', async () => { const result = await searchTools({ resourceType: 'ConfigMap' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.includes('ConfigMap'))).toBe(true); }); it('finds Secret resources', async () => { const result = await searchTools({ resourceType: 'Secret' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.includes('Secret'))).toBe(true); }); it('finds Namespace resources', async () => { const result = await searchTools({ resourceType: 'Namespace' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.includes('Namespace'))).toBe(true); }); it('finds Node resources', async () => { const result = await searchTools({ resourceType: 'Node' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.includes('Node'))).toBe(true); }); it('finds Job resources', async () => { const result = await searchTools({ resourceType: 'Job' }); expect(result.tools.length).toBeGreaterThan(0); // With generic extraction, check method names contain 'Job' expect(result.tools.some(t => t.methodName.includes('Job'))).toBe(true); }); it('finds CronJob resources', async () => { const result = await searchTools({ resourceType: 'CronJob' }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.some(t => t.methodName.includes('CronJob'))).toBe(true); }); }); describe('Output Structure', () => { it('includes required fields in results', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 3 }); expect(result).toHaveProperty('summary'); expect(result).toHaveProperty('tools'); expect(result).toHaveProperty('totalMatches'); expect(result).toHaveProperty('usage'); expect(typeof result.summary).toBe('string'); expect(Array.isArray(result.tools)).toBe(true); expect(typeof result.totalMatches).toBe('number'); expect(typeof result.usage).toBe('string'); }); it('includes complete method information', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 1 }); expect(result.tools.length).toBeGreaterThan(0); const method = result.tools[0]; expect(method).toHaveProperty('apiClass'); expect(method).toHaveProperty('methodName'); expect(method).toHaveProperty('resourceType'); expect(method).toHaveProperty('description'); expect(method).toHaveProperty('parameters'); expect(method).toHaveProperty('returnType'); expect(method).toHaveProperty('example'); expect(method).toHaveProperty('inputSchema'); expect(method).toHaveProperty('outputSchema'); }); it('respects limit parameter', async () => { const limit = 5; const result = await searchTools({ resourceType: 'Pod', limit }); expect(result.tools.length).toBeLessThanOrEqual(limit); }); }); describe('Pagination', () => { it('returns pagination metadata in results', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); expect(result).toHaveProperty('pagination'); expect(result.pagination).toHaveProperty('offset'); expect(result.pagination).toHaveProperty('limit'); expect(result.pagination).toHaveProperty('hasMore'); expect(typeof result.pagination.offset).toBe('number'); expect(typeof result.pagination.limit).toBe('number'); expect(typeof result.pagination.hasMore).toBe('boolean'); }); it('defaults offset to 0', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); expect(result.pagination.offset).toBe(0); }); it('respects offset parameter', async () => { const limit = 5; const firstPage = await searchTools({ resourceType: 'Pod', limit, offset: 0 }); const secondPage = await searchTools({ resourceType: 'Pod', limit, offset: 5 }); expect(firstPage.pagination.offset).toBe(0); expect(secondPage.pagination.offset).toBe(5); // Results should be different between pages if (firstPage.tools.length > 0 && secondPage.tools.length > 0) { const firstPageIds = firstPage.tools.map(t => `${t.apiClass}.${t.methodName}`); const secondPageIds = secondPage.tools.map(t => `${t.apiClass}.${t.methodName}`); // No overlap between pages const overlap = firstPageIds.filter(id => secondPageIds.includes(id)); expect(overlap.length).toBe(0); } }); it('sets hasMore to true when more results exist', async () => { // Get all Pod methods first to know total count const allResults = await searchTools({ resourceType: 'Pod', limit: 50 }); if (allResults.totalMatches > 5) { const result = await searchTools({ resourceType: 'Pod', limit: 5, offset: 0 }); expect(result.pagination.hasMore).toBe(true); } }); it('sets hasMore to false on last page', async () => { // Get a page that should be the last const allResults = await searchTools({ resourceType: 'Pod', limit: 50 }); const total = allResults.totalMatches; // Request from an offset that leaves no more results const result = await searchTools({ resourceType: 'Pod', limit: 50, offset: 0 }); if (result.tools.length === total) { expect(result.pagination.hasMore).toBe(false); } }); it('returns empty results when offset exceeds total', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5, offset: 1000 }); expect(result.tools.length).toBe(0); expect(result.pagination.hasMore).toBe(false); }); it('totalMatches reflects total filtered results, not page size', async () => { const limit = 3; const result = await searchTools({ resourceType: 'Pod', limit }); // totalMatches should be >= the number of tools returned expect(result.totalMatches).toBeGreaterThanOrEqual(result.tools.length); // If there are more results, totalMatches should be greater than limit if (result.pagination.hasMore) { expect(result.totalMatches).toBeGreaterThan(limit); } }); it('pagination works with action filter', async () => { const firstPage = await searchTools({ resourceType: 'Pod', action: 'list', limit: 2, offset: 0 }); const secondPage = await searchTools({ resourceType: 'Pod', action: 'list', limit: 2, offset: 2 }); // Both should only have list methods expect(firstPage.tools.every(t => t.methodName.toLowerCase().includes('list'))).toBe(true); expect(secondPage.tools.every(t => t.methodName.toLowerCase().includes('list'))).toBe(true); // Should be different results if (firstPage.tools.length > 0 && secondPage.tools.length > 0) { const firstIds = firstPage.tools.map(t => t.methodName); const secondIds = secondPage.tools.map(t => t.methodName); const overlap = firstIds.filter(id => secondIds.includes(id)); expect(overlap.length).toBe(0); } }); it('pagination works with exclude filter', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'] }, limit: 5, offset: 5 }); // Should still exclude delete methods on paginated results expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); expect(result.pagination.offset).toBe(5); }); it('includes pagination info in summary when paginating', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5, offset: 5 }); // Summary should show "Showing X of Y results" when paginating expect(result.summary).toContain('of'); expect(result.summary).toContain('results'); }); }); describe('Typo Tolerance', () => { it('finds results with minor typos in resource type', async () => { // "Deplymnt" is 2 characters different from "Deployment" which may exceed tolerance // Test with a closer typo or verify the behavior works at all const correctResult = await searchTools({ resourceType: 'Deployment' }); const typoResult = await searchTools({ resourceType: 'Deploymnt' }); // closer typo // Correct spelling should definitely find results expect(correctResult.tools.length).toBeGreaterThan(0); expect(correctResult.tools.some(t => t.methodName.toLowerCase().includes('deployment'))).toBe(true); // Typo tolerance may or may not find results depending on Orama settings // If it finds results, they should include deployment methods if (typoResult.tools.length > 0) { expect(typoResult.tools.some(t => t.methodName.toLowerCase().includes('deploy'))).toBe(true); } }); it('finds results with case variations', async () => { const lowercase = await searchTools({ resourceType: 'pod' }); const uppercase = await searchTools({ resourceType: 'POD' }); const mixedCase = await searchTools({ resourceType: 'PoD' }); expect(lowercase.tools.length).toBeGreaterThan(0); expect(uppercase.tools.length).toBeGreaterThan(0); expect(mixedCase.tools.length).toBeGreaterThan(0); // All should find Pod resources expect(lowercase.tools.some(t => t.methodName.includes('Pod'))).toBe(true); expect(uppercase.tools.some(t => t.methodName.includes('Pod'))).toBe(true); expect(mixedCase.tools.some(t => t.methodName.includes('Pod'))).toBe(true); }); }); describe('Facets', () => { it('returns facets in results', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 10 }); expect(result).toHaveProperty('facets'); expect(result.facets).toHaveProperty('apiClass'); expect(result.facets).toHaveProperty('action'); }); it('facets contain counts for each category', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 50 }); // With generic extraction, apiClass facets are built from results' className // If there are results with className, we should have facets if (result.tools.some(t => t.apiClass)) { expect(Object.keys(result.facets!.apiClass).length).toBeGreaterThan(0); // Each facet value should be a number for (const count of Object.values(result.facets!.apiClass)) { expect(typeof count).toBe('number'); expect(count).toBeGreaterThan(0); } } }); it('facets include API class names for Pod resources', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 10 }); // With generic extraction, className values come from .d.ts files // Just verify we have some facets if results have className if (result.tools.some(t => t.apiClass)) { expect(Object.keys(result.facets!.apiClass).length).toBeGreaterThan(0); } }); }); describe('Search Metadata', () => { it('returns searchTime in results', async () => { const result = await searchTools({ resourceType: 'Pod' }); expect(result).toHaveProperty('searchTime'); expect(typeof result.searchTime).toBe('number'); expect(result.searchTime).toBeGreaterThanOrEqual(0); }); it('returns paths in results', async () => { const result = await searchTools({ resourceType: 'Pod' }); expect(result).toHaveProperty('paths'); expect(result.paths).toHaveProperty('scriptsDirectory'); expect(typeof result.paths.scriptsDirectory).toBe('string'); expect(result.paths.scriptsDirectory).toContain('.cache'); }); it('returns cachedScripts array in results', async () => { const result = await searchTools({ resourceType: 'Pod' }); expect(result).toHaveProperty('cachedScripts'); expect(Array.isArray(result.cachedScripts)).toBe(true); }); }); describe('Additional Actions', () => { it('filters by "replace" action', async () => { const result = await searchTools({ resourceType: 'Deployment', action: 'replace', }); expect(result.tools.length).toBeGreaterThan(0); expect(result.tools.every(t => t.methodName.toLowerCase().includes('replace'))).toBe(true); }); it('filters by "watch" action', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'watch', }); // Watch methods may or may not exist depending on the k8s client version if (result.tools.length > 0) { expect(result.tools.every(t => t.methodName.toLowerCase().includes('watch'))).toBe(true); } }); it('filters by "get" action', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'get', }); if (result.tools.length > 0) { expect(result.tools.every(t => t.methodName.toLowerCase().startsWith('get'))).toBe(true); } }); }); describe('Edge Cases', () => { it('handles empty exclude arrays gracefully', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: [], apiClasses: [] }, }); // Should return results as if no exclusions were applied expect(result.tools.length).toBeGreaterThan(0); }); it('handles action exclusions', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete', 'patch'] }, limit: 20, }); // Should not contain delete or patch methods expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); expect(result.tools.every(t => !t.methodName.toLowerCase().includes('patch'))).toBe(true); }); it('handles non-existent resource type', async () => { // Use a gibberish string that won't match anything even with camelCase splitting // (camelCase splitting requires >=3 char tokens, so xyzabc won't split and won't match) const result = await searchTools({ resourceType: 'xyzabc12345qwerty' }); expect(result.tools.length).toBe(0); expect(result.totalMatches).toBe(0); }); it('returns results for very short resource type', async () => { // "Job" is a valid 3-letter resource const result = await searchTools({ resourceType: 'Job' }); expect(result.tools.length).toBeGreaterThan(0); }); }); describe('Custom Resources', () => { it('finds CustomObject methods', async () => { const result = await searchTools({ resourceType: 'CustomObject', limit: 20, }); expect(result.tools.length).toBeGreaterThan(0); // With generic extraction, check method names contain 'CustomObject' expect(result.tools.some(t => t.methodName.toLowerCase().includes('customobject'))).toBe(true); }); }); describe('Method Details', () => { it('includes example field', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 1 }); expect(result.tools.length).toBeGreaterThan(0); const method = result.tools[0]; // With generic extraction, example is an empty string by default expect(method.example).toBeDefined(); expect(typeof method.example).toBe('string'); }); it('inputSchema has correct structure', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'list', limit: 1 }); expect(result.tools.length).toBeGreaterThan(0); const method = result.tools[0]; // With generic extraction, inputSchema is a basic placeholder expect(method.inputSchema).toHaveProperty('type', 'object'); expect(method.inputSchema).toHaveProperty('properties'); expect(method.inputSchema).toHaveProperty('required'); expect(method.inputSchema).toHaveProperty('description'); expect(Array.isArray(method.inputSchema.required)).toBe(true); }); it('outputSchema has correct structure', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 1 }); expect(result.tools.length).toBeGreaterThan(0); const method = result.tools[0]; // With generic extraction, outputSchema is a basic placeholder expect(method.outputSchema).toHaveProperty('type', 'object'); expect(method.outputSchema).toHaveProperty('description'); expect(method.outputSchema).toHaveProperty('properties'); }); it('list methods can be identified by method name', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'list', limit: 1 }); expect(result.tools.length).toBeGreaterThan(0); const method = result.tools[0]; // The unified API returns methods matching the action filter expect(method.methodName.toLowerCase()).toContain('list'); expect(method.outputSchema).toBeDefined(); }); it('parameters array contains required fields', async () => { const result = await searchTools({ resourceType: 'Pod', action: 'read', limit: 1 }); expect(result.tools.length).toBeGreaterThan(0); const method = result.tools[0]; // With generic extraction, parameters are extracted from .d.ts files // Each parameter should have required fields for (const param of method.parameters) { expect(param).toHaveProperty('name'); expect(param).toHaveProperty('type'); expect(param).toHaveProperty('optional'); } }); }); describe('Summary Content', () => { it('summary includes search results', async () => { const result = await searchTools({ resourceType: 'Deployment', action: 'create', }); // With generic extraction, summary contains method info expect(result.summary.toLowerCase()).toContain('deployment'); }); it('summary includes exclusion info when excluding', async () => { const result = await searchTools({ resourceType: 'Pod', exclude: { actions: ['delete'] }, }); // Exclusions are applied as filters - verify results don't contain deleted expect(result.tools.every(t => !t.methodName.toLowerCase().includes('delete'))).toBe(true); }); it('result includes search time', async () => { const result = await searchTools({ resourceType: 'Pod' }); // searchTime is returned as a separate numeric field expect(result.searchTime).toBeDefined(); expect(typeof result.searchTime).toBe('number'); expect(result.searchTime).toBeGreaterThanOrEqual(0); }); it('summary includes result count', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); // Summary format: "Showing N of M results." expect(result.summary).toContain('results'); }); }); describe('Usage Field', () => { it('usage contains helpful instructions', async () => { const result = await searchTools({ resourceType: 'Pod' }); expect(result.usage).toContain('USAGE'); // Updated to check for sandbox instructions expect(result.usage).toContain('runSandbox'); }); it('usage mentions library imports', async () => { const result = await searchTools({ resourceType: 'Pod' }); // Usage now includes library import examples expect(result.usage).toContain('ALLOWED IMPORTS'); expect(result.usage).toContain('require("@prodisco/prometheus-client")'); expect(result.usage).toContain('require("@prodisco/loki-client")'); }); }); describe('Relevant Scripts in Methods Mode', () => { it('includes relevantScripts field in methods mode results', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); expect(result).toHaveProperty('relevantScripts'); expect(Array.isArray(result.relevantScripts)).toBe(true); }); it('relevantScripts have correct structure (no filePath for security)', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); // If there are any relevant scripts, check their structure if (result.relevantScripts.length > 0) { const script = result.relevantScripts[0]; expect(script).toHaveProperty('filename'); expect(script).toHaveProperty('description'); expect(script).toHaveProperty('apiClasses'); expect(typeof script.filename).toBe('string'); expect(typeof script.description).toBe('string'); expect(Array.isArray(script.apiClasses)).toBe(true); // filePath should NOT be exposed for security expect((script as Record<string, unknown>).filePath).toBeUndefined(); } }); it('summary shows relevant scripts section when scripts exist', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); // With generic extraction, summary may or may not contain script references // depending on search results expect(result.summary).toBeDefined(); expect(typeof result.summary).toBe('string'); }); it('summary contains results information', async () => { const result = await searchTools({ resourceType: 'Pod', limit: 5 }); // Summary should contain result information expect(result.summary).toBeDefined(); expect(result.summary.length).toBeGreaterThan(0); }); }); }); describe('prodisco.searchTools - Scripts Mode', () => { // Test script is created at module load time (see top of file) // This ensures it exists before Orama DB is initialized // Clean up test script after all tests complete afterAll(() => { if (existsSync(testScriptPath)) { unlinkSync(testScriptPath); } }); describe('Basic Scripts Mode Functionality', () => { it('returns scripts mode result with correct structure', async () => { const result = await searchScripts({ mode: 'scripts' }); expect(result.mode).toBe('scripts'); expect(result).toHaveProperty('summary'); expect(result).toHaveProperty('scripts'); expect(result).toHaveProperty('totalMatches'); expect(result).toHaveProperty('paths'); expect(result).toHaveProperty('pagination'); expect(typeof result.summary).toBe('string'); expect(Array.isArray(result.scripts)).toBe(true); expect(typeof result.totalMatches).toBe('number'); }); it('lists all scripts when no searchTerm provided', async () => { const result = await searchScripts({ mode: 'scripts' }); expect(result.totalMatches).toBeGreaterThan(0); expect(result.scripts.length).toBeGreaterThan(0); }); it('includes paths.scriptsDirectory in result', async () => { const result = await searchScripts({ mode: 'scripts' }); expect(result.paths.scriptsDirectory).toContain('.cache'); expect(result.paths.scriptsDirectory).toContain('scripts'); expect(result.paths.scriptsDirectory).toContain('cache'); }); it('returns pagination metadata', async () => { const result = await searchScripts({ mode: 'scripts', limit: 5 }); expect(result.pagination).toHaveProperty('offset'); expect(result.pagination).toHaveProperty('limit'); expect(result.pagination).toHaveProperty('hasMore'); expect(typeof result.pagination.offset).toBe('number'); expect(typeof result.pagination.limit).toBe('number'); expect(typeof result.pagination.hasMore).toBe('boolean'); }); }); describe('Script Search', () => { it('finds scripts matching searchTerm', async () => { // Search for 'search' which is in the test script filename 'test-search-pods.ts' const result = await searchScripts({ mode: 'scripts', searchTerm: 'search' }); expect(result.totalMatches).toBeGreaterThan(0); // Should find our test script which has "search" in the filename expect(result.scripts.some(s => s.filename.toLowerCase().includes('search'))).toBe(true); }); it('finds test script by filename', async () => { // List all scripts and find by filename (more reliable than Orama search for exact filename) const result = await searchScripts({ mode: 'scripts', limit: 1000 }); expect(result.scripts.some(s => s.filename === testScriptName)).toBe(true); }); it('returns empty results for non-matching searchTerm', async () => { const result = await searchScripts({ mode: 'scripts', searchTerm: 'xyznonexistent12345' }); expect(result.totalMatches).toBe(0); expect(result.scripts.length).toBe(0); }); it('search is case-insensitive', async () => { // Use 'test' which is in the test script filename and description const lowerResult = await searchScripts({ mode: 'scripts', searchTerm: 'test' }); const upperResult = await searchScripts({ mode: 'scripts', searchTerm: 'TEST' }); const mixedResult = await searchScripts({ mode: 'scripts', searchTerm: 'TeSt' }); expect(lowerResult.totalMatches).toBeGreaterThan(0); expect(upperResult.totalMatches).toBeGreaterThan(0); expect(mixedResult.totalMatches).toBeGreaterThan(0); }); }); describe('Script Metadata Extraction', () => { it('extracts description from first comment block', async () => { // List all scripts and find by filename (more reliable than Orama search) const result = await searchScripts({ mode: 'scripts', limit: 1000 }); const testScript = result.scripts.find(s => s.filename === testScriptName); expect(testScript).toBeDefined(); expect(testScript!.description).toContain('Test script'); expect(testScript!.description).toContain('searching pods'); }); it('extracts API classes from script content', async () => { // List all scripts and find by filename (more reliable than Orama search) const result = await searchScripts({ mode: 'scripts', limit: 1000 }); const testScript = result.scripts.find(s => s.filename === testScriptName); expect(testScript).toBeDefined(); // apiClasses comes from the library field in the document // For scripts with detected API classes, the first one is stored in library // The searchScripts helper maps library back to apiClasses // If CoreV1Api was detected, either apiClasses contains it or description mentions it const hasApiClassInfo = testScript!.apiClasses.includes('CoreV1Api') || testScript!.description.toLowerCase().includes('corev1api'); expect(hasApiClassInfo).toBe(true); }); it('does not expose file path for security', async () => { // List all scripts and find by filename const result = await searchScripts({ mode: 'scripts', limit: 1000 }); const testScript = result.scripts.find(s => s.filename === testScriptName); expect(testScript).toBeDefined(); // filePath should NOT be exposed to the agent - security measure expect((testScript as Record<string, unknown>).filePath).toBeUndefined(); }); }); describe('Scripts Mode Pagination', () => { it('respects limit parameter', async () => { const result = await searchScripts({ mode: 'scripts', limit: 2 }); expect(result.scripts.length).toBeLessThanOrEqual(2); }); it('respects offset parameter', async () => { const firstPage = await searchScripts({ mode: 'scripts', limit: 2, offset: 0 }); const secondPage = await searchScripts({ mode: 'scripts', limit: 2, offset: 2 }); expect(firstPage.pagination.offset).toBe(0); expect(secondPage.pagination.offset).toBe(2); // Note: We don't test for non-overlap between pages because Orama full-text search // doesn't guarantee deterministic ordering between separate search calls. // The pagination mechanics (offset/limit) are correctly applied within each call, // but the underlying search results may vary slightly between calls. }); it('sets hasMore correctly', async () => { const allScripts = await searchScripts({ mode: 'scripts', limit: 1000 }); if (allScripts.totalMatches > 2) { const limitedResult = await searchScripts({ mode: 'scripts', limit: 2 }); expect(limitedResult.pagination.hasMore).toBe(true); } }); it('pagination works with searchTerm', async () => { const result = await searchScripts({ mode: 'scripts', searchTerm: 'pod', limit: 2, offset: 0 }); expect(result.pagination.offset).toBe(0); expect(result.pagination.limit).toBe(2); }); }); describe('Scripts Mode Summary', () => { it('summary indicates total matches', async () => { const result = await searchScripts({ mode: 'scripts' }); // Summary format is "Showing X of Y results" or "Found X result(s)" expect(result.summary).toContain('result'); expect(result.totalMatches).toBeGreaterThanOrEqual(0); }); it('summary indicates search term when provided', async () => { const result = await searchScripts({ mode: 'scripts', searchTerm: 'pod' }); // The search term filters results - verify we get results expect(result.totalMatches).toBeGreaterThanOrEqual(0); // Scripts matching 'pod' should be found if any exist if (result.scripts.length > 0) { expect(result.scripts.some(s => s.filename.toLowerCase().includes('pod'))).toBe(true); } }); it('summary includes script details', async () => { const result = await searchScripts({ mode: 'scripts', limit: 5 }); if (result.scripts.length > 0) { // Should list script info in results (unified format shows (script) type marker) expect(result.summary).toContain('(script)'); // Should include run command info in usage expect(result.usage).toContain('runSandbox'); } }); it('summary includes pagination info when paginating', async () => { const allScripts = await searchScripts({ mode: 'scripts', limit: 1000 }); if (allScripts.totalMatches > 2) { const result = await searchScripts({ mode: 'scripts', limit: 2, offset: 2 }); // Summary shows "Showing X of Y results" format expect(result.summary).toContain('of'); expect(result.summary).toContain('results'); } }); it('returns scripts directory in paths', async () => { const result = await searchScripts({ mode: 'scripts' }); expect(result.paths.scriptsDirectory).toContain('.cache'); }); }); }); describe('prodisco.searchTools - Script Indexing', () => { describe('Script Indexing at Search Time', () => { it('scripts are indexed and searchable', async () => { // Search for scripts should work const result = await searchScripts({ mode: 'scripts' }); expect(result.totalMatches).toBeGreaterThanOrEqual(0); }); // Note: Scripts are indexed directly when saved via runSandbox.indexCacheEntry() // No filesystem watcher is needed since we control script creation. }); }); // Helper for prometheus mode - uses unified API const searchPrometheus = async (input: { mode: 'prometheus'; category?: string; // Accept any category string for generic extraction methodPattern?: string; limit?: number; offset?: number; }) => { // Use 'PrometheusDriver' as default query to find Prometheus methods when no pattern specified const result = await search({ methodName: input.methodPattern || 'PrometheusDriver', documentType: 'method', library: '@prodisco/prometheus-client', category: input.category !== 'all' ? input.category : undefined, limit: input.limit, offset: input.offset, }); return { mode: 'prometheus' as const, methods: result.results.map(r => ({ library: r.library, className: undefined, methodName: r.name, category: r.category, description: r.description, parameters: r.parameters || [], returnType: r.returnType || 'Promise<any>', example: r.example || '', })), totalMatches: result.totalMatches, libraries: { '@prodisco/prometheus-client': { installed: true, version: '3.3.2' }, }, paths: result.paths, facets: { library: result.facets.library, category: result.facets.category, }, pagination: result.pagination, }; }; describe('prodisco.searchTools - Prometheus Mode', () => { describe('Basic Prometheus Mode Functionality', () => { it('returns prometheus mode result with correct structure', async () => { const result = await searchPrometheus({ mode: 'prometheus' }); expect(result.mode).toBe('prometheus'); expect(result).toHaveProperty('methods'); expect(result).toHaveProperty('totalMatches'); expect(result).toHaveProperty('libraries'); expect(result).toHaveProperty('paths'); expect(result).toHaveProperty('facets'); expect(result).toHaveProperty('pagination'); expect(Array.isArray(result.methods)).toBe(true); }); it('lists all prometheus methods when no filters provided', async () => { const result = await searchPrometheus({ mode: 'prometheus', limit: 50 }); expect(result.totalMatches).toBeGreaterThan(0); expect(result.methods.length).toBeGreaterThan(0); }); it('includes paths.scriptsDirectory in result', async () => { const result = await searchPrometheus({ mode: 'prometheus' }); expect(result.paths.scriptsDirectory).toContain('.cache'); expect(result.paths.scriptsDirectory).toContain('scripts'); expect(result.paths.scriptsDirectory).toContain('cache'); }); it('returns facets for category', async () => { const result = await searchPrometheus({ mode: 'prometheus', limit: 50 }); expect(result.facets).toHaveProperty('category'); expect(Object.keys(result.facets.category).length).toBeGreaterThan(0); }); }); describe('Prometheus Mode Filtering', () => { it('can find execute methods by pattern search', async () => { // Generic extraction assigns 'other' category - use pattern search instead const result = await searchPrometheus({ mode: 'prometheus', methodPattern: 'execute', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.some(m => m.methodName.toLowerCase().includes('execute'))).toBe(true); }); it('can find metadata methods by pattern search', async () => { // Generic extraction assigns 'other' category - use pattern search instead const result = await searchPrometheus({ mode: 'prometheus', methodPattern: 'metadata', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.some(m => m.methodName.toLowerCase().includes('metadata'))).toBe(true); }); it('filters by methodPattern', async () => { const result = await searchPrometheus({ mode: 'prometheus', methodPattern: 'query', }); expect(result.methods.length).toBeGreaterThan(0); // Should find query in method name or description expect(result.methods.some(m => m.methodName.toLowerCase().includes('query') || m.description.toLowerCase().includes('query') )).toBe(true); }); }); describe('Prometheus Mode Method Details', () => { it('methods have correct structure', async () => { const result = await searchPrometheus({ mode: 'prometheus', limit: 1 }); expect(result.methods.length).toBeGreaterThan(0); const method = result.methods[0]; expect(method).toHaveProperty('library'); expect(method).toHaveProperty('methodName'); expect(method).toHaveProperty('category'); expect(method).toHaveProperty('description'); expect(method).toHaveProperty('parameters'); expect(method).toHaveProperty('returnType'); expect(method).toHaveProperty('example'); expect(Array.isArray(method.parameters)).toBe(true); }); it('finds execute and executeRange methods', async () => { // Use pattern search to find execute methods const result = await searchPrometheus({ mode: 'prometheus', methodPattern: 'execute', }); const methodNames = result.methods.map(m => m.methodName); expect(methodNames).toContain('execute'); expect(methodNames).toContain('executeRange'); }); it('all methods are from @prodisco/prometheus-client library', async () => { const result = await searchPrometheus({ mode: 'prometheus', limit: 50, }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.every(m => m.library === '@prodisco/prometheus-client')).toBe(true); }); }); describe('Prometheus Mode Pagination', () => { it('respects limit parameter', async () => { const result = await searchPrometheus({ mode: 'prometheus', limit: 5, }); expect(result.methods.length).toBeLessThanOrEqual(5); }); it('respects offset parameter', async () => { const firstPage = await searchPrometheus({ mode: 'prometheus', limit: 5, offset: 0, }); const secondPage = await searchPrometheus({ mode: 'prometheus', limit: 5, offset: 5, }); expect(firstPage.pagination.offset).toBe(0); expect(secondPage.pagination.offset).toBe(5); // Results should be different between pages if (firstPage.methods.length > 0 && secondPage.methods.length > 0) { const firstPageNames = firstPage.methods.map(m => m.methodName); const secondPageNames = secondPage.methods.map(m => m.methodName); const overlap = firstPageNames.filter(n => secondPageNames.includes(n)); expect(overlap.length).toBe(0); } }); it('sets hasMore correctly', async () => { const allMethods = await searchPrometheus({ mode: 'prometheus', limit: 100 }); if (allMethods.totalMatches > 5) { const limitedResult = await searchPrometheus({ mode: 'prometheus', limit: 5 }); expect(limitedResult.pagination.hasMore).toBe(true); } }); }); describe('Prometheus Mode - Alerts Category', () => { it('filters by alerts category', async () => { const result = await searchPrometheus({ mode: 'prometheus', category: 'alerts', }); // alerts category may have 0 or more methods expect(result.methods.every(m => m.category === 'alerts')).toBe(true); }); it('includes alerts and rules methods in alerts category', async () => { const result = await searchPrometheus({ mode: 'prometheus', category: 'alerts', limit: 50, }); // If there are alerts category methods, they should include alerts or rules if (result.methods.length > 0) { const methodNames = result.methods.map(m => m.methodName.toLowerCase()); expect( methodNames.some(n => n.includes('alert') || n.includes('rule')) ).toBe(true); } }); }); describe('Prometheus Mode Summary Content', () => { it('includes tip about metrics category when PROMETHEUS_URL is set', async () => { // Note: This test checks the summary format, actual behavior depends on env const result = await searchPrometheus({ mode: 'prometheus' }); // The result should have a summary (either in 'summary' or in error response) expect(result).toHaveProperty('mode', 'prometheus'); }); it('includes library information', async () => { const result = await searchPrometheus({ mode: 'prometheus' }); expect(result.libraries).toHaveProperty('@prodisco/prometheus-client'); expect(result.libraries['@prodisco/prometheus-client']).toHaveProperty('installed'); expect(result.libraries['@prodisco/prometheus-client']).toHaveProperty('version'); }); }); }); // Helper for analytics mode - uses unified API with function document type const searchAnalytics = async (input: { mode: 'analytics'; library?: 'simple-statistics' | 'all'; functionPattern?: string; limit?: number; offset?: number; }) => { const result = await search({ methodName: input.functionPattern, documentType: 'function', library: input.library !== 'all' ? input.library : undefined, limit: input.limit, offset: input.offset, }); return { mode: 'analytics' as const, summary: result.summary, functions: result.results.map(r => ({ library: r.library as 'simple-statistics', functionName: r.name, category: r.category, description: r.description, signature: `${r.name}(${(r.parameters || []).map(p => p.name).join(', ')})`, parameters: r.parameters || [], returnType: r.returnType || 'any', example: r.example || '', })), totalMatches: result.totalMatches, libraries: { 'simple-statistics': { installed: true, version: '7.8.8', description: 'Simple statistics library' }, }, usage: result.usage, paths: result.paths, facets: { library: result.facets.library, category: result.facets.category, }, pagination: result.pagination, }; }; describe('prodisco.searchTools - Analytics Mode', () => { describe('Basic Analytics Mode Functionality', () => { it('returns analytics mode result with correct structure', async () => { const result = await searchAnalytics({ mode: 'analytics' }); expect(result.mode).toBe('analytics'); expect(result).toHaveProperty('summary'); expect(result).toHaveProperty('functions'); expect(result).toHaveProperty('totalMatches'); expect(result).toHaveProperty('libraries'); expect(result).toHaveProperty('usage'); expect(result).toHaveProperty('paths'); expect(result).toHaveProperty('facets'); expect(result).toHaveProperty('pagination'); expect(typeof result.summary).toBe('string'); expect(Array.isArray(result.functions)).toBe(true); }); it('finds analytics functions when search term provided', async () => { // Orama full-text search requires a search term to return results const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 50 }); expect(result.totalMatches).toBeGreaterThan(0); expect(result.functions.length).toBeGreaterThan(0); }); it('includes paths.scriptsDirectory in result', async () => { const result = await searchAnalytics({ mode: 'analytics' }); expect(result.paths.scriptsDirectory).toContain('.cache'); expect(result.paths.scriptsDirectory).toContain('scripts'); }); it('returns library information', async () => { const result = await searchAnalytics({ mode: 'analytics' }); expect(result.libraries).toHaveProperty('simple-statistics'); expect(result.libraries['simple-statistics'].installed).toBe(true); }); it('returns facets for library and category', async () => { // Provide search term to get results and facets const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 50 }); expect(result.facets).toHaveProperty('library'); expect(result.facets).toHaveProperty('category'); // Facets are populated from search results expect(Object.keys(result.facets.library).length).toBeGreaterThanOrEqual(0); expect(Object.keys(result.facets.category).length).toBeGreaterThanOrEqual(0); }); }); describe('Analytics Mode Library Filtering', () => { it('filters by simple-statistics library', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', limit: 50, }); // simple-statistics functions are dynamically extracted from .d.ts files // If extraction succeeds, we get functions; otherwise we may get none expect(result.functions.every(f => f.library === 'simple-statistics')).toBe(true); }); it('returns results when searching broadly', async () => { // Search with a broad term that might match multiple libraries const result = await searchAnalytics({ mode: 'analytics', library: 'all', functionPattern: 'mean', // simple-statistics has mean function limit: 100, }); // With search term, we get results from matching libraries expect(result.functions.length).toBeGreaterThanOrEqual(0); }); }); describe('Analytics Mode Function Pattern Filtering', () => { it('filters by functionPattern', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', }); expect(result.functions.length).toBeGreaterThan(0); // Should find mean in function name or description expect(result.functions.some(f => f.functionName.toLowerCase().includes('mean') || f.description.toLowerCase().includes('mean') )).toBe(true); }); it('combined library and functionPattern filtering', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', functionPattern: 'standard', }); // Should only have simple-statistics functions expect(result.functions.every(f => f.library === 'simple-statistics')).toBe(true); // Should match the pattern if (result.functions.length > 0) { expect(result.functions.some(f => f.functionName.toLowerCase().includes('standard') || f.description.toLowerCase().includes('standard') )).toBe(true); } }); it('returns empty results for non-matching pattern', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'xyznonexistent12345', }); expect(result.functions.length).toBe(0); expect(result.totalMatches).toBe(0); }); }); describe('Analytics Mode Function Details', () => { it('functions have correct structure', async () => { // Provide search term to get results const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 1 }); expect(result.functions.length).toBeGreaterThan(0); const func = result.functions[0]; expect(func).toHaveProperty('library'); expect(func).toHaveProperty('functionName'); expect(func).toHaveProperty('category'); expect(func).toHaveProperty('description'); expect(func).toHaveProperty('signature'); expect(func).toHaveProperty('parameters'); expect(func).toHaveProperty('returnType'); expect(func).toHaveProperty('example'); expect(Array.isArray(func.parameters)).toBe(true); }); it('finds mean function from simple-statistics', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', functionPattern: 'mean', limit: 50, }); // The function name could be 'mean' or contain 'mean' in name/description if (result.functions.length > 0) { expect(result.functions.some(f => f.functionName.toLowerCase().includes('mean') || f.description.toLowerCase().includes('mean') )).toBe(true); } }); it('functions have example field', async () => { // Provide search term to get results const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 5 }); expect(result.functions.length).toBeGreaterThan(0); for (const func of result.functions) { expect(func.example).toBeDefined(); expect(typeof func.example).toBe('string'); // With generic extraction, example is an empty string by default } }); it('functions have valid signatures', async () => { // Provide search term to get results const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 5 }); expect(result.functions.length).toBeGreaterThan(0); for (const func of result.functions) { expect(func.signature).toBeDefined(); expect(typeof func.signature).toBe('string'); // Signatures should contain the function name expect(func.signature).toContain(func.functionName); } }); }); describe('Analytics Mode Pagination', () => { it('respects limit parameter', async () => { const result = await searchAnalytics({ mode: 'analytics', limit: 5, }); expect(result.functions.length).toBeLessThanOrEqual(5); }); it('respects offset parameter', async () => { const firstPage = await searchAnalytics({ mode: 'analytics', limit: 5, offset: 0, }); const secondPage = await searchAnalytics({ mode: 'analytics', limit: 5, offset: 5, }); expect(firstPage.pagination.offset).toBe(0); expect(secondPage.pagination.offset).toBe(5); // Results should be different between pages if (firstPage.functions.length > 0 && secondPage.functions.length > 0) { const firstPageNames = firstPage.functions.map(f => `${f.library}.${f.functionName}`); const secondPageNames = secondPage.functions.map(f => `${f.library}.${f.functionName}`); const overlap = firstPageNames.filter(n => secondPageNames.includes(n)); expect(overlap.length).toBe(0); } }); it('sets hasMore correctly', async () => { // Must provide functionPattern since Orama requires a search term const allFunctions = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 100 }); if (allFunctions.totalMatches > 5) { const limitedResult = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 5 }); expect(limitedResult.pagination.hasMore).toBe(true); } }); it('pagination works with library filter', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', limit: 5, offset: 0, }); expect(result.pagination.offset).toBe(0); expect(result.pagination.limit).toBe(5); // All results should be from simple-statistics expect(result.functions.every(f => f.library === 'simple-statistics')).toBe(true); }); it('pagination works with functionPattern filter', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'regression', limit: 3, offset: 0, }); expect(result.pagination.offset).toBe(0); expect(result.pagination.limit).toBe(3); }); it('returns empty results when offset exceeds total', async () => { const result = await searchAnalytics({ mode: 'analytics', limit: 5, offset: 10000, }); expect(result.functions.length).toBe(0); expect(result.pagination.hasMore).toBe(false); }); it('totalMatches reflects total filtered results, not page size', async () => { const limit = 3; const result = await searchAnalytics({ mode: 'analytics', limit, }); // totalMatches should be >= the number of functions returned expect(result.totalMatches).toBeGreaterThanOrEqual(result.functions.length); // If there are more results, totalMatches should be greater than limit if (result.pagination.hasMore) { expect(result.totalMatches).toBeGreaterThan(limit); } }); }); describe('Analytics Mode Categories', () => { it('includes descriptive category functions', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', functionPattern: 'mean', // Provide search term limit: 50, }); // If simple-statistics extraction succeeds, should have descriptive category if (result.functions.length > 0) { const categories = new Set(result.functions.map(f => f.category)); expect(categories.has('descriptive')).toBe(true); } }); it('facets include category counts', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', // Provide search term limit: 100, }); // Facets are populated when there are search results if (result.functions.length > 0) { expect(Object.keys(result.facets.category).length).toBeGreaterThan(0); // Each facet value should be a number > 0 for (const count of Object.values(result.facets.category)) { expect(typeof count).toBe('number'); expect(count).toBeGreaterThan(0); } } }); }); describe('Analytics Mode Summary Content', () => { it('summary includes function count', async () => { // Provide search term to get results const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', limit: 5 }); expect(result.summary).toContain('function'); }); it('summary includes library info when results found', async () => { // Provide search term to get results const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean' }); // The summary should contain results information expect(result.summary).toBeDefined(); expect(typeof result.summary).toBe('string'); }); it('usage contains helpful instructions', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean' }); expect(result.usage).toContain('USAGE'); expect(result.usage).toContain('require'); }); it('usage mentions runSandbox', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean' }); expect(result.usage.toLowerCase()).toContain('sandbox'); }); }); describe('Analytics Mode - Specific Functions', () => { it('searches for variance-related functions from simple-statistics', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', functionPattern: 'variance', limit: 50, }); // If found, should match variance pattern if (result.functions.length > 0) { expect(result.functions.some(f => f.functionName.toLowerCase().includes('variance') || f.description.toLowerCase().includes('variance') )).toBe(true); } }); it('searches for standardDeviation-related functions from simple-statistics', async () => { const result = await searchAnalytics({ mode: 'analytics', library: 'simple-statistics', functionPattern: 'standard', limit: 100, }); // If found, should match standard deviation pattern if (result.functions.length > 0) { expect(result.functions.some(f => f.functionName.toLowerCase().includes('standard') || f.description.toLowerCase().includes('standard') )).toBe(true); } }); }); describe('Analytics Mode Edge Cases', () => { it('handles case-insensitive pattern search', async () => { const lowerResult = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', }); const upperResult = await searchAnalytics({ mode: 'analytics', functionPattern: 'MEAN', }); expect(lowerResult.totalMatches).toBe(upperResult.totalMatches); }); it('handles partial pattern matches', async () => { const result = await searchAnalytics({ mode: 'analytics', functionPattern: 'mea', // Partial match for 'mean' }); // Should find mean-related functions with partial match expect(result.functions.length).toBeGreaterThanOrEqual(0); }); it('handles empty library filter with pattern', async () => { const withAll = await searchAnalytics({ mode: 'analytics', library: 'all', functionPattern: 'mean', }); const withoutLibrary = await searchAnalytics({ mode: 'analytics', functionPattern: 'mean', }); expect(withAll.totalMatches).toBe(withoutLibrary.totalMatches); }); }); }); // Helper for loki mode - uses unified API const searchLoki = async (input: { mode: 'loki'; lokiCategory?: string; // Accept any category string for generic extraction lokiMethodPattern?: string; limit?: number; offset?: number; }) => { // Use 'LokiClient' as default query to find Loki methods when no pattern specified const result = await search({ methodName: input.lokiMethodPattern || 'LokiClient', documentType: 'method', library: '@prodisco/loki-client', category: input.lokiCategory !== 'all' ? input.lokiCategory : undefined, limit: input.limit, offset: input.offset, }); return { mode: 'loki' as const, summary: result.summary, methods: result.results.map(r => ({ library: '@prodisco/loki-client' as const, className: 'LokiClient', methodName: r.name, category: r.category, description: r.description, parameters: r.parameters || [], returnType: r.returnType || 'Promise<any>', example: r.example || '', })), totalMatches: result.totalMatches, libraries: { '@prodisco/loki-client': { installed: true, version: '7.0.0' }, '@sigyn/logql': { installed: true, version: '2.0.0' }, }, paths: result.paths, facets: { library: result.facets.library, category: result.facets.category, }, pagination: result.pagination, usage: result.usage, }; }; describe('prodisco.searchTools - Loki Mode', () => { describe('Basic Loki Mode Functionality', () => { it('returns loki mode result with correct structure', async () => { const result = await searchLoki({ mode: 'loki' }); expect(result.mode).toBe('loki'); expect(result).toHaveProperty('summary'); expect(result).toHaveProperty('methods'); expect(result).toHaveProperty('totalMatches'); expect(result).toHaveProperty('libraries'); expect(result).toHaveProperty('paths'); expect(result).toHaveProperty('facets'); expect(result).toHaveProperty('pagination'); expect(typeof result.summary).toBe('string'); expect(Array.isArray(result.methods)).toBe(true); }); it('lists all loki methods when no filters provided', async () => { const result = await searchLoki({ mode: 'loki', limit: 50 }); expect(result.totalMatches).toBeGreaterThan(0); expect(result.methods.length).toBeGreaterThan(0); }); it('includes paths.scriptsDirectory in result', async () => { const result = await searchLoki({ mode: 'loki' }); expect(result.paths.scriptsDirectory).toContain('.cache'); expect(result.paths.scriptsDirectory).toContain('scripts'); }); it('returns library information', async () => { const result = await searchLoki({ mode: 'loki' }); expect(result.libraries).toHaveProperty('@prodisco/loki-client'); expect(result.libraries).toHaveProperty('@sigyn/logql'); expect(result.libraries['@prodisco/loki-client'].installed).toBe(true); expect(result.libraries['@sigyn/logql'].installed).toBe(true); }); it('returns facets for library and category', async () => { const result = await searchLoki({ mode: 'loki', limit: 50 }); expect(result.facets).toHaveProperty('library'); expect(result.facets).toHaveProperty('category'); expect(Object.keys(result.facets.library).length).toBeGreaterThan(0); expect(Object.keys(result.facets.category).length).toBeGreaterThan(0); }); }); describe('Loki Mode Category Filtering', () => { it('filters by query category', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.every(m => m.category === 'query')).toBe(true); }); it('filters by other category (includes labels methods)', async () => { // Generic extraction assigns 'other' category to labels methods const result = await searchLoki({ mode: 'loki', lokiCategory: 'other', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.every(m => m.category === 'other')).toBe(true); }); it('filters by read category (includes ready method)', async () => { // Generic extraction assigns 'read' category to ready method const result = await searchLoki({ mode: 'loki', lokiCategory: 'read', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.every(m => m.category === 'read')).toBe(true); }); it('returns multiple categories when lokiCategory is "all"', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'all', limit: 50, }); // With generic extraction, we should still get methods with various categories expect(result.methods.length).toBeGreaterThan(0); }); it('returns methods when no lokiCategory specified', async () => { const result = await searchLoki({ mode: 'loki', limit: 50, }); expect(result.methods.length).toBeGreaterThan(0); }); }); describe('Loki Mode Method Pattern Filtering', () => { it('filters by lokiMethodPattern', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'query', }); expect(result.methods.length).toBeGreaterThan(0); // Should find query in method name or description expect(result.methods.some(m => m.methodName.toLowerCase().includes('query') || m.description.toLowerCase().includes('query') )).toBe(true); }); it('finds labels methods by pattern', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'label', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.some(m => m.methodName.toLowerCase().includes('label') || m.description.toLowerCase().includes('label') )).toBe(true); }); it('finds series method by pattern', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'series', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.some(m => m.methodName.toLowerCase().includes('series') || m.description.toLowerCase().includes('series') )).toBe(true); }); it('finds ready method by pattern', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'ready', }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.some(m => m.methodName.toLowerCase().includes('ready') || m.description.toLowerCase().includes('ready') )).toBe(true); }); it('combined category and lokiMethodPattern filtering', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', lokiMethodPattern: 'range', }); // Should only have query category methods expect(result.methods.every(m => m.category === 'query')).toBe(true); // Should match the pattern if (result.methods.length > 0) { expect(result.methods.some(m => m.methodName.toLowerCase().includes('range') || m.description.toLowerCase().includes('range') )).toBe(true); } }); it('returns empty results for non-matching pattern', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'xyznonexistent12345', }); expect(result.methods.length).toBe(0); expect(result.totalMatches).toBe(0); }); }); describe('Loki Mode Method Details', () => { it('methods have correct structure', async () => { const result = await searchLoki({ mode: 'loki', limit: 1 }); expect(result.methods.length).toBeGreaterThan(0); const method = result.methods[0]; expect(method).toHaveProperty('library'); expect(method).toHaveProperty('methodName'); expect(method).toHaveProperty('category'); expect(method).toHaveProperty('description'); expect(method).toHaveProperty('parameters'); expect(method).toHaveProperty('returnType'); expect(method).toHaveProperty('example'); expect(Array.isArray(method.parameters)).toBe(true); }); it('finds queryRange method', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', limit: 50, }); const queryRange = result.methods.find(m => m.methodName === 'queryRange'); expect(queryRange).toBeDefined(); expect(queryRange?.library).toBe('@prodisco/loki-client'); expect(queryRange?.category).toBe('query'); }); it('finds series method via pattern search', async () => { // Use pattern search since 'series' has category 'query' in generic extraction const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'series', limit: 50, }); const method = result.methods.find(m => m.methodName === 'series'); expect(method).toBeDefined(); expect(method?.library).toBe('@prodisco/loki-client'); }); it('finds queryRangeMatrix method', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', limit: 50, }); const method = result.methods.find(m => m.methodName === 'queryRangeMatrix'); expect(method).toBeDefined(); expect(method?.library).toBe('@prodisco/loki-client'); }); it('finds labels method', async () => { // Use pattern search (generic extraction assigns 'other' category) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'labels', limit: 50, }); const method = result.methods.find(m => m.methodName === 'labels'); expect(method).toBeDefined(); expect(method?.category).toBe('other'); // Generic extraction assigns 'other' category }); it('finds labelValues method', async () => { // Use pattern search (generic extraction assigns 'other' category) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'labelValues', limit: 50, }); const method = result.methods.find(m => m.methodName === 'labelValues'); expect(method).toBeDefined(); expect(method?.category).toBe('other'); // Generic extraction assigns 'other' category }); it('finds series method', async () => { // Use pattern search to find series method (generic extraction assigns 'other' category) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'series', limit: 50, }); const method = result.methods.find(m => m.methodName === 'series'); expect(method).toBeDefined(); }); it('finds ready method', async () => { // Use pattern search to find ready method (generic extraction assigns 'read' category) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'ready', limit: 50, }); const method = result.methods.find(m => m.methodName === 'ready'); expect(method).toBeDefined(); expect(method?.category).toBe('read'); // Generic extraction assigns 'read' category }); it('all methods are from @prodisco/loki-client library', async () => { const result = await searchLoki({ mode: 'loki', limit: 50, }); expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.every(m => m.library === '@prodisco/loki-client')).toBe(true); }); it('methods have example field', async () => { const result = await searchLoki({ mode: 'loki', limit: 5 }); expect(result.methods.length).toBeGreaterThan(0); for (const method of result.methods) { expect(method.example).toBeDefined(); expect(typeof method.example).toBe('string'); // With generic extraction, example is an empty string by default } }); it('methods have valid parameters', async () => { const result = await searchLoki({ mode: 'loki', limit: 5 }); expect(result.methods.length).toBeGreaterThan(0); for (const method of result.methods) { expect(Array.isArray(method.parameters)).toBe(true); for (const param of method.parameters) { expect(param).toHaveProperty('name'); expect(param).toHaveProperty('type'); expect(param).toHaveProperty('optional'); expect(typeof param.name).toBe('string'); expect(typeof param.type).toBe('string'); expect(typeof param.optional).toBe('boolean'); } } }); it('methods have valid return types', async () => { const result = await searchLoki({ mode: 'loki', limit: 5 }); expect(result.methods.length).toBeGreaterThan(0); for (const method of result.methods) { expect(method.returnType).toBeDefined(); expect(typeof method.returnType).toBe('string'); expect(method.returnType.length).toBeGreaterThan(0); } }); }); describe('Loki Mode Pagination', () => { it('respects limit parameter', async () => { const result = await searchLoki({ mode: 'loki', limit: 3, }); expect(result.methods.length).toBeLessThanOrEqual(3); }); it('respects offset parameter', async () => { const firstPage = await searchLoki({ mode: 'loki', limit: 3, offset: 0, }); const secondPage = await searchLoki({ mode: 'loki', limit: 3, offset: 3, }); expect(firstPage.pagination.offset).toBe(0); expect(secondPage.pagination.offset).toBe(3); // Results should be different between pages if (firstPage.methods.length > 0 && secondPage.methods.length > 0) { const firstPageNames = firstPage.methods.map(m => m.methodName); const secondPageNames = secondPage.methods.map(m => m.methodName); const overlap = firstPageNames.filter(n => secondPageNames.includes(n)); expect(overlap.length).toBe(0); } }); it('sets hasMore correctly when more results exist', async () => { const allMethods = await searchLoki({ mode: 'loki', limit: 100 }); if (allMethods.totalMatches > 3) { const limitedResult = await searchLoki({ mode: 'loki', limit: 3 }); expect(limitedResult.pagination.hasMore).toBe(true); } }); it('sets hasMore to false on last page', async () => { // Request all methods at once const result = await searchLoki({ mode: 'loki', limit: 100 }); if (result.methods.length === result.totalMatches) { expect(result.pagination.hasMore).toBe(false); } }); it('returns empty results when offset exceeds total', async () => { const result = await searchLoki({ mode: 'loki', limit: 5, offset: 10000, }); expect(result.methods.length).toBe(0); expect(result.pagination.hasMore).toBe(false); }); it('totalMatches reflects total filtered results, not page size', async () => { const limit = 2; const result = await searchLoki({ mode: 'loki', limit, }); // totalMatches should be >= the number of methods returned expect(result.totalMatches).toBeGreaterThanOrEqual(result.methods.length); // If there are more results, totalMatches should be greater than limit if (result.pagination.hasMore) { expect(result.totalMatches).toBeGreaterThan(limit); } }); it('pagination works with category filter', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', limit: 2, offset: 0, }); expect(result.pagination.offset).toBe(0); expect(result.pagination.limit).toBe(2); // All results should be from query category expect(result.methods.every(m => m.category === 'query')).toBe(true); }); it('pagination works with lokiMethodPattern filter', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'query', limit: 2, offset: 0, }); expect(result.pagination.offset).toBe(0); expect(result.pagination.limit).toBe(2); }); }); describe('Loki Mode Summary Content', () => { it('summary includes result count', async () => { const result = await searchLoki({ mode: 'loki', limit: 5 }); // Summary format: "Showing N of M results." expect(result.summary).toContain('results'); }); it('summary includes loki library info', async () => { const result = await searchLoki({ mode: 'loki' }); // The summary should contain results from loki library expect(result.summary.toLowerCase()).toContain('loki'); }); it('returns methods for API discovery', async () => { const result = await searchLoki({ mode: 'loki' }); // Should return methods for discovery expect(result.methods.length).toBeGreaterThan(0); }); it('includes loki-client in results', async () => { const result = await searchLoki({ mode: 'loki' }); // With generic extraction, summary contains method details including library expect(result.summary.toLowerCase()).toContain('loki'); }); it('usage includes sandbox instructions', async () => { const result = await searchLoki({ mode: 'loki' }); expect(result.usage).toContain('runSandbox'); }); it('summary includes action filter when specified', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', }); expect(result.summary.toLowerCase()).toContain('query'); }); it('summary includes results for pattern filter when specified', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'range', }); // Should find methods matching 'range' pattern expect(result.methods.length).toBeGreaterThan(0); expect(result.methods.some(m => m.methodName.toLowerCase().includes('range'))).toBe(true); }); }); describe('Loki Mode Without LOKI_URL', () => { it('returns methods for discovery when LOKI_URL not set', async () => { const originalUrl = process.env.LOKI_URL; // Temporarily unset for this test delete process.env.LOKI_URL; try { const result = await searchLoki({ mode: 'loki' }); // Should still return methods for discovery expect(result.methods.length).toBeGreaterThan(0); // Methods should be from @prodisco/loki-client expect(result.methods.every(m => m.library === '@prodisco/loki-client')).toBe(true); } finally { // Restore original value if (originalUrl) { process.env.LOKI_URL = originalUrl; } } }); it('still returns methods even without LOKI_URL', async () => { const result = await searchLoki({ mode: 'loki' }); // Methods should always be returned for API discovery expect(result.methods.length).toBeGreaterThan(0); }); it('error result has correct structure', async () => { const originalUrl = process.env.LOKI_URL; // Temporarily unset for this test delete process.env.LOKI_URL; try { const result = await searchLoki({ mode: 'loki' }); // Error result should still have standard fields expect(result).toHaveProperty('mode', 'loki'); expect(result).toHaveProperty('methods'); expect(result).toHaveProperty('totalMatches'); expect(result).toHaveProperty('libraries'); expect(result).toHaveProperty('pagination'); } finally { // Restore original value if (originalUrl) { process.env.LOKI_URL = originalUrl; } } }); }); describe('Loki Mode Categories', () => { it('query category includes log query methods', async () => { const result = await searchLoki({ mode: 'loki', lokiCategory: 'query', limit: 50, }); const methodNames = result.methods.map(m => m.methodName); expect(methodNames).toContain('queryRange'); expect(methodNames).toContain('queryRangeMatrix'); // series is categorized as 'other' in generic extraction }); it('can find labels method by pattern search', async () => { // Generic extraction assigns 'other' category - use pattern search instead const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'label', limit: 50, }); const methodNames = result.methods.map(m => m.methodName); expect(methodNames).toContain('labels'); expect(methodNames).toContain('labelValues'); }); it('can find ready method by pattern search', async () => { // Generic extraction assigns 'read' category - use pattern search instead const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'ready', limit: 50, }); const methodNames = result.methods.map(m => m.methodName); expect(methodNames).toContain('ready'); }); it('facets include category counts', async () => { const result = await searchLoki({ mode: 'loki', limit: 100, }); expect(Object.keys(result.facets.category).length).toBeGreaterThan(0); // Each facet value should be a number > 0 for (const count of Object.values(result.facets.category)) { expect(typeof count).toBe('number'); expect(count).toBeGreaterThan(0); } }); it('facets include category names from generic extraction', async () => { const result = await searchLoki({ mode: 'loki', limit: 100, }); const categoryNames = Object.keys(result.facets.category); // With generic extraction, category names are different (query, other, read, etc.) expect(categoryNames.length).toBeGreaterThan(0); }); }); describe('Loki Mode Edge Cases', () => { it('handles case-insensitive pattern search', async () => { const lowerResult = await searchLoki({ mode: 'loki', lokiMethodPattern: 'query', }); const upperResult = await searchLoki({ mode: 'loki', lokiMethodPattern: 'QUERY', }); expect(lowerResult.totalMatches).toBe(upperResult.totalMatches); }); it('handles partial pattern matches', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'rang', }); // Should find queryRange methods expect(result.methods.length).toBeGreaterThan(0); }); it('handles all category with pattern', async () => { const withAll = await searchLoki({ mode: 'loki', lokiCategory: 'all', lokiMethodPattern: 'query', }); const withoutCategory = await searchLoki({ mode: 'loki', lokiMethodPattern: 'query', }); expect(withAll.totalMatches).toBe(withoutCategory.totalMatches); }); it('methods have className for Loki API', async () => { const result = await searchLoki({ mode: 'loki', limit: 5, }); expect(result.methods.length).toBeGreaterThan(0); // All methods should have className 'LokiClient' expect(result.methods.every(m => m.className === 'LokiClient')).toBe(true); }); }); describe('Loki Mode - Specific Method Details', () => { it('queryRange method has parameters', async () => { const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'queryRange', limit: 50, }); const method = result.methods.find(m => m.methodName === 'queryRange'); expect(method).toBeDefined(); // With generic extraction, parameters are extracted from .d.ts files expect(method?.parameters).toBeDefined(); }); it('labels method has correct description', async () => { // Use method pattern instead of category filter (generic extraction uses different categories) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'labels', limit: 50, }); const method = result.methods.find(m => m.methodName === 'labels'); expect(method).toBeDefined(); expect(method?.description.toLowerCase()).toContain('label'); }); it('labelValues method has parameters', async () => { // Use method pattern instead of category filter (generic extraction uses different categories) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'labelValues', limit: 50, }); const method = result.methods.find(m => m.methodName === 'labelValues'); expect(method).toBeDefined(); // With generic extraction, parameters are extracted from .d.ts files expect(method?.parameters).toBeDefined(); }); it('ready method exists', async () => { // Use method pattern instead of category filter (generic extraction uses different categories) const result = await searchLoki({ mode: 'loki', lokiMethodPattern: 'ready', limit: 50, }); const method = result.methods.find(m => m.methodName === 'ready'); expect(method).toBeDefined(); // With generic extraction, parameters are extracted from .d.ts files expect(method?.parameters).toBeDefined(); }); }); describe('Loki Mode - Usage Instructions', () => { it('includes usage instructions when LOKI_URL is set', async () => { const originalUrl = process.env.LOKI_URL; // Set LOKI_URL for this test process.env.LOKI_URL = 'http://localhost:3100'; try { const result = await searchLoki({ mode: 'loki' }); expect(result.usage).toBeDefined(); expect(result.usage).toContain('USAGE'); // With unified API, usage contains library imports including loki expect(result.usage.toLowerCase()).toContain('loki'); } finally { // Restore original value if (originalUrl) { process.env.LOKI_URL = originalUrl; } else { delete process.env.LOKI_URL; } } }); it('results include loki library methods', async () => { const result = await searchLoki({ mode: 'loki' }); // Results should include @prodisco/loki-client methods expect(result.summary).toContain('@prodisco/loki-client'); expect(result.methods.length).toBeGreaterThan(0); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/harche/ProDisco'

If you have feedback or need assistance with the MCP directory API, please join our Discord server