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.

599 lines (595 loc) 19.4 kB
"use strict"; (() => { // dist/ws-transport.js var WebSocketTransport = class { ws = null; serverUrl; messageHandler; connectionToken; // v0.4.0: Store token for force cleanup sessionId; // v0.4.5: Session management constructor(serverUrl = "ws://localhost:8080") { this.serverUrl = serverUrl; } async connect(options) { const url = new URL(this.serverUrl); if (options?.device) url.searchParams.set("device", options.device); if (options?.service) url.searchParams.set("service", options.service); if (options?.write) url.searchParams.set("write", options.write); if (options?.notify) url.searchParams.set("notify", options.notify); if (options?.session) { url.searchParams.set("session", options.session); this.sessionId = options.session; } this.ws = new WebSocket(url.toString()); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Connection timeout")); }, 1e4); this.ws.onopen = () => { }; this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "connected") { clearTimeout(timeout); if (msg.token) { this.connectionToken = msg.token; } resolve(); } else if (msg.type === "error") { clearTimeout(timeout); reject(new Error(msg.error || "Connection failed")); } } catch { } }; this.ws.onerror = () => { clearTimeout(timeout); reject(new Error("WebSocket error")); }; this.ws.onclose = () => { this.ws = null; if (this.messageHandler) { this.messageHandler({ type: "disconnected" }); } }; }); } send(data) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error("Not connected"); } this.ws.send(JSON.stringify({ type: "data", data: Array.from(data) })); } onMessage(callback) { this.messageHandler = callback; if (this.ws) { this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (this.messageHandler) { this.messageHandler(msg); } } catch { } }; } } async forceCleanup() { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error("Not connected"); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Force cleanup timeout")); }, 5e3); const ws = this.ws; const originalHandler = ws.onmessage; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "cleanup_complete" || msg.type === "force_cleanup_complete") { clearTimeout(timeout); ws.onmessage = originalHandler; resolve(); } else if (originalHandler) { originalHandler.call(ws, event); } } catch { if (originalHandler) originalHandler.call(ws, event); } }; const request = { type: "force_cleanup" }; if (this.connectionToken) { request.token = this.connectionToken; } ws.send(JSON.stringify(request)); }); } disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } } isConnected() { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } // Session management methods getSessionId() { return this.sessionId; } async reconnectToSession(sessionId) { return this.connect({ session: sessionId }); } }; // dist/mock-bluetooth.js var MockBluetoothRemoteGATTCharacteristic = class { service; uuid; notificationHandlers = []; constructor(service, uuid) { this.service = service; this.uuid = uuid; 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() { return this; } async stopNotifications() { return this; } addEventListener(event, handler) { if (event === "characteristicvaluechanged") { 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"); } 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) { const mockEvent = { target: { value: { buffer: data.buffer, byteLength: data.byteLength, byteOffset: data.byteOffset, getUint8: (index) => data[index] } } }; this.notificationHandlers.forEach((handler) => { handler(mockEvent); }); } }; var MockBluetoothRemoteGATTService = class { server; uuid; constructor(server, uuid) { this.server = server; this.uuid = uuid; } async getCharacteristic(characteristicUuid) { return new MockBluetoothRemoteGATTCharacteristic(this, characteristicUuid); } }; var MOCK_CONFIG = { // Match server's expected recovery timing: // - Clean disconnect: 1s (new default) // - Failed connection: 5s+ (server default) connectRetryDelay: parseInt("1200", 10), // 1.2s to cover 1s clean recovery maxConnectRetries: parseInt("20", 10), // More retries for 5s+ recovery postDisconnectDelay: parseInt("1100", 10), // 1.1s to ensure server is ready retryBackoffMultiplier: parseFloat("1.3"), // Gentler backoff logRetries: true }; function updateMockConfig(updates) { MOCK_CONFIG = { ...MOCK_CONFIG, ...updates }; } var MockBluetoothRemoteGATTServer = class { 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 { const connectOptions = { device: this.device.name }; if (this.device.bleConfig) { Object.assign(connectOptions, this.device.bleConfig); 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); 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; 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)); retryDelay = Math.min( retryDelay * MOCK_CONFIG.retryBackoffMultiplier, 1e4 // Max 10 second delay ); continue; } throw error; } } throw lastError || new Error("Failed to connect after maximum retries"); } async disconnect() { if (!this.connected) { return; } try { if (this.device.transport.isConnected()) { if (MOCK_CONFIG.logRetries) { console.log("[Mock] Sending force_cleanup before disconnect"); } await this.device.transport.forceCleanup(); await new Promise((resolve) => setTimeout(resolve, 100)); } } catch (error) { console.warn("[Mock] Force cleanup failed during disconnect:", error); } try { await this.device.transport.disconnect(); } catch (error) { console.warn("[Mock] WebSocket disconnect error:", error); } this.connected = false; 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); } }; var MockBluetoothDevice = class { id; name; gatt; transport; bleConfig; characteristics = /* @__PURE__ */ 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); this.characteristics.forEach((char) => { char.handleTransportMessage(data); }); } else if (msg.type === "disconnected") { if (this.gatt.connected) { this.gatt.connected = false; } this.dispatchEvent("gattserverdisconnected"); } }); } disconnectHandlers = []; addEventListener(event, handler) { if (event === "gattserverdisconnected") { this.disconnectHandlers.push(handler); } } dispatchEvent(eventType) { if (eventType === "gattserverdisconnected") { this.disconnectHandlers.forEach((handler) => handler()); } } }; var MockBluetooth = class { serverUrl; bleConfig; autoSessionId; constructor(serverUrl, bleConfig) { this.serverUrl = serverUrl; this.bleConfig = bleConfig; if (!bleConfig?.sessionId) { this.autoSessionId = this.generateAutoSessionId(); } } generateAutoSessionId() { if (this.isPlaywrightEnvironment()) { const projectName = this.getProjectName(); return `playwright-${projectName}`; } return `session-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; } getProjectName() { if (typeof process !== "undefined" && process.cwd) { const cwd = process.cwd(); const parts = cwd.split(/[\/\\]/); return parts[parts.length - 1] || "test"; } 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 "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() { if (typeof window !== "undefined") { if (window.location.href === "about:blank") { return true; } if (window.playwright) { return true; } } if (typeof navigator !== "undefined" && navigator.userAgent) { return navigator.userAgent.includes("HeadlessChrome"); } return false; } getStableTestSuffix() { if (typeof window !== "undefined") { const url = window.location.href; let hash = 0; for (let i = 0; i < url.length; i++) { const char = url.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash).toString(36); } return Date.now().toString(36); } getTestFilePath() { try { if (typeof window !== "undefined" && window.__playwright?.testInfo) { const testInfo = window.__playwright.testInfo; if (testInfo.file) { return this.normalizeTestPath(testInfo.file); } } const stack = new Error().stack; if (stack) { console.log(`[MockBluetooth] Stack trace for test path extraction: ${stack}`); 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]; 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) { const normalized = path.replace(/\\/g, "/"); const segments = normalized.split("/"); const relevantSegments = []; 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 (!foundTestDir && segments.length >= 2) { relevantSegments.push(segments[segments.length - 2]); relevantSegments.push(segments[segments.length - 1]); } const result = relevantSegments.join("/").replace(/\.(test|spec)\.(ts|js|mjs)$/, ""); return result; } async requestDevice(options) { let deviceName = "MockDevice000000"; if (options?.filters) { for (const filter of options.filters) { if (filter.namePrefix) { deviceName = filter.namePrefix; break; } } } 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() { return true; } }; function getBundleVersion() { return typeof window.WebBleMock?.version === "string" ? window.WebBleMock.version : "unknown"; } function injectWebBluetoothMock(serverUrl, bleConfig) { if (typeof window === "undefined") { console.warn("injectWebBluetoothMock: Not in browser environment"); return; } const mockBluetooth = new MockBluetooth(serverUrl, bleConfig); try { window.navigator.bluetooth = mockBluetooth; } catch { try { Object.defineProperty(window.navigator, "bluetooth", { value: mockBluetooth, configurable: true, writable: true }); } catch { const nav = Object.create(window.navigator); nav.bluetooth = mockBluetooth; Object.defineProperty(window, "navigator", { value: nav, configurable: true, writable: true }); } } } // dist/mock-browser-entry.js var WebBleMock = { MockBluetooth, injectWebBluetoothMock, updateMockConfig, getBundleVersion, version: "0.5.7" // Bundle version for cache-busting verification }; if (typeof window !== "undefined") { window.WebBleMock = WebBleMock; } })(); // Bundle version: 0.5.7 if (typeof window !== 'undefined' && window.WebBleMock) { window.WebBleMock.version = '0.5.7'; console.log('[WebBleMock] Bundle loaded successfully, version: 0.5.7, exports:', Object.keys(window.WebBleMock)); } else { console.error('[WebBleMock] Bundle failed to create window.WebBleMock'); }