- Add webBluetooth.ts with browser detection and compatibility checks - Add WebBLEManager implementing IBLEManager for Web Bluetooth API - Add BrowserNotSupported component showing clear error for Safari/Firefox - Update services/ble/index.ts to use WebBLEManager on web platform - Add comprehensive tests for browser detection and WebBLEManager Works in Chrome/Edge/Opera, shows user-friendly error in unsupported browsers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
// Tests for WebBLEManager
|
|
|
|
import { BLEErrorCode } from '../errors';
|
|
import { BLEConnectionState } from '../types';
|
|
|
|
// Mock the webBluetooth module before importing WebBLEManager
|
|
jest.mock('../webBluetooth', () => ({
|
|
checkWebBluetoothSupport: jest.fn(() => ({
|
|
supported: true,
|
|
browserName: 'Chrome',
|
|
browserVersion: '120',
|
|
})),
|
|
getUnsupportedBrowserMessage: jest.fn(() => ({
|
|
title: 'Browser Not Supported',
|
|
message: 'Safari does not support Web Bluetooth.',
|
|
suggestion: 'Please use Chrome.',
|
|
})),
|
|
}));
|
|
|
|
import { WebBLEManager } from '../WebBLEManager';
|
|
import { checkWebBluetoothSupport } from '../webBluetooth';
|
|
|
|
// Mock Web Bluetooth API
|
|
const mockDevice: any = {
|
|
id: 'device-123',
|
|
name: 'WP_497_81a14c',
|
|
gatt: {
|
|
connected: false,
|
|
connect: jest.fn(),
|
|
disconnect: jest.fn(),
|
|
getPrimaryService: jest.fn(),
|
|
},
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
const mockCharacteristic: any = {
|
|
startNotifications: jest.fn(),
|
|
stopNotifications: jest.fn(),
|
|
writeValueWithResponse: jest.fn(),
|
|
addEventListener: jest.fn(),
|
|
removeEventListener: jest.fn(),
|
|
};
|
|
|
|
const mockService: any = {
|
|
getCharacteristic: jest.fn(() => Promise.resolve(mockCharacteristic)),
|
|
};
|
|
|
|
const mockServer: any = {
|
|
device: mockDevice,
|
|
connected: true,
|
|
connect: jest.fn(() => Promise.resolve(mockServer)),
|
|
disconnect: jest.fn(),
|
|
getPrimaryService: jest.fn(() => Promise.resolve(mockService)),
|
|
};
|
|
|
|
describe('WebBLEManager', () => {
|
|
let manager: WebBLEManager;
|
|
const originalNavigator = global.navigator;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Reset mock implementation
|
|
(checkWebBluetoothSupport as jest.Mock).mockReturnValue({
|
|
supported: true,
|
|
browserName: 'Chrome',
|
|
browserVersion: '120',
|
|
});
|
|
|
|
// Mock navigator.bluetooth
|
|
Object.defineProperty(global, 'navigator', {
|
|
value: {
|
|
...originalNavigator,
|
|
bluetooth: {
|
|
requestDevice: jest.fn(() => Promise.resolve(mockDevice)),
|
|
},
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
// Reset device mocks
|
|
mockDevice.gatt = {
|
|
connected: false,
|
|
connect: jest.fn(() => {
|
|
mockDevice.gatt.connected = true;
|
|
return Promise.resolve(mockServer);
|
|
}),
|
|
disconnect: jest.fn(() => {
|
|
mockDevice.gatt.connected = false;
|
|
}),
|
|
getPrimaryService: jest.fn(() => Promise.resolve(mockService)),
|
|
};
|
|
|
|
mockServer.connected = true;
|
|
mockServer.getPrimaryService.mockResolvedValue(mockService);
|
|
mockCharacteristic.startNotifications.mockResolvedValue(mockCharacteristic);
|
|
|
|
manager = new WebBLEManager();
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(global, 'navigator', {
|
|
value: originalNavigator,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create manager when Web Bluetooth is supported', () => {
|
|
expect(manager).toBeDefined();
|
|
});
|
|
|
|
it('should warn when Web Bluetooth is not supported', () => {
|
|
(checkWebBluetoothSupport as jest.Mock).mockReturnValue({
|
|
supported: false,
|
|
browserName: 'Safari',
|
|
browserVersion: '17',
|
|
reason: 'unsupported_browser',
|
|
});
|
|
|
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
new WebBLEManager();
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('scanDevices', () => {
|
|
it('should return device when user selects one', async () => {
|
|
const devices = await manager.scanDevices();
|
|
|
|
expect(devices).toHaveLength(1);
|
|
expect(devices[0].id).toBe('device-123');
|
|
expect(devices[0].name).toBe('WP_497_81a14c');
|
|
expect(devices[0].wellId).toBe(497);
|
|
expect(devices[0].mac).toBe('81A14C');
|
|
});
|
|
|
|
it('should return empty array when user cancels', async () => {
|
|
(navigator.bluetooth!.requestDevice as jest.Mock).mockRejectedValue(
|
|
new Error('User cancelled')
|
|
);
|
|
|
|
const devices = await manager.scanDevices();
|
|
expect(devices).toHaveLength(0);
|
|
});
|
|
|
|
it('should throw BLEError for permission denied', async () => {
|
|
const permissionError = new Error('Permission denied');
|
|
(permissionError as any).name = 'NotAllowedError';
|
|
(navigator.bluetooth!.requestDevice as jest.Mock).mockRejectedValue(permissionError);
|
|
|
|
await expect(manager.scanDevices()).rejects.toMatchObject({
|
|
code: BLEErrorCode.PERMISSION_DENIED,
|
|
});
|
|
});
|
|
|
|
it('should throw BLEError when Web Bluetooth not supported', async () => {
|
|
(checkWebBluetoothSupport as jest.Mock).mockReturnValue({
|
|
supported: false,
|
|
browserName: 'Safari',
|
|
browserVersion: '17',
|
|
reason: 'unsupported_browser',
|
|
});
|
|
|
|
const newManager = new WebBLEManager();
|
|
await expect(newManager.scanDevices()).rejects.toMatchObject({
|
|
code: BLEErrorCode.BLUETOOTH_DISABLED,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('connectDevice', () => {
|
|
beforeEach(async () => {
|
|
// First scan to get device reference
|
|
await manager.scanDevices();
|
|
});
|
|
|
|
it('should connect to device successfully', async () => {
|
|
const result = await manager.connectDevice('device-123');
|
|
|
|
expect(result).toBe(true);
|
|
expect(manager.getConnectionState('device-123')).toBe(BLEConnectionState.READY);
|
|
expect(mockDevice.gatt.connect).toHaveBeenCalled();
|
|
expect(mockCharacteristic.startNotifications).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw error for unknown device', async () => {
|
|
const result = await manager.connectDevice('unknown-device');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return true if already connected', async () => {
|
|
// First connection
|
|
await manager.connectDevice('device-123');
|
|
|
|
// Second connection attempt
|
|
const result = await manager.connectDevice('device-123');
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should emit state_changed event with ready state on successful connection', async () => {
|
|
const listener = jest.fn();
|
|
manager.addEventListener(listener);
|
|
|
|
await manager.connectDevice('device-123');
|
|
|
|
expect(listener).toHaveBeenCalledWith(
|
|
'device-123',
|
|
'state_changed',
|
|
expect.objectContaining({ state: 'ready' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('disconnectDevice', () => {
|
|
beforeEach(async () => {
|
|
await manager.scanDevices();
|
|
await manager.connectDevice('device-123');
|
|
});
|
|
|
|
it('should disconnect device', async () => {
|
|
await manager.disconnectDevice('device-123');
|
|
|
|
expect(manager.getConnectionState('device-123')).toBe(BLEConnectionState.DISCONNECTED);
|
|
expect(manager.isDeviceConnected('device-123')).toBe(false);
|
|
});
|
|
|
|
it('should emit disconnected event', async () => {
|
|
const listener = jest.fn();
|
|
manager.addEventListener(listener);
|
|
|
|
await manager.disconnectDevice('device-123');
|
|
|
|
expect(listener).toHaveBeenCalledWith(
|
|
'device-123',
|
|
'disconnected',
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('isDeviceConnected', () => {
|
|
it('should return false for unknown device', () => {
|
|
expect(manager.isDeviceConnected('unknown')).toBe(false);
|
|
});
|
|
|
|
it('should return true for connected device', async () => {
|
|
await manager.scanDevices();
|
|
await manager.connectDevice('device-123');
|
|
|
|
expect(manager.isDeviceConnected('device-123')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('event listeners', () => {
|
|
it('should add and remove event listeners', () => {
|
|
const listener = jest.fn();
|
|
|
|
manager.addEventListener(listener);
|
|
// Trigger an event by changing state
|
|
(manager as any).emitEvent('device-123', 'ready', {});
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
manager.removeEventListener(listener);
|
|
(manager as any).emitEvent('device-123', 'ready', {});
|
|
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called again
|
|
});
|
|
|
|
it('should not add duplicate listeners', () => {
|
|
const listener = jest.fn();
|
|
|
|
manager.addEventListener(listener);
|
|
manager.addEventListener(listener);
|
|
|
|
(manager as any).emitEvent('device-123', 'ready', {});
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('cleanup', () => {
|
|
it('should disconnect all devices and clear state', async () => {
|
|
await manager.scanDevices();
|
|
await manager.connectDevice('device-123');
|
|
|
|
await manager.cleanup();
|
|
|
|
expect(manager.getAllConnections().size).toBe(0);
|
|
expect(manager.isDeviceConnected('device-123')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('reconnect functionality', () => {
|
|
it('should set and get reconnect config', () => {
|
|
manager.setReconnectConfig({ maxAttempts: 5 });
|
|
const config = manager.getReconnectConfig();
|
|
expect(config.maxAttempts).toBe(5);
|
|
});
|
|
|
|
it('should enable and disable auto reconnect', async () => {
|
|
await manager.scanDevices();
|
|
await manager.connectDevice('device-123');
|
|
|
|
manager.enableAutoReconnect('device-123', 'Test Device');
|
|
let state = manager.getReconnectState('device-123');
|
|
expect(state).toBeDefined();
|
|
expect(state?.deviceName).toBe('Test Device');
|
|
|
|
manager.disableAutoReconnect('device-123');
|
|
state = manager.getReconnectState('device-123');
|
|
expect(state).toBeUndefined();
|
|
});
|
|
|
|
it('should cancel reconnect', async () => {
|
|
await manager.scanDevices();
|
|
manager.enableAutoReconnect('device-123', 'Test Device');
|
|
|
|
// Set as reconnecting
|
|
(manager as any).reconnectStates.set('device-123', {
|
|
deviceId: 'device-123',
|
|
deviceName: 'Test Device',
|
|
attempts: 1,
|
|
lastAttemptTime: Date.now(),
|
|
isReconnecting: true,
|
|
});
|
|
|
|
manager.cancelReconnect('device-123');
|
|
const state = manager.getReconnectState('device-123');
|
|
expect(state?.isReconnecting).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('bulk operations', () => {
|
|
beforeEach(async () => {
|
|
await manager.scanDevices();
|
|
await manager.connectDevice('device-123');
|
|
});
|
|
|
|
it('should bulk disconnect devices', async () => {
|
|
const results = await manager.bulkDisconnect(['device-123']);
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].deviceId).toBe('device-123');
|
|
});
|
|
|
|
it('should handle errors in bulk disconnect', async () => {
|
|
const results = await manager.bulkDisconnect(['unknown-device']);
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].success).toBe(true); // disconnect on unknown is no-op
|
|
});
|
|
});
|
|
});
|