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.
Files changed (112) hide show
  1. package/README.md +188 -117
  2. package/TESTING.md +417 -176
  3. package/android/build.gradle +14 -0
  4. package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
  5. package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
  6. package/bin/expose-serial.js +205 -0
  7. package/lib/commonjs/UsbSerial.js +1 -1
  8. package/lib/commonjs/WebSerial.js +110 -26
  9. package/lib/commonjs/WebSerial.js.map +1 -1
  10. package/lib/commonjs/index.js +2 -2
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/lib/event-target.js +3 -1
  13. package/lib/commonjs/lib/event-target.js.map +1 -1
  14. package/lib/commonjs/lib/web-streams.js +42 -0
  15. package/lib/commonjs/lib/web-streams.js.map +1 -0
  16. package/lib/commonjs/testing/device-fixture.js +70 -0
  17. package/lib/commonjs/testing/device-fixture.js.map +1 -0
  18. package/lib/commonjs/testing/expose.js +91 -0
  19. package/lib/commonjs/testing/expose.js.map +1 -0
  20. package/lib/commonjs/testing/harness.js +98 -0
  21. package/lib/commonjs/testing/harness.js.map +1 -0
  22. package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
  23. package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
  24. package/lib/commonjs/testing/index.js +100 -17
  25. package/lib/commonjs/testing/index.js.map +1 -1
  26. package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
  27. package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
  28. package/lib/commonjs/testing/serial-client.js +277 -0
  29. package/lib/commonjs/testing/serial-client.js.map +1 -0
  30. package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
  31. package/lib/commonjs/testing/simulated-device.js.map +1 -0
  32. package/lib/commonjs/testing/test-suite.js +142 -0
  33. package/lib/commonjs/testing/test-suite.js.map +1 -0
  34. package/lib/commonjs/transport.js +3 -3
  35. package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
  36. package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
  37. package/lib/commonjs/websocket/bridge.js +234 -0
  38. package/lib/commonjs/websocket/bridge.js.map +1 -0
  39. package/lib/commonjs/websocket/index.js +33 -0
  40. package/lib/commonjs/websocket/index.js.map +1 -0
  41. package/lib/commonjs/websocket/protocol.js +55 -0
  42. package/lib/commonjs/websocket/protocol.js.map +1 -0
  43. package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
  44. package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
  45. package/lib/typescript/src/UsbSerial.d.ts +1 -1
  46. package/lib/typescript/src/WebSerial.d.ts +7 -7
  47. package/lib/typescript/src/WebSerial.d.ts.map +1 -1
  48. package/lib/typescript/src/index.d.ts +1 -1
  49. package/lib/typescript/src/index.d.ts.map +1 -1
  50. package/lib/typescript/src/lib/event-target.d.ts +2 -0
  51. package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
  52. package/lib/typescript/src/lib/web-streams.d.ts +9 -0
  53. package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
  54. package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
  55. package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
  56. package/lib/typescript/src/testing/expose.d.ts +71 -0
  57. package/lib/typescript/src/testing/expose.d.ts.map +1 -0
  58. package/lib/typescript/src/testing/harness.d.ts +34 -0
  59. package/lib/typescript/src/testing/harness.d.ts.map +1 -0
  60. package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
  61. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
  62. package/lib/typescript/src/testing/index.d.ts +18 -8
  63. package/lib/typescript/src/testing/index.d.ts.map +1 -1
  64. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
  65. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
  66. package/lib/typescript/src/testing/serial-client.d.ts +62 -0
  67. package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
  68. package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
  69. package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
  70. package/lib/typescript/src/testing/test-suite.d.ts +75 -0
  71. package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
  72. package/lib/typescript/src/transport.d.ts +3 -3
  73. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
  74. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
  75. package/lib/typescript/src/websocket/bridge.d.ts +66 -0
  76. package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
  77. package/lib/typescript/src/websocket/index.d.ts +19 -0
  78. package/lib/typescript/src/websocket/index.d.ts.map +1 -0
  79. package/lib/typescript/src/websocket/protocol.d.ts +64 -0
  80. package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
  81. package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
  82. package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
  83. package/package.json +21 -3
  84. package/src/UsbSerial.ts +1 -1
  85. package/src/WebSerial.ts +134 -35
  86. package/src/index.ts +4 -1
  87. package/src/lib/event-target.ts +12 -0
  88. package/src/lib/web-streams.ts +43 -0
  89. package/src/testing/device-fixture.ts +150 -0
  90. package/src/testing/expose.ts +147 -0
  91. package/src/testing/harness.ts +124 -0
  92. package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
  93. package/src/testing/index.ts +69 -21
  94. package/src/testing/install-in-memory-serial-transport.ts +65 -0
  95. package/src/testing/serial-client.ts +313 -0
  96. package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
  97. package/src/testing/test-suite.ts +186 -0
  98. package/src/transport.ts +3 -3
  99. package/src/websocket/WebSocketSerialTransport.ts +796 -0
  100. package/src/websocket/bridge.ts +299 -0
  101. package/src/websocket/index.ts +38 -0
  102. package/src/websocket/protocol.ts +101 -0
  103. package/src/websocket/serial-device-bridge.ts +160 -0
  104. package/lib/commonjs/testing/install.js +0 -54
  105. package/lib/commonjs/testing/install.js.map +0 -1
  106. package/lib/commonjs/testing/serial-device.js.map +0 -1
  107. package/lib/commonjs/testing/virtual-serial.js.map +0 -1
  108. package/lib/typescript/src/testing/install.d.ts +0 -25
  109. package/lib/typescript/src/testing/install.d.ts.map +0 -1
  110. package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
  111. package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
  112. 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 **without USB hardware** both in a
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
- Because `VirtualSerialTransport` has **no `react-native` dependency**, the same
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
- ## The transport seam
17
+ ## Transport layer
22
18
 
