Skip to main content
Glama
integration-utils.ts10.9 kB
/** * Shared integration test utilities for flint-note MCP server testing * * Provides common functions for server lifecycle management, workspace setup, * and MCP protocol communication testing. */ import { promises as fs } from 'node:fs'; import { constants } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawn, type ChildProcess } from 'node:child_process'; import { platform } from 'node:os'; import { createRequire } from 'node:module'; /** * Integration test context containing server process and workspace info */ export interface IntegrationTestContext { tempDir: string; serverProcess?: ChildProcess; } /** * Server startup options */ export interface ServerStartupOptions { workspacePath: string; timeout?: number; env?: Record<string, string>; } /** * Creates a unique temporary directory name for integration tests */ export function createTempDirName(prefix = 'flint-note-integration'): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); return join(tmpdir(), `${prefix}-${timestamp}-${random}`); } /** * Creates a temporary workspace for integration testing */ export async function createIntegrationWorkspace( prefix?: string ): Promise<IntegrationTestContext> { const tempDir = createTempDirName(prefix); await fs.mkdir(tempDir, { recursive: true }); // Create basic workspace structure await fs.mkdir(join(tempDir, 'general'), { recursive: true }); await fs.mkdir(join(tempDir, '.flint-note'), { recursive: true }); return { tempDir }; } /** * Cleans up integration test workspace and kills any running server processes */ export async function cleanupIntegrationWorkspace( context: IntegrationTestContext ): Promise<void> { // Kill server process if it's still running if (context.serverProcess && !context.serverProcess.killed) { context.serverProcess.kill('SIGTERM'); // Wait a bit for graceful shutdown await new Promise(resolve => setTimeout(resolve, 100)); // Force kill if still running if (!context.serverProcess.killed) { context.serverProcess.kill('SIGKILL'); } } // Clean up temporary directory try { await fs.rm(context.tempDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } } /** * Gets the command to run tsx in a cross-platform way */ async function getTsxCommand(): Promise<{ command: string; args: string[] }> { const isWindows = platform() === 'win32'; // First try to use the tsx module entry point directly with node try { const require = createRequire(import.meta.url); const tsxEntryPoint = require.resolve('tsx/cli'); // Validate that the entry point exists await fs.access(tsxEntryPoint, constants.F_OK); return { command: process.execPath, // Use current Node.js executable args: [tsxEntryPoint] }; } catch { // Fallback: try to find tsx in node_modules/.bin const tsxBin = join( process.cwd(), 'node_modules', '.bin', isWindows ? 'tsx.cmd' : 'tsx' ); try { await fs.access(tsxBin, constants.F_OK); return { command: tsxBin, args: [] }; } catch { // On Windows, try additional variants if (isWindows) { const variants = [ join(process.cwd(), 'node_modules', '.bin', 'tsx.exe'), join(process.cwd(), 'node_modules', '.bin', 'tsx'), join(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs') ]; for (const variant of variants) { try { await fs.access(variant, constants.F_OK); return { command: variant, args: [] }; } catch { // Continue to next variant } } // Final Windows fallback return { command: 'npm.cmd', args: ['exec', 'tsx', '--'] }; } else { // Unix fallback return { command: 'npx', args: ['tsx'] }; } } } } /** * Starts the MCP server as a child process */ export async function startServer(options: ServerStartupOptions): Promise<ChildProcess> { const { workspacePath, timeout = 15000, env = {} } = options; const serverPath = join(process.cwd(), 'src', 'index.ts'); const { command, args } = await getTsxCommand(); return new Promise((resolve, reject) => { const fullArgs = [...args, serverPath, '--workspace', workspacePath]; const serverProcess = spawn(command, fullArgs, { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], shell: platform() === 'win32' // Use shell on Windows for better compatibility }); let startupOutput = ''; let errorOutput = ''; let hasStarted = false; // Set a timeout for server startup const startupTimeout = setTimeout(() => { if (!hasStarted) { serverProcess.kill('SIGKILL'); reject( new Error( `Server failed to start within ${timeout}ms. Stdout: ${startupOutput}, Stderr: ${errorOutput}` ) ); } }, timeout); // Listen for server output serverProcess.stdout?.on('data', data => { startupOutput += data.toString(); }); // Listen for server startup message serverProcess.stderr?.on('data', data => { const output = data.toString(); errorOutput += output; if (output.includes('Flint Note MCP server running on stdio')) { hasStarted = true; clearTimeout(startupTimeout); resolve(serverProcess); } }); // Handle server errors serverProcess.on('error', error => { clearTimeout(startupTimeout); const errorMessage = `Failed to start server: ${error.message}. Command: ${command} ${fullArgs.join(' ')}. Platform: ${platform()}. Errno: ${(error as any).errno || 'unknown'}. Code: ${(error as any).code || 'unknown'}`; reject(new Error(errorMessage)); }); // Handle unexpected server exit serverProcess.on('exit', (code, signal) => { clearTimeout(startupTimeout); if (!hasStarted) { reject( new Error( `Server exited unexpectedly with code ${code}, signal ${signal}. Stdout: ${startupOutput}, Stderr: ${errorOutput}` ) ); } }); }); } /** * Spawns tsx with the given arguments using the same resolution logic as startServer */ export async function spawnTsxCommand( args: string[], options: { stdio?: ('pipe' | 'ignore' | 'inherit')[]; env?: Record<string, string>; } = {} ): Promise<import('child_process').ChildProcess> { const { command, args: tsxArgs } = await getTsxCommand(); const fullArgs = [...tsxArgs, ...args]; const { stdio = ['pipe', 'pipe', 'pipe'], env = {} } = options; return spawn(command, fullArgs, { env: { ...process.env, ...env }, stdio, shell: platform() === 'win32' // Use shell on Windows for better compatibility }); } /** * Gracefully stops a server process */ export async function stopServer( serverProcess: ChildProcess, timeout = 2000 ): Promise<void> { return new Promise<void>((resolve, reject) => { if (!serverProcess || serverProcess.killed) { resolve(); return; } const shutdownTimeout = setTimeout(() => { // Force kill if graceful shutdown failed if (!serverProcess.killed) { serverProcess.kill('SIGKILL'); } reject(new Error(`Server failed to shutdown gracefully within ${timeout}ms`)); }, timeout); serverProcess.on('exit', () => { clearTimeout(shutdownTimeout); resolve(); }); // Send SIGTERM for graceful shutdown serverProcess.kill('SIGTERM'); }); } /** * Creates test notes in a workspace for integration testing */ export async function createIntegrationTestNotes(workspacePath: string): Promise<void> { // Create a simple test note const testNote1 = `# Test Note 1 This is a test note for integration testing. ## Content Sample content for testing search and retrieval functionality. `; await fs.writeFile(join(workspacePath, 'general', 'test-note-1.md'), testNote1, 'utf8'); // Create a note with metadata const testNote2 = `--- title: "Integration Test Note" tags: ["integration", "testing"] created: "2024-01-01T00:00:00Z" --- # Integration Test Note This note contains metadata for testing purposes. ## Testing Features - Metadata parsing - Search functionality - MCP protocol communication `; await fs.writeFile( join(workspacePath, 'general', 'integration-test-note.md'), testNote2, 'utf8' ); // Create a note in a different type await fs.mkdir(join(workspacePath, 'projects'), { recursive: true }); const projectNote = `# Test Project This is a project note for testing different note types. ## Goals Test the note type functionality in integration tests. `; await fs.writeFile( join(workspacePath, 'projects', 'test-project.md'), projectNote, 'utf8' ); } /** * Creates a note type with description for integration testing */ export async function createTestNoteType( workspacePath: string, noteType: string, description: string ): Promise<void> { const noteTypePath = join(workspacePath, noteType); await fs.mkdir(noteTypePath, { recursive: true }); // Create .flint-note directory if it doesn't exist const flintNoteDir = join(workspacePath, '.flint-note'); await fs.mkdir(flintNoteDir, { recursive: true }); // Write description file to .flint-note config directory const descriptionPath = join(flintNoteDir, `${noteType}_description.md`); await fs.writeFile(descriptionPath, description, 'utf8'); } /** * Waits for a condition to be true, with timeout */ export async function waitFor( condition: () => boolean | Promise<boolean>, timeout = 5000, interval = 100 ): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < timeout) { if (await condition()) { return; } await new Promise(resolve => setTimeout(resolve, interval)); } throw new Error(`Condition not met within ${timeout}ms timeout`); } /** * Integration test constants */ export const INTEGRATION_CONSTANTS = { DEFAULT_TIMEOUT: 10000, SERVER_STARTUP_TIMEOUT: 20000, SERVER_SHUTDOWN_TIMEOUT: 5000, REQUEST_TIMEOUT: 30000, TEST_NOTES: { SIMPLE: { title: 'Simple Test Note', content: 'This is a simple test note for integration testing.' }, WITH_METADATA: { title: 'Test Note with Metadata', content: `--- title: "Test Note with Metadata" tags: ["test", "integration"] created: "2024-01-01T00:00:00Z" --- # Test Note with Metadata This note has frontmatter metadata for testing.` } }, NOTE_TYPES: { DEFAULT: 'general', PROJECTS: 'projects', MEETINGS: 'meetings', BOOKS: 'book-reviews' } } as const;

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/disnet/flint-note'

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