react-native-web-serial-api 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -117
- package/TESTING.md +417 -176
- package/android/build.gradle +14 -0
- package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
- package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
- package/bin/expose-serial.js +205 -0
- package/lib/commonjs/UsbSerial.js +1 -1
- package/lib/commonjs/WebSerial.js +110 -26
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +2 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/event-target.js +3 -1
- package/lib/commonjs/lib/event-target.js.map +1 -1
- package/lib/commonjs/lib/web-streams.js +42 -0
- package/lib/commonjs/lib/web-streams.js.map +1 -0
- package/lib/commonjs/testing/device-fixture.js +70 -0
- package/lib/commonjs/testing/device-fixture.js.map +1 -0
- package/lib/commonjs/testing/expose.js +91 -0
- package/lib/commonjs/testing/expose.js.map +1 -0
- package/lib/commonjs/testing/harness.js +98 -0
- package/lib/commonjs/testing/harness.js.map +1 -0
- package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
- package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/index.js +100 -17
- package/lib/commonjs/testing/index.js.map +1 -1
- package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
- package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/serial-client.js +277 -0
- package/lib/commonjs/testing/serial-client.js.map +1 -0
- package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
- package/lib/commonjs/testing/simulated-device.js.map +1 -0
- package/lib/commonjs/testing/test-suite.js +142 -0
- package/lib/commonjs/testing/test-suite.js.map +1 -0
- package/lib/commonjs/transport.js +3 -3
- package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
- package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
- package/lib/commonjs/websocket/bridge.js +234 -0
- package/lib/commonjs/websocket/bridge.js.map +1 -0
- package/lib/commonjs/websocket/index.js +33 -0
- package/lib/commonjs/websocket/index.js.map +1 -0
- package/lib/commonjs/websocket/protocol.js +55 -0
- package/lib/commonjs/websocket/protocol.js.map +1 -0
- package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
- package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
- package/lib/typescript/src/UsbSerial.d.ts +1 -1
- package/lib/typescript/src/WebSerial.d.ts +7 -7
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/event-target.d.ts +2 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
- package/lib/typescript/src/lib/web-streams.d.ts +9 -0
- package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
- package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
- package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
- package/lib/typescript/src/testing/expose.d.ts +71 -0
- package/lib/typescript/src/testing/expose.d.ts.map +1 -0
- package/lib/typescript/src/testing/harness.d.ts +34 -0
- package/lib/typescript/src/testing/harness.d.ts.map +1 -0
- package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
- package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +18 -8
- package/lib/typescript/src/testing/index.d.ts.map +1 -1
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/serial-client.d.ts +62 -0
- package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
- package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
- package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
- package/lib/typescript/src/testing/test-suite.d.ts +75 -0
- package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
- package/lib/typescript/src/transport.d.ts +3 -3
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
- package/lib/typescript/src/websocket/bridge.d.ts +66 -0
- package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
- package/lib/typescript/src/websocket/index.d.ts +19 -0
- package/lib/typescript/src/websocket/index.d.ts.map +1 -0
- package/lib/typescript/src/websocket/protocol.d.ts +64 -0
- package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
- package/package.json +21 -3
- package/src/UsbSerial.ts +1 -1
- package/src/WebSerial.ts +134 -35
- package/src/index.ts +4 -1
- package/src/lib/event-target.ts +12 -0
- package/src/lib/web-streams.ts +43 -0
- package/src/testing/device-fixture.ts +150 -0
- package/src/testing/expose.ts +147 -0
- package/src/testing/harness.ts +124 -0
- package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
- package/src/testing/index.ts +69 -21
- package/src/testing/install-in-memory-serial-transport.ts +65 -0
- package/src/testing/serial-client.ts +313 -0
- package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
- package/src/testing/test-suite.ts +186 -0
- package/src/transport.ts +3 -3
- package/src/websocket/WebSocketSerialTransport.ts +796 -0
- package/src/websocket/bridge.ts +299 -0
- package/src/websocket/index.ts +38 -0
- package/src/websocket/protocol.ts +101 -0
- package/src/websocket/serial-device-bridge.ts +160 -0
- package/lib/commonjs/testing/install.js +0 -54
- package/lib/commonjs/testing/install.js.map +0 -1
- package/lib/commonjs/testing/serial-device.js.map +0 -1
- package/lib/commonjs/testing/virtual-serial.js.map +0 -1
- package/lib/typescript/src/testing/install.d.ts +0 -25
- package/lib/typescript/src/testing/install.d.ts.map +0 -1
- package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
- package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
- package/src/testing/install.ts +0 -65
package/TESTING.md
CHANGED
|
@@ -1,150 +1,173 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
This library is designed to be testable
|
|
4
|
-
test runner (Jest, Vitest, …) and live on a device, an emulator, or in the
|
|
5
|
-
browser. One idea makes that possible: every path to the hardware goes through a
|
|
6
|
-
single interface, [`SerialTransport`](src/transport.ts), and you can swap in a
|
|
7
|
-
pure-JavaScript implementation.
|
|
3
|
+
This library is designed to be testable without USB hardware. The same code can run in Jest, on a real Android device, in the browser, or in an emulator.
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
Serial / SerialPort ──depends on──► SerialTransport
|
|
11
|
-
▲ ▲
|
|
12
|
-
UsbSerialModule │ │ VirtualSerialTransport
|
|
13
|
-
(real, native) (in-memory, no native deps)
|
|
14
|
-
```
|
|
5
|
+
The key idea is simple: all hardware access goes through a single transport interface, [`SerialTransport`](src/transport.ts), and you can swap in a pure JavaScript transport when you do not want to talk to real hardware.
|
|
15
6
|
|
|
16
|
-
|
|
17
|
-
code runs under Node/Jest, on a real Android device, and on the web.
|
|
7
|
+
## At a glance
|
|
18
8
|
|
|
19
|
-
|
|
9
|
+
- `new Serial(transport)` for isolated unit tests
|
|
10
|
+
- `setUsbSerial(transport)` for redirecting the singleton `serial`
|
|
11
|
+
- `InMemorySerialTransport` for loopback and simulated devices
|
|
12
|
+
- `SimulatedDevice` for authoring a full peripheral model
|
|
13
|
+
- `SerialClient` for host-side protocol tests
|
|
14
|
+
- `runTestSuite()` for reusable test suites that work in Jest and on real hardware
|
|
15
|
+
- `exposeSimulatedDevice()` for WebSocket-based E2E and emulator testing
|
|
20
16
|
|
|
21
|
-
##
|
|
17
|
+
## Transport layer
|
|
22
18
|
|
|
23
|
-
`Serial` resolves its transport in three ways, in order
|
|
19
|
+
`Serial` resolves its transport in three ways, in order:
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
| Priority | How to use it | Best for |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| 1 | `new Serial(transport)` | Isolated tests and explicit control |
|
|
24
|
+
| 2 | `setUsbSerial(transport)` | Redirecting the singleton `serial` |
|
|
25
|
+
| 3 | Default native transport | Real Android hardware |
|
|
29
26
|
|
|
30
27
|
```ts
|
|
31
28
|
import {Serial, setUsbSerial, resetUsbSerial} from 'react-native-web-serial-api';
|
|
32
|
-
import {
|
|
29
|
+
import {InMemorySerialTransport} from 'react-native-web-serial-api/testing';
|
|
33
30
|
|
|
34
|
-
//
|
|
35
|
-
const transport = new
|
|
31
|
+
// Best for unit tests: the transport is explicit.
|
|
32
|
+
const transport = new InMemorySerialTransport();
|
|
36
33
|
const serial = new Serial(transport);
|
|
37
34
|
|
|
38
|
-
//
|
|
39
|
-
// getPorts()/requestPort()/addEventListener(). resetUsbSerial() restores default.
|
|
35
|
+
// Best when you want the singleton `serial` to use a virtual transport too.
|
|
40
36
|
setUsbSerial(transport);
|
|
37
|
+
|
|
38
|
+
// Restore the native default after the test.
|
|
39
|
+
resetUsbSerial();
|
|
41
40
|
```
|
|
42
41
|
|
|
43
|
-
The testing
|
|
44
|
-
|
|
42
|
+
The testing helpers live under the `react-native-web-serial-api/testing` subpath, so they stay out of your production bundle.
|
|
43
|
+
|
|
44
|
+
## Which test style should I use?
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
| Use case | Start with | Why |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| Quick byte-level smoke test | `InMemorySerialTransport` + `LoopbackDevice` | Fast and deterministic |
|
|
49
|
+
| Stateful peripheral simulation | `SimulatedDevice` | Lets you model a real device protocol |
|
|
50
|
+
| Host-side protocol tests | `SerialClient` | Removes reader/writer boilerplate |
|
|
51
|
+
| Reusable suite against virtual and real devices | `runTestSuite()` | Same cases, different runtimes |
|
|
52
|
+
| App/emulator/browser talking to a simulated device | `exposeSimulatedDevice()` | WebSocket bridge with the same simulator |
|
|
47
53
|
|
|
48
|
-
##
|
|
54
|
+
## Fast in-memory test
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
[`SerialDevice`](#simulating-a-whole-device-serialdevice) and the transport
|
|
52
|
-
reads its USB identity; `options` carries the transport-side knobs.
|
|
56
|
+
`InMemorySerialTransport` is the core test transport. It can host one or more simulated devices and it does not depend on `react-native`, which is why the same code works in Jest and in browser-style environments.
|
|
53
57
|
|
|
54
58
|
```ts
|
|
55
|
-
import {
|
|
59
|
+
import {InMemorySerialTransport, LoopbackDevice} from 'react-native-web-serial-api/testing';
|
|
56
60
|
|
|
57
|
-
const transport = new
|
|
58
|
-
latencyMs: 0,
|
|
61
|
+
const transport = new InMemorySerialTransport({
|
|
62
|
+
latencyMs: 0, // 0 = resolve on a microtask, which is nice for Jest
|
|
59
63
|
autoGrantPermission: true,
|
|
60
64
|
});
|
|
61
65
|
|
|
62
66
|
const device = transport.addDevice(
|
|
63
|
-
new
|
|
67
|
+
new LoopbackDevice({
|
|
68
|
+
usbVendorId: 0x0403,
|
|
69
|
+
usbProductId: 0x6001,
|
|
70
|
+
serialNumber: 'DEMO-1',
|
|
71
|
+
}),
|
|
64
72
|
{
|
|
65
|
-
hasPermission: true,
|
|
66
|
-
loopbackSignals: true,
|
|
73
|
+
hasPermission: true,
|
|
74
|
+
loopbackSignals: true,
|
|
67
75
|
},
|
|
68
76
|
);
|
|
69
77
|
```
|
|
70
78
|
|
|
71
|
-
`
|
|
72
|
-
built in; for anything richer, write a `SerialDevice` (next section).
|
|
79
|
+
`LoopbackDevice` echoes bytes back to the host. `SinkDevice` accepts writes and produces no output.
|
|
73
80
|
|
|
74
|
-
### Driving a device from a test
|
|
81
|
+
### Driving a device from a test
|
|
75
82
|
|
|
76
83
|
```ts
|
|
77
|
-
device.push([0x01, 0x02]);
|
|
78
|
-
device.emitError('cable fault');
|
|
79
|
-
device.attach();
|
|
80
|
-
device.detach();
|
|
81
|
-
device.loseDevice();
|
|
82
|
-
device.failNext('open');
|
|
83
|
-
device.written;
|
|
84
|
+
device.push([0x01, 0x02]); // device sends bytes to the host
|
|
85
|
+
device.emitError('cable fault'); // readable stream error
|
|
86
|
+
device.attach(); // attach again and fire connect
|
|
87
|
+
device.detach(); // detach and fire disconnect
|
|
88
|
+
device.loseDevice(); // unplug while open
|
|
89
|
+
device.failNext('open'); // make the next open() reject once
|
|
90
|
+
device.written; // everything the host wrote
|
|
84
91
|
```
|
|
85
92
|
|
|
86
|
-
|
|
87
|
-
what the next `requestPort()` returns.
|
|
93
|
+
You can also script the next permission prompt:
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
```ts
|
|
96
|
+
transport.selectNextPort(device);
|
|
97
|
+
transport.rejectNextPortPicker();
|
|
98
|
+
```
|
|
90
99
|
|
|
91
|
-
##
|
|
100
|
+
## Host-side protocol test
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
over time, reacts to control signals, and raises typed errors — extend
|
|
95
|
-
**`SerialDevice`** and override the lifecycle hooks:
|
|
102
|
+
For a more realistic peripheral, extend `SimulatedDevice` and override lifecycle hooks.
|
|
96
103
|
|
|
97
104
|
```ts
|
|
98
|
-
import {SerialDevice, VirtualSerialTransport} from 'react-native-web-serial-api/testing';
|
|
99
105
|
import {Serial} from 'react-native-web-serial-api';
|
|
106
|
+
import {InMemorySerialTransport, SimulatedDevice} from 'react-native-web-serial-api/testing';
|
|
100
107
|
|
|
101
|
-
class Thermometer extends
|
|
108
|
+
class Thermometer extends SimulatedDevice {
|
|
102
109
|
usbVendorId = 0x0403;
|
|
103
110
|
usbProductId = 0x6001;
|
|
104
111
|
#timer?: ReturnType<typeof setInterval>;
|
|
105
112
|
|
|
106
|
-
onOpen() {
|
|
113
|
+
onOpen() {
|
|
107
114
|
this.send('READY\r\n');
|
|
108
115
|
this.#timer = setInterval(() => this.send(`temp=${20 + Math.random()}C\r\n`), 1000);
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
|
|
118
|
+
onData(bytes: Uint8Array) {
|
|
119
|
+
if (String.fromCharCode(...bytes).trim() === 'ID?') {
|
|
120
|
+
this.send('ACME-TEMP\r\n');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onHostSignals() {
|
|
125
|
+
// DTR/RTS/break changed
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onClose() {
|
|
129
|
+
clearInterval(this.#timer);
|
|
112
130
|
}
|
|
113
|
-
onHostSignals(s) { /* DTR/RTS/break changed */ }
|
|
114
|
-
onClose() { clearInterval(this.#timer); }
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
const transport = new
|
|
133
|
+
const transport = new InMemorySerialTransport();
|
|
118
134
|
transport.addDevice(new Thermometer(), {hasPermission: true});
|
|
119
135
|
const serial = new Serial(transport);
|
|
120
136
|
```
|
|
121
137
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
`
|
|
126
|
-
`
|
|
127
|
-
|
|
128
|
-
`addDevice(device, options?)` reads the device's
|
|
129
|
-
`usbVendorId`/`usbProductId`/`serialNumber`; `options` carries the transport
|
|
130
|
-
knobs (`hasPermission`, `portNumber`, …).
|
|
138
|
+
Available hooks:
|
|
139
|
+
|
|
140
|
+
- `onOpen(options)`
|
|
141
|
+
- `onData(data)`
|
|
142
|
+
- `onHostSignals(signals)`
|
|
143
|
+
- `onClose()`
|
|
131
144
|
|
|
132
|
-
|
|
145
|
+
Helpers on `SimulatedDevice`:
|
|
133
146
|
|
|
134
|
-
|
|
147
|
+
- `this.send(bytesOrString)`
|
|
148
|
+
- `this.raiseError(message, name?)`
|
|
149
|
+
- `this.setSignals({dataCarrierDetect, clearToSend, ringIndicator, dataSetReady})`
|
|
150
|
+
- `this.openOptions`
|
|
135
151
|
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
## Writing unit tests
|
|
153
|
+
|
|
154
|
+
Inject the transport and exercise the real `Serial` / `SerialPort` logic.
|
|
138
155
|
|
|
139
156
|
```ts
|
|
140
157
|
import {Serial} from 'react-native-web-serial-api';
|
|
141
|
-
import {
|
|
158
|
+
import {
|
|
159
|
+
InMemorySerialTransport,
|
|
160
|
+
LoopbackDevice,
|
|
161
|
+
} from 'react-native-web-serial-api/testing';
|
|
142
162
|
|
|
143
163
|
it('echoes bytes through the streams', async () => {
|
|
144
|
-
const transport = new
|
|
145
|
-
transport.addDevice(
|
|
146
|
-
|
|
164
|
+
const transport = new InMemorySerialTransport();
|
|
165
|
+
transport.addDevice(
|
|
166
|
+
new LoopbackDevice({usbVendorId: 0x0403, usbProductId: 0x6001}),
|
|
167
|
+
{hasPermission: true},
|
|
168
|
+
);
|
|
147
169
|
|
|
170
|
+
const serial = new Serial(transport);
|
|
148
171
|
const [port] = await serial.getPorts();
|
|
149
172
|
await port.open({baudRate: 115200});
|
|
150
173
|
|
|
@@ -155,141 +178,360 @@ it('echoes bytes through the streams', async () => {
|
|
|
155
178
|
});
|
|
156
179
|
```
|
|
157
180
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
181
|
+
The library tests themselves live under [`src/__tests__/`](src/__tests__). Because the transport layer imports `react-native`, Jest needs the React Native preset. See [`jest.config.js`](jest.config.js).
|
|
182
|
+
|
|
183
|
+
## Testing as the host: `SerialClient`
|
|
184
|
+
|
|
185
|
+
`SerialClient` is a timeout-aware wrapper around any `SerialPort`. It removes the boilerplate of managing `ReadableStream` readers and `WritableStream` writers in test code.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import {SerialClient} from 'react-native-web-serial-api/testing';
|
|
189
|
+
|
|
190
|
+
const client = new SerialClient(port);
|
|
191
|
+
await client.open({baudRate: 115200});
|
|
192
|
+
|
|
193
|
+
await client.write([0x01, 0x02, 0x03]);
|
|
194
|
+
|
|
195
|
+
const echo = await client.readBytes(3);
|
|
196
|
+
const frame = await client.readUntil([0xC0]);
|
|
197
|
+
const line = await client.readLine();
|
|
198
|
+
const chunk = await client.readAvailable();
|
|
199
|
+
|
|
200
|
+
await client.expectIdle(100);
|
|
201
|
+
await client.close();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
All read methods accept `{timeout?: number}` and default to 5 seconds.
|
|
205
|
+
|
|
206
|
+
### Building a protocol client
|
|
207
|
+
|
|
208
|
+
`readAvailable()` is useful when you want to feed a framing decoder.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import {SerialClient} from 'react-native-web-serial-api/testing';
|
|
212
|
+
import {SlipDecoder} from './slip';
|
|
213
|
+
|
|
214
|
+
class MyProtocolClient {
|
|
215
|
+
#client: SerialClient;
|
|
216
|
+
#pending: MyMessage[] = [];
|
|
217
|
+
|
|
218
|
+
static async open(port: SerialPort, baudRate = 115200) {
|
|
219
|
+
const client = new MyProtocolClient(new SerialClient(port));
|
|
220
|
+
await client.#client.open({baudRate});
|
|
221
|
+
return client;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private constructor(client: SerialClient) {
|
|
225
|
+
this.#client = client;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async recv(timeoutMs = 5000): Promise<MyMessage> {
|
|
229
|
+
if (this.#pending.length) return this.#pending.shift()!;
|
|
230
|
+
|
|
231
|
+
const decoder = new SlipDecoder();
|
|
232
|
+
while (!this.#client.ended) {
|
|
233
|
+
const chunk = await this.#client.readAvailable({timeout: timeoutMs});
|
|
234
|
+
for (const frame of decoder.feed(chunk)) {
|
|
235
|
+
this.#pending.push(parseHci(frame));
|
|
236
|
+
}
|
|
237
|
+
if (this.#pending.length) return this.#pending.shift()!;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
throw new Error('port closed before message arrived');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async close() {
|
|
244
|
+
await this.#client.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## One-call fixture: `createDeviceFixture`
|
|
161
250
|
|
|
162
|
-
|
|
251
|
+
`createDeviceFixture()` wires up a `SimulatedDevice` and returns the handles you usually need in a test.
|
|
163
252
|
|
|
164
|
-
|
|
253
|
+
```ts
|
|
254
|
+
import {createDeviceFixture} from 'react-native-web-serial-api/testing';
|
|
255
|
+
import {WMBusGateway} from './devices/wmbus/WMBusGateway';
|
|
256
|
+
import {WMBusMeter} from './devices/wmbus/WMBusMeter';
|
|
257
|
+
|
|
258
|
+
const ADDRESS = {
|
|
259
|
+
manufacturerId: 0x1234,
|
|
260
|
+
deviceId: 0x56789abc,
|
|
261
|
+
version: 0x01,
|
|
262
|
+
type: 0x07,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const {client, simulatedDevice, whenOpened, whenClosed} =
|
|
266
|
+
await createDeviceFixture(new WMBusGateway('iU891A-XL'));
|
|
267
|
+
|
|
268
|
+
const opened = whenOpened();
|
|
269
|
+
await client.open({baudRate: 115200});
|
|
270
|
+
await opened;
|
|
271
|
+
|
|
272
|
+
const meter = new WMBusMeter({address: ADDRESS, payloadTemplate: [0x01]});
|
|
273
|
+
simulatedDevice.addMeter(meter);
|
|
274
|
+
meter.sendTelegram();
|
|
275
|
+
const frame = await client.readAvailable();
|
|
276
|
+
|
|
277
|
+
await whenClosed();
|
|
278
|
+
await client.close();
|
|
279
|
+
```
|
|
165
280
|
|
|
166
|
-
|
|
167
|
-
`serialConformanceTests` — self-contained cases (each builds its own
|
|
168
|
-
`Serial` + `VirtualSerialTransport`) with built-in assertions and **no
|
|
169
|
-
test-runner dependency**. It is **test-only code**: it is excluded from the
|
|
170
|
-
build and from the published npm package (it imports the shipped testing
|
|
171
|
-
utilities, not the other way round), so it adds nothing to consumers' bundles.
|
|
172
|
-
The very same array runs:
|
|
281
|
+
Multiple devices at once:
|
|
173
282
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
283
|
+
```ts
|
|
284
|
+
const {ports, transport} = await createDeviceFixture([
|
|
285
|
+
new WMBusGateway('iU891A-XL'),
|
|
286
|
+
new NmeaGpsDevice(),
|
|
287
|
+
]);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
If you pass `opts.installGlobally = true`, `createDeviceFixture()` calls `setUsbSerial(transport)` so the singleton `serial` also uses the virtual transport. Remember to call `resetUsbSerial()` in teardown.
|
|
291
|
+
|
|
292
|
+
## Lifecycle awaiting: `whenOpened` / `whenClosed`
|
|
293
|
+
|
|
294
|
+
`whenOpened()` and `whenClosed()` are available on both `createDeviceFixture()` and the lower-level `DeviceHandle`.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const device = transport.addDevice(new MyDevice(), {hasPermission: true});
|
|
298
|
+
|
|
299
|
+
const opts = await device.whenOpened(); // resolves on the next open
|
|
300
|
+
await device.whenClosed(); // resolves on the next close
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Each call returns a fresh promise for the next transition, so you can use them in reconnect loops.
|
|
304
|
+
|
|
305
|
+
## Fault injection
|
|
306
|
+
|
|
307
|
+
`DeviceHandle` includes helpers for protocol and lifecycle failures.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
device.push([0x01, 0x02]);
|
|
311
|
+
device.emitError('cable fault');
|
|
312
|
+
|
|
313
|
+
device.failNext('open');
|
|
314
|
+
device.failNext('write');
|
|
315
|
+
device.failNext('startReading');
|
|
316
|
+
device.overrunAfter(16);
|
|
317
|
+
|
|
318
|
+
device.loseDevice();
|
|
319
|
+
device.detach();
|
|
320
|
+
device.attach();
|
|
321
|
+
|
|
322
|
+
device.written;
|
|
323
|
+
device.isOpen;
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
These are useful for asserting protocol-level behavior:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
device.failNext('write');
|
|
330
|
+
await expect(client.write([1, 2])).rejects.toThrow();
|
|
331
|
+
expect(device.written).toHaveLength(0);
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Writing a test suite: `runTestSuite` + `compareTestResults`
|
|
335
|
+
|
|
336
|
+
`runTestSuite()` lets you define a list of named serial tests and run them against different ports.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import {runTestSuite, compareTestResults, type SerialTest}
|
|
340
|
+
from 'react-native-web-serial-api/testing';
|
|
341
|
+
|
|
342
|
+
const suite: SerialTest[] = [
|
|
343
|
+
{
|
|
344
|
+
name: 'echoes 4 bytes',
|
|
345
|
+
async run(client) {
|
|
346
|
+
await client.write([1, 2, 3, 4]);
|
|
347
|
+
const echo = await client.readBytes(4);
|
|
348
|
+
if (!echo.every((b, i) => b === [1, 2, 3, 4][i])) {
|
|
349
|
+
throw new Error(`echo mismatch: got ${Array.from(echo)}`);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const {port} = await createDeviceFixture(new LoopbackDevice());
|
|
356
|
+
const ref = await runTestSuite(suite, port, {open: {baudRate: 115200}});
|
|
357
|
+
const real = await runTestSuite(suite, realPort, {open: {baudRate: 115200}});
|
|
358
|
+
const agreed = compareTestResults(ref, real);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Options:
|
|
362
|
+
|
|
363
|
+
| Option | Default | Meaning |
|
|
364
|
+
| --- | --- | --- |
|
|
365
|
+
| `open` | `{baudRate: 9600}` | Forwarded to `port.open()` |
|
|
366
|
+
| `shared` | `true` | Reuse one client for all tests |
|
|
367
|
+
| `client` | - | Custom protocol client factory |
|
|
368
|
+
| `progress` | - | Live `onStart` / `onResult` callbacks |
|
|
369
|
+
|
|
370
|
+
### Custom protocol client
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import {runTestSuite, type TestClient}
|
|
374
|
+
from 'react-native-web-serial-api/testing';
|
|
375
|
+
import {HciHost} from './HciHost';
|
|
376
|
+
|
|
377
|
+
const results = await runTestSuite(hciSuite, port, {
|
|
378
|
+
open: {baudRate: 115200},
|
|
379
|
+
client: {
|
|
380
|
+
connect: (p) => HciHost.open(p),
|
|
381
|
+
disconnect: (host) => host.close(),
|
|
382
|
+
} satisfies TestClient<HciHost>,
|
|
383
|
+
});
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## WebSocket E2E: `exposeSimulatedDevice`
|
|
387
|
+
|
|
388
|
+
`exposeSimulatedDevice()` runs a simulator behind a WebSocket server so a real app, emulator, or browser can connect to it.
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import {createDeviceFixture, runTestSuite} from 'react-native-web-serial-api/testing';
|
|
392
|
+
|
|
393
|
+
const {port} = await createDeviceFixture(new WMBusGateway('iU891A-XL'));
|
|
394
|
+
const results = await runTestSuite(wmbusSuite, port, {open: {baudRate: 115200}});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
import {exposeSimulatedDevice} from 'react-native-web-serial-api/testing';
|
|
399
|
+
import {WebSocketServer} from 'ws'; // optional dependency
|
|
400
|
+
|
|
401
|
+
const ex = exposeSimulatedDevice(new WMBusGateway('iU891A-XL'), {
|
|
402
|
+
port: 8090,
|
|
403
|
+
WebSocketServer,
|
|
404
|
+
// host: '0.0.0.0', // use this for a physical device or emulator
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await ex.whenOpened();
|
|
408
|
+
await ex.close();
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`ExposedDevice` gives you:
|
|
412
|
+
|
|
413
|
+
| Field | Type | Description |
|
|
414
|
+
| --- | --- | --- |
|
|
415
|
+
| `url` | `string` | URL for `WebSocketSerialTransport` |
|
|
416
|
+
| `simulatedDevice` | `D` | The concrete simulator |
|
|
417
|
+
| `device` | `DeviceHandle` | Low-level device handle |
|
|
418
|
+
| `whenOpened()` | `Promise<SimulatedDeviceOpenOptions>` | Resolves when the app opens the port |
|
|
419
|
+
| `whenClosed()` | `Promise<void>` | Resolves when the app closes the port |
|
|
420
|
+
| `close()` | `Promise<void>` | Stops the WebSocket server |
|
|
421
|
+
|
|
422
|
+
`ws` is loaded lazily, so importing from `react-native-web-serial-api/testing` is safe in React Native. The dependency is only needed when `exposeSimulatedDevice()` is called in Node.
|
|
423
|
+
|
|
424
|
+
## Fake-timer gotcha
|
|
425
|
+
|
|
426
|
+
`InMemorySerialTransport` delivers data via `queueMicrotask()` at the default `latencyMs: 0`. If your suite uses `jest.useFakeTimers()`, microtasks still run, but `SerialClient` read timeouts use `setTimeout`, so fake timers will stall reads unless you advance them.
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
beforeAll(() => {
|
|
430
|
+
jest.useFakeTimers({
|
|
431
|
+
doNotFake: ['queueMicrotask'],
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('streams data periodically', async () => {
|
|
436
|
+
// start the device timer...
|
|
437
|
+
jest.advanceTimersByTime(5000);
|
|
438
|
+
const line = await client.readLine();
|
|
439
|
+
});
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
If your suite does not use periodic timers, real timers are usually simpler.
|
|
443
|
+
|
|
444
|
+
## The conformance suite
|
|
445
|
+
|
|
446
|
+
[`src/__tests__/conformance-suite.ts`](src/__tests__/conformance-suite.ts) exports `serialConformanceTests`, a set of self-contained cases that each build their own `Serial` and `InMemorySerialTransport`.
|
|
447
|
+
|
|
448
|
+
The suite is test-only code. It is excluded from the build and from the published package, and the same cases can run in both Jest and on-device.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import {serialConformanceTests} from './conformance-suite';
|
|
452
|
+
|
|
453
|
+
for (const test of serialConformanceTests) {
|
|
454
|
+
it(test.name, () => test.run());
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The same suite can also run through `runSerialConformance()`, which returns structured pass/fail results for use in the example app's Self Test screen.
|
|
183
459
|
|
|
184
460
|
```ts
|
|
185
461
|
import {runSerialConformance, runRealDeviceSmokeTest} from './conformance-suite';
|
|
186
462
|
|
|
187
|
-
const results = await runSerialConformance();
|
|
188
|
-
const smoke = await runRealDeviceSmokeTest(serial);
|
|
463
|
+
const results = await runSerialConformance();
|
|
464
|
+
const smoke = await runRealDeviceSmokeTest(serial);
|
|
189
465
|
```
|
|
190
466
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
## WPT spec compliance
|
|
194
|
-
|
|
195
|
-
The `WPT …` cases **at the end of the conformance suite**
|
|
196
|
-
([`src/__tests__/conformance-suite.ts`](src/__tests__/conformance-suite.ts)) are ports of the
|
|
197
|
-
**official Web Platform Tests** for the Web Serial API (vendored in
|
|
198
|
-
`tmp/serial/`), so the spec's own test logic runs against our polyfill via the
|
|
199
|
-
virtual loopback device — proof of W3C compliance, not just our own assertions.
|
|
200
|
-
They live in the conformance suite rather than a separate Jest-only file, so the
|
|
201
|
-
same spec tests run **under Jest *and* on-device** (Self Test screen), not just
|
|
202
|
-
in a browser. They cover loopback read/write (small + large, repeated),
|
|
203
|
-
`readable.cancel()` discarding buffered data, hardware flow-control
|
|
204
|
-
back-pressure, typed `BreakError`/`BufferOverrunError`, large PRNG-stream
|
|
205
|
-
integrity, disconnect during a pending read/write, and the interface (IDL) shape.
|
|
206
|
-
|
|
207
|
-
Porting the spec faithfully originally surfaced five real gaps in the polyfill
|
|
208
|
-
(typed `BreakError`/`BufferOverrunError` on the readable; disconnect rejecting
|
|
209
|
-
the pending read/write with `NetworkError`; the `disconnect` event `target`; and
|
|
210
|
-
a leaked subscription on `readable.cancel()`). All are now **fixed** in
|
|
211
|
-
`WebSerial.ts`, and these cases pass as regression guards. The browser-security
|
|
212
|
-
WPT files (permission prompts, secure-context, `requestPort()` user-gesture
|
|
213
|
-
gating) don't apply to a React Native polyfill and are intentionally not ported;
|
|
214
|
-
the PRNG-stream length is scaled down from the upstream 10 MB so the on-device
|
|
215
|
-
Self Test stays fast.
|
|
216
|
-
|
|
217
|
-
---
|
|
467
|
+
### Web Platform Tests
|
|
218
468
|
|
|
219
|
-
|
|
469
|
+
The `WPT ...` cases at the end of the conformance suite are ports of the official Web Platform Tests for the Web Serial API. They run against the virtual loopback device so the spec logic is exercised under Jest and on-device.
|
|
470
|
+
|
|
471
|
+
These cases cover things like:
|
|
220
472
|
|
|
221
|
-
|
|
473
|
+
- loopback read/write
|
|
474
|
+
- `readable.cancel()` behavior
|
|
475
|
+
- hardware flow-control back-pressure
|
|
476
|
+
- typed read errors such as `BreakError` and `BufferOverrunError`
|
|
477
|
+
- disconnect handling during pending operations
|
|
478
|
+
- the IDL shape of the interface
|
|
222
479
|
|
|
223
|
-
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
- **Virtual device (demo)** toggle (overflow menu) injects a
|
|
227
|
-
`VirtualSerialTransport` (an FTDI `EchoDevice` + a CP210x `SensorDevice`, both
|
|
228
|
-
authored as `SerialDevice`s — see [example/src/devices/](example/src/devices))
|
|
229
|
-
so the whole Devices → Connect → Terminal flow runs hardware-free.
|
|
480
|
+
The browser-specific WPT cases around permission prompts and secure-context gating are intentionally not ported because they do not map to a React Native runtime.
|
|
481
|
+
|
|
482
|
+
## On-device testing (example app)
|
|
230
483
|
|
|
231
|
-
|
|
232
|
-
> on Android (where `serial` is this library's polyfill). On web `serial` is the
|
|
233
|
-
> browser's native `navigator.serial`, which the library cannot inject into — but
|
|
234
|
-
> the Self-Test screen still works everywhere because it builds its own
|
|
235
|
-
> `new Serial(virtualTransport)`.
|
|
484
|
+
The [example app](example) includes two ways to test without hardware:
|
|
236
485
|
|
|
237
|
-
|
|
486
|
+
- **Self Test** screen: runs the conformance suite in-app and shows pass/fail
|
|
487
|
+
- **Virtual device (demo)** mode: injects an `InMemorySerialTransport` with simulated devices so the Devices -> Connect -> Terminal flow works without hardware
|
|
488
|
+
|
|
489
|
+
> Demo mode redirects the app's live serial transport, which only works on Android. On web, `serial` is the browser's native `navigator.serial`, so the library cannot override it. The Self Test screen still works everywhere because it creates its own `new Serial(new InMemorySerialTransport())`.
|
|
238
490
|
|
|
239
491
|
## E2E in the emulator
|
|
240
492
|
|
|
241
|
-
|
|
242
|
-
USB hardware. Install the mock once at startup behind your own flag:
|
|
493
|
+
You can also run UI E2E tests against an app with no USB hardware by installing the mock transport at startup behind your own flag.
|
|
243
494
|
|
|
244
495
|
```ts
|
|
245
|
-
|
|
246
|
-
|
|
496
|
+
import {
|
|
497
|
+
LoopbackDevice,
|
|
498
|
+
installInMemorySerialTransport,
|
|
499
|
+
} from 'react-native-web-serial-api/testing';
|
|
247
500
|
import {MyThermometer} from './devices/MyThermometer';
|
|
248
501
|
|
|
249
|
-
|
|
250
|
-
enabled: process.env.RNWS_SERIAL_MOCK === '1',
|
|
251
|
-
devices: [new
|
|
502
|
+
installInMemorySerialTransport({
|
|
503
|
+
enabled: process.env.RNWS_SERIAL_MOCK === '1',
|
|
504
|
+
devices: [new LoopbackDevice(), new MyThermometer()],
|
|
252
505
|
});
|
|
253
506
|
```
|
|
254
507
|
|
|
255
|
-
`
|
|
256
|
-
`navigator.serial` now talks to your simulated devices.
|
|
508
|
+
`installInMemorySerialTransport()` builds an `InMemorySerialTransport` and calls `setUsbSerial()`, so `navigator.serial` now talks to your simulated devices.
|
|
257
509
|
|
|
258
|
-
The example app ships
|
|
259
|
-
that drives the real UI against the in-app mock (via the demo toggle):
|
|
510
|
+
The example app ships Maestro tests in [`example/.maestro/`](example/.maestro):
|
|
260
511
|
|
|
261
512
|
```sh
|
|
262
|
-
|
|
263
|
-
npm --prefix example run
|
|
264
|
-
npm --prefix example run e2e # maestro test .maestro
|
|
513
|
+
npm --prefix example run android
|
|
514
|
+
npm --prefix example run e2e
|
|
265
515
|
|
|
266
|
-
#
|
|
516
|
+
# or run host Jest first, then emulator E2E
|
|
267
517
|
npm run test:host+emulator
|
|
268
518
|
```
|
|
269
519
|
|
|
270
|
-
- `demo-echo.yaml`
|
|
271
|
-
|
|
272
|
-
- `self-test.yaml` — open *Self test* → run the conformance suite → assert green.
|
|
273
|
-
|
|
274
|
-
This isn't wired into GitHub CI (it needs an emulator); run it locally or in an
|
|
275
|
-
emulator-equipped job. [Detox](https://wix.github.io/Detox/) works the same way —
|
|
276
|
-
the mock is what makes either runner hardware-free.
|
|
277
|
-
|
|
278
|
-
---
|
|
520
|
+
- `demo-echo.yaml` enables demo mode, connects to the echo device, sends a line, and checks that it round-trips.
|
|
521
|
+
- `self-test.yaml` opens Self Test, runs the conformance suite, and asserts success.
|
|
279
522
|
|
|
280
523
|
## Running the tests
|
|
281
524
|
|
|
282
525
|
```sh
|
|
283
|
-
npm test
|
|
284
|
-
npm run test:watch
|
|
526
|
+
npm test
|
|
527
|
+
npm run test:watch
|
|
285
528
|
npm run typecheck
|
|
286
529
|
npm run lint
|
|
287
530
|
|
|
288
|
-
npm test --prefix example
|
|
531
|
+
npm test --prefix example
|
|
289
532
|
```
|
|
290
533
|
|
|
291
|
-
CI runs
|
|
292
|
-
[`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
534
|
+
CI runs these checks plus a build step on every push or pull request.
|
|
293
535
|
|
|
294
536
|
## Coverage
|
|
295
537
|
|
|
@@ -297,5 +539,4 @@ CI runs all of the above plus a build check on every push/PR — see
|
|
|
297
539
|
npm run test:coverage
|
|
298
540
|
```
|
|
299
541
|
|
|
300
|
-
|
|
301
|
-
`coverage/lcov-report/index.html` (config in [`jest.config.js`](jest.config.js)).
|
|
542
|
+
This prints a summary and writes an HTML report to `coverage/lcov-report/index.html`.
|