23
- `Serial` resolves its transport in three ways, in order of precedence:
19
+ `Serial` resolves its transport in three ways, in order:
24
20
 
25
- 1. **Constructor injection** `new Serial(transport)` (best for isolated tests).
26
- 2. **Global override** `setUsbSerial(transport)` affects the singleton `serial`
27
- and any `new Serial()` created without an explicit transport.
28
- 3. **Default** the real `UsbSerialModule` backed by the native TurboModule.
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 {VirtualSerialTransport} from 'react-native-web-serial-api/testing';
29
+ import {InMemorySerialTransport} from 'react-native-web-serial-api/testing';
33
30
 
34
- // (1) explicit recommended in unit tests
35
- const transport = new VirtualSerialTransport();
31
+ // Best for unit tests: the transport is explicit.
32
+ const transport = new InMemorySerialTransport();
36
33
  const serial = new Serial(transport);
37
34
 
38
- // (2) global redirects the singleton `serial` too. Call it before the first
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 utilities live in the **`react-native-web-serial-api/testing`**
44
- subpath, so they stay out of your production bundle.
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
- ## VirtualSerialTransport
54
+ ## Fast in-memory test
49
55
 
50
- An in-memory transport backing one or more simulated devices. You register a
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 {VirtualSerialTransport, EchoDevice} from 'react-native-web-serial-api/testing';
59
+ import {InMemorySerialTransport, LoopbackDevice} from 'react-native-web-serial-api/testing';
56
60
 
