UNPKG

ble-mcp-test

Version:

Complete BLE testing stack: WebSocket bridge server, MCP observability layer, and Web Bluetooth API mock. Test real BLE devices in Playwright/E2E tests without browser support.

550 lines (549 loc) 22.1 kB
/** * Mock Web Bluetooth API * * Provides a mock implementation of the Web Bluetooth API that routes * all calls through the WebSocketTransport, bypassing all native dialogs * * ==================================================================== * 🤖 AI DEBUGGING TIP: Using MCP for Enhanced Debugging * ==================================================================== * * If you're an AI agent debugging an application that uses this mock, * the ble-mcp-test server provides powerful MCP (Model Context Protocol) * tools for real-time debugging: * * Available MCP Tools: * - get_logs: View recent BLE communication with filtering * - search_packets: Find specific hex patterns in traffic * - get_connection_state: Check current BLE connection status * - status: Get server configuration and health * - scan_devices: Discover nearby BLE devices * * Health Check: * - HTTP endpoint: http://localhost:8081/health * - Returns: { status: 'ok', timestamp: string, bridge: {...} } * - Use before running tests to ensure server is available * * Example Usage: * 1. Use get_logs to see recent TX/RX packets * 2. Use search_packets to find specific command/response patterns * 3. Monitor connection state during debugging * * The server runs MCP by default on stdio. For network access: * - Run with --mcp-http for HTTP transport on port 8081 * - Or set MCP_TOKEN=secret for authenticated access * * This enables real-time inspection of BLE communication without * modifying application code or adding console.log statements. * ==================================================================== */ import { WebSocketTransport } from './ws-transport.js'; // Mock BluetoothRemoteGATTCharacteristic class MockBluetoothRemoteGATTCharacteristic { service; uuid; notificationHandlers = []; constructor(service, uuid) { this.service = service; this.uuid = uuid; // Register this characteristic with the device for transport message handling this.service.server.device.registerCharacteristic(this.uuid, this); } async writeValue(value) { const data = new Uint8Array(value); await this.service.server.device.transport.send(data); } async startNotifications() { // Notifications are automatically started by WebSocketTransport return this; } async stopNotifications() { // In a real implementation, this would stop notifications // For our mock, we don't need to do anything special return this; } addEventListener(event, handler) { if (event === 'characteristicvaluechanged') { // Store handler for both real and simulated notifications this.notificationHandlers.push(handler); } } removeEventListener(event, handler) { if (event === 'characteristicvaluechanged') { const index = this.notificationHandlers.indexOf(handler); if (index > -1) { this.notificationHandlers.splice(index, 1); } } } // Called by the device when transport receives data handleTransportMessage(data) { if (this.notificationHandlers.length > 0) { this.triggerNotification(data); } } /** * Simulate a notification from the device (for testing) * This allows tests to inject data as if it came from the real device * * @example * // Simulate button press event * characteristic.simulateNotification(new Uint8Array([0xA7, 0xB3, 0x01, 0xFF])); * // Simulate button release event * characteristic.simulateNotification(new Uint8Array([0xA7, 0xB3, 0x01, 0x00])); */ simulateNotification(data) { if (!this.service.server.connected) { throw new Error('GATT Server not connected'); } // Log for debugging if enabled if (MOCK_CONFIG.logRetries) { const hex = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '); console.log(`[Mock] Simulating device notification: ${hex}`); } this.triggerNotification(data); } triggerNotification(data) { // Create a mock event with the data matching Web Bluetooth API structure const mockEvent = { target: { value: { buffer: data.buffer, byteLength: data.byteLength, byteOffset: data.byteOffset, getUint8: (index) => data[index] } } }; // Trigger all registered handlers this.notificationHandlers.forEach(handler => { handler(mockEvent); }); } } // Mock BluetoothRemoteGATTService class MockBluetoothRemoteGATTService { server; uuid; constructor(server, uuid) { this.server = server; this.uuid = uuid; } async getCharacteristic(characteristicUuid) { // Return mock characteristic return new MockBluetoothRemoteGATTCharacteristic(this, characteristicUuid); } } // Configuration for mock behavior - can be overridden at runtime let MOCK_CONFIG = { // Match server's expected recovery timing: // - Clean disconnect: 1s (new default) // - Failed connection: 5s+ (server default) connectRetryDelay: parseInt(process.env.BLE_MCP_MOCK_RETRY_DELAY || '1200', 10), // 1.2s to cover 1s clean recovery maxConnectRetries: parseInt(process.env.BLE_MCP_MOCK_MAX_RETRIES || '20', 10), // More retries for 5s+ recovery postDisconnectDelay: parseInt(process.env.BLE_MCP_MOCK_CLEANUP_DELAY || '1100', 10), // 1.1s to ensure server is ready retryBackoffMultiplier: parseFloat(process.env.BLE_MCP_MOCK_BACKOFF || '1.3'), // Gentler backoff logRetries: process.env.BLE_MCP_MOCK_LOG_RETRIES !== 'false' }; // Allow runtime configuration updates export function updateMockConfig(updates) { MOCK_CONFIG = { ...MOCK_CONFIG, ...updates }; } // Mock BluetoothRemoteGATTServer class MockBluetoothRemoteGATTServer { device; connected = false; constructor(device) { this.device = device; } async connect() { let lastError = null; let retryDelay = MOCK_CONFIG.connectRetryDelay; for (let attempt = 1; attempt <= MOCK_CONFIG.maxConnectRetries; attempt++) { try { // Pass BLE configuration including session if available const connectOptions = { device: this.device.name }; if (this.device.bleConfig) { Object.assign(connectOptions, this.device.bleConfig); // Map sessionId to session for WebSocketTransport if (connectOptions.sessionId && !connectOptions.session) { connectOptions.session = connectOptions.sessionId; console.log(`[MockGATT] Using session ID for WebSocket: ${connectOptions.sessionId}`); } } console.log(`[MockGATT] WebSocket connect options:`, JSON.stringify(connectOptions)); await this.device.transport.connect(connectOptions); // Store session ID if one was generated or provided const sessionId = this.device.transport.getSessionId(); if (sessionId) { this.device.sessionId = sessionId; } this.connected = true; if (attempt > 1 && MOCK_CONFIG.logRetries) { console.log(`[Mock] Connected successfully after ${attempt} attempts`); } return this; } catch (error) { lastError = error; // Check if error is retryable (bridge busy states) const retryableErrors = [ 'Bridge is disconnecting', 'Bridge is connecting', 'only ready state accepts connections' ]; const isRetryable = retryableErrors.some(msg => error.message?.includes(msg)); if (isRetryable && attempt < MOCK_CONFIG.maxConnectRetries) { if (MOCK_CONFIG.logRetries) { console.log(`[Mock] Bridge busy (${error.message}), retry ${attempt}/${MOCK_CONFIG.maxConnectRetries} in ${retryDelay}ms...`); } await new Promise(resolve => setTimeout(resolve, retryDelay)); // Exponential backoff for subsequent retries retryDelay = Math.min(retryDelay * MOCK_CONFIG.retryBackoffMultiplier, 10000 // Max 10 second delay ); continue; } // Non-retryable error or max retries reached throw error; } } // If we get here, we've exhausted retries throw lastError || new Error('Failed to connect after maximum retries'); } async disconnect() { if (!this.connected) { return; // Already disconnected } try { // Send force_cleanup before disconnecting if (this.device.transport.isConnected()) { if (MOCK_CONFIG.logRetries) { console.log('[Mock] Sending force_cleanup before disconnect'); } await this.device.transport.forceCleanup(); // Small delay to ensure cleanup message is processed await new Promise(resolve => setTimeout(resolve, 100)); } } catch (error) { // Log but continue with disconnect even if cleanup fails console.warn('[Mock] Force cleanup failed during disconnect:', error); } // Now disconnect the WebSocket try { await this.device.transport.disconnect(); } catch (error) { console.warn('[Mock] WebSocket disconnect error:', error); } this.connected = false; // Optional post-disconnect delay for tests that need it if (MOCK_CONFIG.postDisconnectDelay > 0) { if (MOCK_CONFIG.logRetries) { console.log(`[Mock] Post-disconnect delay: ${MOCK_CONFIG.postDisconnectDelay}ms`); } await new Promise(resolve => setTimeout(resolve, MOCK_CONFIG.postDisconnectDelay)); } } async forceCleanup() { await this.device.transport.forceCleanup(); } async getPrimaryService(serviceUuid) { if (!this.connected) { throw new Error('GATT Server not connected'); } return new MockBluetoothRemoteGATTService(this, serviceUuid); } } // Mock BluetoothDevice class MockBluetoothDevice { id; name; gatt; transport; bleConfig; characteristics = new Map(); isTransportSetup = false; sessionId; constructor(id, name, serverUrl, bleConfig) { this.id = id; this.name = name; this.transport = new WebSocketTransport(serverUrl); this.gatt = new MockBluetoothRemoteGATTServer(this); this.bleConfig = bleConfig; this.sessionId = bleConfig?.sessionId; } // Register a characteristic for notifications registerCharacteristic(uuid, characteristic) { this.characteristics.set(uuid, characteristic); this.setupTransportHandler(); } setupTransportHandler() { if (this.isTransportSetup) return; this.isTransportSetup = true; this.transport.onMessage((msg) => { if (msg.type === 'data' && msg.data) { const data = new Uint8Array(msg.data); // Forward to all characteristics that have notification handlers this.characteristics.forEach(char => { char.handleTransportMessage(data); }); } else if (msg.type === 'disconnected') { // Ensure GATT server knows it's disconnected if (this.gatt.connected) { this.gatt.connected = false; } // Trigger disconnection events this.dispatchEvent('gattserverdisconnected'); } }); } disconnectHandlers = []; addEventListener(event, handler) { if (event === 'gattserverdisconnected') { this.disconnectHandlers.push(handler); } } dispatchEvent(eventType) { if (eventType === 'gattserverdisconnected') { this.disconnectHandlers.forEach(handler => handler()); } } } // Mock Bluetooth API export class MockBluetooth { serverUrl; bleConfig; autoSessionId; constructor(serverUrl, bleConfig) { this.serverUrl = serverUrl; this.bleConfig = bleConfig; // Auto-generate session ID if not provided if (!bleConfig?.sessionId) { this.autoSessionId = this.generateAutoSessionId(); } } generateAutoSessionId() { // Simple: Playwright gets a directory-based ID, browsers get random if (this.isPlaywrightEnvironment()) { // Use the current working directory name as the session ID base // This allows all tests in the same project to share the connection pool const projectName = this.getProjectName(); return `playwright-${projectName}`; } // For interactive use, just timestamp + random return `session-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; } getProjectName() { // Try to get a stable project identifier if (typeof process !== 'undefined' && process.cwd) { // Get the last part of the current working directory const cwd = process.cwd(); const parts = cwd.split(/[\/\\]/); return parts[parts.length - 1] || 'test'; } // Fallback for browser environment - use hostname if (typeof window !== 'undefined') { return window.location.hostname.replace(/[^a-z0-9]/gi, '-') || 'test'; } return 'test'; } getClientIP() { if (typeof window !== 'undefined') { const hostname = window.location.hostname; if (hostname) { return hostname; // Return actual hostname (localhost, 127.0.0.1, etc.) } } return '127.0.0.1'; } getBrowser() { if (typeof navigator !== 'undefined') { const ua = navigator.userAgent; if (ua.includes('Playwright')) return 'playwright'; if (ua.includes('Puppeteer')) return 'puppeteer'; if (ua.includes('HeadlessChrome')) return 'headless'; if (ua.includes('Chrome')) return 'chrome'; if (ua.includes('Firefox')) return 'firefox'; if (ua.includes('Safari')) return 'safari'; if (ua.includes('Edge')) return 'edge'; } return 'browser'; } getStorageContext() { if (typeof window !== 'undefined') { return `${window.location.origin || 'unknown-origin'}`; } return 'no-window'; } isPlaywrightEnvironment() { // Simple check: Playwright tests typically use about:blank or have playwright in the user agent if (typeof window !== 'undefined') { // Check if we're in about:blank (common for Playwright) if (window.location.href === 'about:blank') { return true; } // Check for Playwright marker in window object (if injected by test) if (window.playwright) { return true; } } // Check for headless Chrome (common in Playwright) if (typeof navigator !== 'undefined' && navigator.userAgent) { return navigator.userAgent.includes('HeadlessChrome'); } return false; } getStableTestSuffix() { // Generate a stable suffix based on the current page URL // This ensures consistent session IDs across page reloads in the same test if (typeof window !== 'undefined') { const url = window.location.href; // Create a simple hash of the URL to use as a stable suffix let hash = 0; for (let i = 0; i < url.length; i++) { const char = url.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(36); } // Fallback to timestamp if window is not available return Date.now().toString(36); } getTestFilePath() { try { // Try to get test info from Playwright context if (typeof window !== 'undefined' && window.__playwright?.testInfo) { const testInfo = window.__playwright.testInfo; if (testInfo.file) { return this.normalizeTestPath(testInfo.file); } } // Fallback: Try to extract from stack trace const stack = new Error().stack; if (stack) { console.log(`[MockBluetooth] Stack trace for test path extraction:\n${stack}`); // Look for test file patterns in stack trace const testFilePattern = /\/(tests?|spec|e2e)\/(.*?)\.(test|spec)\.(ts|js|mjs)/; const lines = stack.split('\n'); for (const line of lines) { const match = line.match(testFilePattern); if (match) { const testPath = match[2]; // The path between tests/ and .test/spec console.log(`[MockBluetooth] Found test path in stack: ${testPath}`); return this.normalizeTestPath(`tests/${testPath}`); } } console.log(`[MockBluetooth] No test path found in stack trace`); } } catch (e) { console.log(`[MockBluetooth] Error extracting test path: ${e instanceof Error ? e.message : String(e)}`); } return null; } normalizeTestPath(path) { // Normalize path separators (Windows backslashes to forward slashes) const normalized = path.replace(/\\/g, '/'); // Extract last 2-3 path segments for uniqueness const segments = normalized.split('/'); const relevantSegments = []; // Find the tests/spec/e2e directory and take segments from there let foundTestDir = false; for (let i = segments.length - 1; i >= 0; i--) { if (['tests', 'test', 'spec', 'e2e'].includes(segments[i])) { foundTestDir = true; } if (foundTestDir) { relevantSegments.unshift(segments[i]); if (relevantSegments.length >= 3) break; } } // If we didn't find a test directory, just take the last 2 segments if (!foundTestDir && segments.length >= 2) { relevantSegments.push(segments[segments.length - 2]); relevantSegments.push(segments[segments.length - 1]); } // Remove file extensions const result = relevantSegments.join('/').replace(/\.(test|spec)\.(ts|js|mjs)$/, ''); return result; } async requestDevice(options) { // Bypass all dialogs - immediately return a mock device // Use the namePrefix filter if provided, otherwise use generic name let deviceName = 'MockDevice000000'; if (options?.filters) { for (const filter of options.filters) { if (filter.namePrefix) { // If a specific device name is provided in the filter, use it deviceName = filter.namePrefix; break; } } } // Create and return mock device with BLE configuration // Use auto-generated session ID if no explicit sessionId provided const effectiveConfig = { ...this.bleConfig, sessionId: this.bleConfig?.sessionId || this.autoSessionId }; const device = new MockBluetoothDevice('mock-device-id', deviceName, this.serverUrl, effectiveConfig); return device; } async getAvailability() { // Always available when using WebSocket bridge return true; } } // Function to get bundle version export function getBundleVersion() { // This will be replaced during build with actual version return typeof window.WebBleMock?.version === 'string' ? window.WebBleMock.version : 'unknown'; } // Export function to inject mock into window export function injectWebBluetoothMock(serverUrl, bleConfig) { if (typeof window === 'undefined') { console.warn('injectWebBluetoothMock: Not in browser environment'); return; } // Try to replace navigator.bluetooth with our mock const mockBluetooth = new MockBluetooth(serverUrl, bleConfig); try { // First attempt: direct assignment window.navigator.bluetooth = mockBluetooth; } catch { // Second attempt: defineProperty try { Object.defineProperty(window.navigator, 'bluetooth', { value: mockBluetooth, configurable: true, writable: true }); } catch { // Third attempt: create a new navigator object const nav = Object.create(window.navigator); nav.bluetooth = mockBluetooth; // Replace window.navigator Object.defineProperty(window, 'navigator', { value: nav, configurable: true, writable: true }); } } }