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
JavaScript
"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');
}