57
- const transport = new VirtualSerialTransport({
58
- latencyMs: 0, // 0 = resolve on a microtask (deterministic for Jest)
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 EchoDevice({usbVendorId: 0x0403, usbProductId: 0x6001, serialNumber: 'DEMO-1'}),
67
+ new LoopbackDevice({
68
+ usbVendorId: 0x0403,
69
+ usbProductId: 0x6001,
70
+ serialNumber: 'DEMO-1',
71
+ }),
64
72
  {
65
- hasPermission: true, // false → hidden from getPorts() until requestPort()
66
- loopbackSignals: true, // DTR→DSR+DCD, RTS→CTS, so getSignals reflects setSignals
73
+ hasPermission: true,
74
+ loopbackSignals: true,
67
75
  },
68
76
  );
69
77
  ```
70
78
 
71
- `EchoDevice` (loopback) and `SilentDevice` (accepts writes, sends nothing) are
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/UI
81
+ ### Driving a device from a test
75
82
 
76
83
  ```ts
77
- device.push([0x01, 0x02]); // inbound bytes as if the device sent them
78
- device.emitError('cable fault'); // raise a read error on the readable stream
79
- device.attach(); // (re)attach fires "connect" (new deviceId)
80
- device.detach(); // detach fires "disconnect"
81
- device.loseDevice(); // unplug while open: errors the stream, disconnects
82
- device.failNext('open'); // make the next open()/write()/… reject once
83
- device.written; // number[][] — everything the host wrote
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
- `transport.selectNextPort(device)` / `transport.rejectNextPortPicker()` script
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
- ## Simulating a whole device (`SerialDevice`)
100
+ ## Host-side protocol test
92
101
 
93
- To model a *whole* peripheral a stateful protocol that greets on open, streams
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 SerialDevice {
108
+ class Thermometer extends SimulatedDevice {
102
109
  usbVendorId = 0x0403;
103
110
  usbProductId = 0x6001;
104
111
  #timer?: ReturnType<typeof setInterval>;
105
112
 
106
- onOpen() { // host opened the port
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
- onData(bytes: Uint8Array) { // host wrote to the device
111
- if (String.fromCharCode(...bytes).trim() === 'ID?') this.send('ACME-TEMP\r\n');
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 VirtualSerialTransport();
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
- Hooks: `onOpen(options)`, `onData(data)`, `onHostSignals(signals)`, `onClose()`
123
- (all optional, may be async). Helpers: `this.send(bytes|string)`,
124
- `this.raiseError(message, name?)` (e.g. `'BreakError'`),
125
- `this.setSignals({dataCarrierDetect, clearToSend, ringIndicator, dataSetReady})`,
126
- `this.openOptions`. **`EchoDevice`** (loopback), **`SilentDevice`**, and
127
- **`LineDevice`** (buffers to `\n`, calls `onLine(line)`) are built in.
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
- ## Writing unit tests (Jest)
147
+ - `this.send(bytesOrString)`
148
+ - `this.raiseError(message, name?)`
149
+ - `this.setSignals({dataCarrierDetect, clearToSend, ringIndicator, dataSetReady})`
150
+ - `this.openOptions`
135
151
 
136
- No native mocks required — inject the transport and exercise the real
137
- `Serial`/`SerialPort` logic and streams:
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 {VirtualSerialTransport} from 'react-native-web-serial-api/testing';
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 VirtualSerialTransport();
145
- transport.addDevice({usbVendorId: 0x0403, usbProductId: 0x6001, hasPermission: true});
146
- const serial = new Serial(transport);
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
- See [`src/__tests__/`](src/__tests__) for the library's own suites. Note: the
159
- seam imports `react-native`, so a Jest setup needs the RN preset — see this
160
- repo's [`jest.config.js`](jest.config.js).
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
- ## The conformance suite (one suite, two runtimes)
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
- [`src/__tests__/conformance-suite.ts`](src/__tests__/conformance-suite.ts) exports
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
- - **under Jest** — [`src/__tests__/conformance.test.ts`](src/__tests__/conformance.test.ts):
175
- ```ts
176
- import {serialConformanceTests} from './conformance-suite';
177
- for (const t of serialConformanceTests) it(t.name, () => t.run());
178
- ```
179
- - **on a device** — via `runSerialConformance()`, which returns a structured
180
- pass/fail result per test (it never throws). The example app's Self-Test
181
- screen imports it directly from the test folder (a dev-only import see
182
- [`example/src/screens/SelfTestScreen.tsx`](example/src/screens/SelfTestScreen.tsx)).
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(); // virtual, full suite
188
- const smoke = await runRealDeviceSmokeTest(serial); // small, safe, real device
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
- ## On-device testing (example app)
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
- The [example app](example) ships two ways to test on real hardware (or none):
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
- - **Self Test screen** (overflow menu *Self test…*) runs the conformance suite
224
- in-app and shows pass/fail, plus a *Run on connected device* smoke test. This
225
- works on Android, web, and in an emulator with **no device attached**.
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
- > **Platform note:** demo mode redirects the app's live serial, which only works
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
- The same `SerialDevice` mocks let you run **UI E2E tests** against an app with no
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
- // index.js — debug/E2E build only
246
- import {installSerialMock, EchoDevice} from 'react-native-web-serial-api/testing';
496
+ import {
497
+ LoopbackDevice,
498
+ installInMemorySerialTransport,
499
+ } from 'react-native-web-serial-api/testing';
247
500
  import {MyThermometer} from './devices/MyThermometer';
248
501
 
249
- installSerialMock({
250
- enabled: process.env.RNWS_SERIAL_MOCK === '1', // your own gate
251
- devices: [new EchoDevice(), new MyThermometer()],
502
+ installInMemorySerialTransport({
503
+ enabled: process.env.RNWS_SERIAL_MOCK === '1',
504
+ devices: [new LoopbackDevice(), new MyThermometer()],
252
505
  });
253
506
  ```
254
507
 
255
- `installSerialMock` builds a `VirtualSerialTransport` and calls `setUsbSerial`, so
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 a **Maestro** suite ([example/.maestro/](example/.maestro))
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
- # start an Android emulator, then:
263
- npm --prefix example run android # build + install the debug app (Metro)
264
- npm --prefix example run e2e # maestro test .maestro
513
+ npm --prefix example run android
514
+ npm --prefix example run e2e
265
515
 
266
- # from the repo root: run host Jest first, then emulator E2E
516
+ # or run host Jest first, then emulator E2E
267
517
  npm run test:host+emulator
268
518
  ```
269
519
 
270
- - `demo-echo.yaml` enable demo mode connect to the FTDI echo device send a
271
- line in the Terminal assert it round-trips.
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 # library unit + conformance suite
284
- npm run test:watch # watch mode
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 # example app tests
531
+ npm test --prefix example
289
532
  ```
290
533
 
291
- CI runs all of the above plus a build check on every push/PR see
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
- Prints a per-file table + summary and writes a browsable HTML report to
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`.