Skip to content

Commit 9553df3

Browse files
Merge pull request #71 from loginov-rocks/dev
v1.6.0
2 parents 0f0dac3 + e4e2956 commit 9553df3

14 files changed

+2585
-1277
lines changed

.vscode/settings.json

Lines changed: 0 additions & 3 deletions
This file was deleted.

README.md

Lines changed: 291 additions & 102 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 257 additions & 356 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "bluetooth-terminal",
3-
"version": "1.5.0",
4-
"description": "ES6 class for serial communication with Bluetooth Low Energy (Smart) devices",
3+
"version": "1.6.0",
4+
"description": "Class that enables bidirectional serial communication between web browsers and Bluetooth Low Energy modules via the Web Bluetooth API",
55
"main": "dist/BluetoothTerminal.js",
66
"files": [
77
"dist"
@@ -35,7 +35,7 @@
3535
"iot",
3636
"physical-web",
3737
"serial",
38-
"smart"
38+
"terminal"
3939
],
4040
"author": "Danila Loginov <danila@loginov.rocks> (https://loginov.rocks)",
4141
"license": "MIT",
@@ -48,16 +48,16 @@
4848
"@babel/core": "^7.26.10",
4949
"@babel/preset-env": "^7.26.9",
5050
"@babel/preset-typescript": "^7.27.0",
51-
"@eslint/js": "^9.24.0",
51+
"@eslint/js": "^9.25.1",
5252
"@types/jest": "^29.5.14",
5353
"babel-jest": "^29.7.0",
54-
"eslint": "^9.24.0",
54+
"eslint": "^9.25.1",
5555
"eslint-config-google": "^0.14.0",
5656
"eslint-plugin-jsdoc": "^50.6.9",
5757
"jest": "^29.7.0",
5858
"jest-environment-jsdom": "^29.7.0",
5959
"typescript": "^5.8.3",
60-
"typescript-eslint": "^8.29.0",
60+
"typescript-eslint": "^8.31.0",
6161
"web-bluetooth-mock": "^1.2.0"
6262
},
6363
"jest": {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Test file uses `require()` style imports because the BluetoothTerminal module is exported only as a CommonJS module.
2+
/* eslint-disable @typescript-eslint/no-require-imports */
3+
// Import the `TextEncoder` and `TextDecoder` from the Node.js `util` module and add them to the global scope. These
4+
// are natively available in browsers but need to be explicitly added in Node.js test environments. The
5+
// `BluetoothTerminal` class requires these for encoding/decoding messages sent to and received from Bluetooth devices.
6+
const util = require('util');
7+
global.TextDecoder = util.TextDecoder;
8+
global.TextEncoder = util.TextEncoder;
9+
10+
const {DeviceMock, WebBluetoothMock} = require('web-bluetooth-mock');
11+
12+
const BluetoothTerminal = require('./BluetoothTerminal');
13+
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+
const changeCharacteristicValue = (bluetoothTerminal: any, value: string) => {
16+
// Simulate Bluetooth characteristic value change: set value and dispatch the `characteristicvaluechanged` event to
17+
// trigger the notification handler, mimicking how the Web Bluetooth API would behave when receiving data from the
18+
// connected device.
19+
const characteristic = bluetoothTerminal._characteristic;
20+
characteristic.value = new TextEncoder().encode(value);
21+
characteristic.dispatchEvent(new CustomEvent('characteristicvaluechanged'));
22+
};
23+
24+
describe('Communication', () => {
25+
// Using `any` type to access private members for testing purposes. This allows for thorough testing of the internal
26+
// state and behavior while maintaining strong encapsulation in the production code.
27+
let bt: any; // eslint-disable-line @typescript-eslint/no-explicit-any
28+
29+
beforeEach(() => {
30+
bt = new BluetoothTerminal();
31+
const device = new DeviceMock('Simon', [0xFFE0]);
32+
navigator.bluetooth = new WebBluetoothMock([device]);
33+
});
34+
35+
describe('When sending a message...', () => {
36+
it('should send the message with the send separator', async () => {
37+
await bt.connect();
38+
39+
jest.spyOn(bt._characteristic, 'writeValue');
40+
41+
await bt.send('Hello, world!');
42+
43+
expect(bt._characteristic.writeValue).toHaveBeenCalledTimes(1);
44+
expect(bt._characteristic.writeValue).toHaveBeenCalledWith(new TextEncoder().encode('Hello, world!\n'));
45+
});
46+
47+
it('should send the message longer than the characteristic value size by splitting it into chunks', async () => {
48+
await bt.connect();
49+
50+
jest.spyOn(bt._characteristic, 'writeValue');
51+
52+
await bt.send('This message is longer than the default characteristic value size of 20 chars, so it will be ' +
53+
'split into 6 chunks');
54+
55+
expect(bt._characteristic.writeValue).toHaveBeenCalledTimes(6);
56+
expect(bt._characteristic.writeValue).toHaveBeenNthCalledWith(1,
57+
new TextEncoder().encode('This message is long'));
58+
expect(bt._characteristic.writeValue).toHaveBeenNthCalledWith(2,
59+
new TextEncoder().encode('er than the default '));
60+
expect(bt._characteristic.writeValue).toHaveBeenNthCalledWith(3,
61+
new TextEncoder().encode('characteristic value'));
62+
expect(bt._characteristic.writeValue).toHaveBeenNthCalledWith(4,
63+
new TextEncoder().encode(' size of 20 chars, s'));
64+
expect(bt._characteristic.writeValue).toHaveBeenNthCalledWith(5,
65+
new TextEncoder().encode('o it will be split i'));
66+
expect(bt._characteristic.writeValue).toHaveBeenNthCalledWith(6, new TextEncoder().encode('nto 6 chunks\n'));
67+
});
68+
69+
it('should throw an error when no message is provided', () => {
70+
return expect(bt.send()).rejects.toThrow();
71+
});
72+
73+
it('should throw an error when an empty string is provided', () => {
74+
return expect(bt.send('')).rejects.toThrow();
75+
});
76+
77+
it('should throw an error when no device is connected', () => {
78+
return expect(bt.send('Hello, world!')).rejects.toThrow();
79+
});
80+
81+
it('should throw an error when the device disconnects while sending the long message', async () => {
82+
await bt.connect();
83+
84+
jest.spyOn(bt._characteristic, 'writeValue');
85+
86+
const promise = bt.send('This message is longer than the default characteristic value size of 20 chars');
87+
88+
expect(bt._characteristic.writeValue).toHaveBeenCalledTimes(1);
89+
expect(bt._characteristic.writeValue).toHaveBeenCalledWith(new TextEncoder().encode('This message is long'));
90+
91+
// Disconnect the device while sending the long message split into chunks before the Promise resolves. This tests
92+
// the error handling when the connection is lost after the first chunk is sent, but before the remaining chunks
93+
// can be sent.
94+
bt.disconnect();
95+
96+
await expect(promise).rejects.toThrow();
97+
});
98+
});
99+
100+
describe('When receiving a message...', () => {
101+
let onReceiveCallback: jest.Func;
102+
103+
beforeEach(() => {
104+
onReceiveCallback = jest.fn();
105+
bt.onReceive(onReceiveCallback);
106+
107+
return bt.connect();
108+
});
109+
110+
it('should not invoke the onReceive callback until the receive separator is received', () => {
111+
changeCharacteristicValue(bt, 'Hello, world!');
112+
113+
expect(onReceiveCallback).not.toHaveBeenCalled();
114+
});
115+
116+
it('should invoke the onReceive callback when data with the receive separator is received', () => {
117+
changeCharacteristicValue(bt, 'Hello, world!\n');
118+
119+
expect(onReceiveCallback).toHaveBeenCalledTimes(1);
120+
expect(onReceiveCallback).toHaveBeenCalledWith('Hello, world!');
121+
});
122+
123+
it('should invoke the onReceive callback when data with multiple receive separators is received', () => {
124+
changeCharacteristicValue(bt, 'Hello, world!\nCiao, mondo!\n');
125+
126+
expect(onReceiveCallback).toHaveBeenCalledTimes(2);
127+
expect(onReceiveCallback).toHaveBeenNthCalledWith(1, 'Hello, world!');
128+
expect(onReceiveCallback).toHaveBeenNthCalledWith(2, 'Ciao, mondo!');
129+
});
130+
131+
it('should invoke the onReceive callback only with the complete message until the receive separator is received',
132+
() => {
133+
changeCharacteristicValue(bt, 'Hello, world!\nCiao, mondo!');
134+
135+
expect(onReceiveCallback).toHaveBeenCalledTimes(1);
136+
expect(onReceiveCallback).toHaveBeenCalledWith('Hello, world!');
137+
});
138+
});
139+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Test file uses `require()` style imports because the BluetoothTerminal module is exported only as a CommonJS module.
2+
/* eslint-disable @typescript-eslint/no-require-imports */
3+
const {DeviceMock, WebBluetoothMock} = require('web-bluetooth-mock');
4+
5+
const BluetoothTerminal = require('./BluetoothTerminal');
6+
7+
const unexpectedlyDisconnect = (device: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
8+
// Simulate an unexpected disconnection by dispatching the corresponding Web Bluetooth API event.
9+
device.dispatchEvent(new CustomEvent('gattserverdisconnected'));
10+
};
11+
12+
describe('Connection', () => {
13+
// Using `any` type to access private members for testing purposes. This allows for thorough testing of the internal
14+
// state and behavior while maintaining strong encapsulation in the production code.
15+
let bt: any; // eslint-disable-line @typescript-eslint/no-explicit-any
16+
let device: any; // eslint-disable-line @typescript-eslint/no-explicit-any
17+
18+
beforeEach(() => {
19+
bt = new BluetoothTerminal();
20+
device = new DeviceMock('Simon', [0xFFE0]);
21+
navigator.bluetooth = new WebBluetoothMock([device]);
22+
});
23+
24+
describe('When connecting to the device...', () => {
25+
beforeEach(() => {
26+
jest.spyOn(navigator.bluetooth, 'requestDevice');
27+
jest.spyOn(device.gatt, 'connect');
28+
});
29+
30+
it('should open the browser Bluetooth device picker and connect to the selected device', async () => {
31+
await expect(bt.connect()).resolves.toBeUndefined();
32+
expect(navigator.bluetooth.requestDevice).toHaveBeenCalledTimes(1);
33+
expect(device.gatt.connect).toHaveBeenCalledTimes(1);
34+
});
35+
36+
it('should connect to the previously selected device on subsequent calls to avoid prompting the user multiple ' +
37+
'times', async () => {
38+
// The first connection should open the browser Bluetooth device picker.
39+
await expect(bt.connect()).resolves.toBeUndefined();
40+
// The second connection should connect to the previously selected device.
41+
await expect(bt.connect()).resolves.toBeUndefined();
42+
expect(navigator.bluetooth.requestDevice).toHaveBeenCalledTimes(1);
43+
expect(device.gatt.connect).toHaveBeenCalledTimes(2);
44+
});
45+
46+
it('should invoke the onConnect callback after connection', async () => {
47+
const callback = jest.fn();
48+
bt.onConnect(callback);
49+
50+
await expect(bt.connect()).resolves.toBeUndefined();
51+
52+
expect(callback).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it('should throw an error when no device with a matching service UUID is available', async () => {
56+
device = new DeviceMock('Simon', [1234]);
57+
navigator.bluetooth = new WebBluetoothMock([device]);
58+
jest.spyOn(navigator.bluetooth, 'requestDevice');
59+
60+
await expect(bt.connect()).rejects.toThrow();
61+
expect(navigator.bluetooth.requestDevice).toHaveBeenCalledTimes(1);
62+
});
63+
});
64+
65+
describe('When disconnecting from the device...', () => {
66+
beforeEach(() => {
67+
jest.spyOn(device.gatt, 'disconnect');
68+
69+
return bt.connect();
70+
});
71+
72+
it('should disconnect only once despite subsequent calls', () => {
73+
bt.disconnect();
74+
bt.disconnect(); // The second call shouldn't trigger another disconnect.
75+
76+
expect(device.gatt.disconnect).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it('should invoke the onDisconnect callback after disconnection', () => {
80+
const callback = jest.fn();
81+
bt.onDisconnect(callback);
82+
83+
bt.disconnect();
84+
85+
expect(callback).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('should avoid disconnecting when the device unexpectedly disconnects', () => {
89+
// Simulate an unexpected disconnection by setting the Web Bluetooth API connection flag.
90+
device.gatt.connected = false;
91+
92+
bt.disconnect();
93+
94+
expect(device.gatt.disconnect).not.toHaveBeenCalled();
95+
});
96+
});
97+
98+
describe('When the device unexpectedly disconnects...', () => {
99+
beforeEach(() => {
100+
jest.spyOn(device.gatt, 'connect');
101+
102+
return bt.connect();
103+
});
104+
105+
it('should invoke the onDisconnect callback', () => {
106+
const callback = jest.fn();
107+
bt.onDisconnect(callback);
108+
109+
unexpectedlyDisconnect(device);
110+
111+
expect(callback).toHaveBeenCalledTimes(1);
112+
});
113+
114+
it('should attempt to reconnect to the device', () => {
115+
// Verify that connect was already called once during test setup.
116+
expect(device.gatt.connect).toHaveBeenCalledTimes(1);
117+
118+
unexpectedlyDisconnect(device);
119+
120+
// Verify that a reconnection attempt was made.
121+
expect(device.gatt.connect).toHaveBeenCalledTimes(2);
122+
});
123+
124+
it('should invoke the onConnect callback after reconnection', (done) => {
125+
const callback = jest.fn();
126+
bt.onConnect(callback);
127+
128+
unexpectedlyDisconnect(device);
129+
130+
// Using `setTimeout()` to defer verification until after the current call stack has completed. This allows the
131+
// reconnection Promise chain to resolve before checking if the onConnect callback was triggered. Without this,
132+
// the check can happen before the async reconnection process finishes.
133+
setTimeout(() => {
134+
expect(callback).toHaveBeenCalledTimes(1);
135+
// Signal Jest that the asynchronous test is complete.
136+
done();
137+
}, 0);
138+
});
139+
140+
it('should log an error when a reconnection attempt fails', (done) => {
141+
jest.spyOn(bt, '_logError');
142+
143+
// Verify that connect was already called once during test setup.
144+
expect(device.gatt.connect).toHaveBeenCalledTimes(1);
145+
146+
// Mock the connect method to simulate a connection failure.
147+
device.gatt.connect = jest.fn(() => Promise.reject(new Error('Simulated error')));
148+
149+
unexpectedlyDisconnect(device);
150+
151+
// Verify that a reconnection attempt was made. Using `1` since the connect method was just mocked.
152+
expect(device.gatt.connect).toHaveBeenCalledTimes(1);
153+
154+
// Using `setTimeout()` to wait for the Promise rejection to be processed. This allows for verifying asynchronous
155+
// behavior after the reconnection failure.
156+
setTimeout(() => {
157+
// Verify that the error was properly logged.
158+
expect(bt._logError).toHaveBeenLastCalledWith(
159+
'_gattServerDisconnectedListener',
160+
new Error('Simulated error'),
161+
expect.any(Function),
162+
);
163+
// Signal Jest that the asynchronous test is complete.
164+
done();
165+
}, 0);
166+
});
167+
});
168+
169+
describe('When getting the device name...', () => {
170+
it('should return an empty string when no device is connected', () => {
171+
expect(bt.getDeviceName()).toBe('');
172+
});
173+
174+
it('should return the device name after connection', async () => {
175+
await bt.connect();
176+
177+
expect(bt.getDeviceName()).toBe('Simon');
178+
});
179+
180+
it('should return the device name when the device unexpectedly disconnects', async () => {
181+
await bt.connect();
182+
183+
// Simulate an unexpected disconnection by setting the Web Bluetooth API connection flag.
184+
device.gatt.connected = false;
185+
186+
expect(bt.getDeviceName()).toBe('Simon');
187+
});
188+
});
189+
});

0 commit comments

Comments
 (0)