react-native-web-serial-api 0.0.3 → 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 (177) hide show
  1. package/README.md +198 -104
  2. package/TESTING.md +542 -0
  3. package/android/build.gradle +16 -2
  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 +58 -26
  8. package/lib/commonjs/UsbSerial.js.map +1 -1
  9. package/lib/commonjs/WebSerial.js +273 -77
  10. package/lib/commonjs/WebSerial.js.map +1 -1
  11. package/lib/commonjs/index.js +15 -3
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/lib/dom-exception.js +176 -0
  14. package/lib/commonjs/lib/dom-exception.js.map +1 -0
  15. package/lib/commonjs/lib/event-target.js +140 -0
  16. package/lib/commonjs/lib/event-target.js.map +1 -0
  17. package/lib/commonjs/lib/promise.js +23 -0
  18. package/lib/commonjs/lib/promise.js.map +1 -0
  19. package/lib/commonjs/lib/web-streams.js +42 -0
  20. package/lib/commonjs/lib/web-streams.js.map +1 -0
  21. package/lib/commonjs/testing/device-fixture.js +70 -0
  22. package/lib/commonjs/testing/device-fixture.js.map +1 -0
  23. package/lib/commonjs/testing/expose.js +91 -0
  24. package/lib/commonjs/testing/expose.js.map +1 -0
  25. package/lib/commonjs/testing/harness.js +98 -0
  26. package/lib/commonjs/testing/harness.js.map +1 -0
  27. package/lib/commonjs/testing/in-memory-serial-transport.js +653 -0
  28. package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
  29. package/lib/commonjs/testing/index.js +153 -0
  30. package/lib/commonjs/testing/index.js.map +1 -0
  31. package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
  32. package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
  33. package/lib/commonjs/testing/serial-client.js +277 -0
  34. package/lib/commonjs/testing/serial-client.js.map +1 -0
  35. package/lib/commonjs/testing/simulated-device.js +164 -0
  36. package/lib/commonjs/testing/simulated-device.js.map +1 -0
  37. package/lib/commonjs/testing/test-suite.js +142 -0
  38. package/lib/commonjs/testing/test-suite.js.map +1 -0
  39. package/lib/commonjs/transport.js +61 -0
  40. package/lib/commonjs/transport.js.map +1 -0
  41. package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
  42. package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
  43. package/lib/commonjs/websocket/bridge.js +234 -0
  44. package/lib/commonjs/websocket/bridge.js.map +1 -0
  45. package/lib/commonjs/websocket/index.js +33 -0
  46. package/lib/commonjs/websocket/index.js.map +1 -0
  47. package/lib/commonjs/websocket/protocol.js +55 -0
  48. package/lib/commonjs/websocket/protocol.js.map +1 -0
  49. package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
  50. package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
  51. package/lib/typescript/src/UsbSerial.d.ts +24 -67
  52. package/lib/typescript/src/UsbSerial.d.ts.map +1 -1
  53. package/lib/typescript/src/WebSerial.d.ts +16 -7
  54. package/lib/typescript/src/WebSerial.d.ts.map +1 -1
  55. package/lib/typescript/src/index.d.ts +3 -1
  56. package/lib/typescript/src/index.d.ts.map +1 -1
  57. package/lib/typescript/src/lib/dom-exception.d.ts +100 -0
  58. package/lib/typescript/src/lib/dom-exception.d.ts.map +1 -0
  59. package/lib/typescript/src/lib/event-target.d.ts +55 -0
  60. package/lib/typescript/src/lib/event-target.d.ts.map +1 -0
  61. package/lib/typescript/src/lib/promise.d.ts +11 -0
  62. package/lib/typescript/src/lib/promise.d.ts.map +1 -0
  63. package/lib/typescript/src/lib/web-streams.d.ts +9 -0
  64. package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
  65. package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
  66. package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
  67. package/lib/typescript/src/testing/expose.d.ts +71 -0
  68. package/lib/typescript/src/testing/expose.d.ts.map +1 -0
  69. package/lib/typescript/src/testing/harness.d.ts +34 -0
  70. package/lib/typescript/src/testing/harness.d.ts.map +1 -0
  71. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts +216 -0
  72. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
  73. package/lib/typescript/src/testing/index.d.ts +33 -0
  74. package/lib/typescript/src/testing/index.d.ts.map +1 -0
  75. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
  76. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
  77. package/lib/typescript/src/testing/serial-client.d.ts +62 -0
  78. package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
  79. package/lib/typescript/src/testing/simulated-device.d.ts +127 -0
  80. package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
  81. package/lib/typescript/src/testing/test-suite.d.ts +75 -0
  82. package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
  83. package/lib/typescript/src/transport.d.ts +131 -0
  84. package/lib/typescript/src/transport.d.ts.map +1 -0
  85. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
  86. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
  87. package/lib/typescript/src/websocket/bridge.d.ts +66 -0
  88. package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
  89. package/lib/typescript/src/websocket/index.d.ts +19 -0
  90. package/lib/typescript/src/websocket/index.d.ts.map +1 -0
  91. package/lib/typescript/src/websocket/protocol.d.ts +64 -0
  92. package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
  93. package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
  94. package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
  95. package/package.json +57 -3
  96. package/src/UsbSerial.ts +65 -90
  97. package/src/WebSerial.ts +351 -113
  98. package/src/index.ts +6 -8
  99. package/src/lib/dom-exception.ts +129 -60
  100. package/src/lib/event-target.ts +58 -21
  101. package/src/lib/promise.ts +7 -7
  102. package/src/lib/web-streams.ts +43 -0
  103. package/src/testing/device-fixture.ts +150 -0
  104. package/src/testing/expose.ts +147 -0
  105. package/src/testing/harness.ts +124 -0
  106. package/src/testing/in-memory-serial-transport.ts +840 -0
  107. package/src/testing/index.ts +90 -0
  108. package/src/testing/install-in-memory-serial-transport.ts +65 -0
  109. package/src/testing/serial-client.ts +313 -0
  110. package/src/testing/simulated-device.ts +193 -0
  111. package/src/testing/test-suite.ts +186 -0
  112. package/src/transport.ts +200 -0
  113. package/src/websocket/WebSocketSerialTransport.ts +796 -0
  114. package/src/websocket/bridge.ts +299 -0
  115. package/src/websocket/index.ts +38 -0
  116. package/src/websocket/protocol.ts +101 -0
  117. package/src/websocket/serial-device-bridge.ts +160 -0
  118. package/babel.config.js +0 -3
  119. package/biome.json +0 -35
  120. package/example/.watchmanconfig +0 -1
  121. package/example/App.tsx +0 -71
  122. package/example/__tests__/App.test.tsx +0 -16
  123. package/example/__tests__/connectEvents.test.tsx +0 -81
  124. package/example/__tests__/getPorts.test.tsx +0 -140
  125. package/example/android/app/build.gradle +0 -120
  126. package/example/android/app/debug.keystore +0 -0
  127. package/example/android/app/proguard-rules.pro +0 -10
  128. package/example/android/app/src/debug/AndroidManifest.xml +0 -9
  129. package/example/android/app/src/main/AndroidManifest.xml +0 -38
  130. package/example/android/app/src/main/java/dev/uzlopak/MainActivity.kt +0 -22
  131. package/example/android/app/src/main/java/dev/uzlopak/MainApplication.kt +0 -41
  132. package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +0 -37
  133. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  134. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  135. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  136. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  137. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  138. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  139. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  140. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  141. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  142. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  143. package/example/android/app/src/main/res/values/strings.xml +0 -3
  144. package/example/android/app/src/main/res/values/styles.xml +0 -9
  145. package/example/android/build.gradle +0 -22
  146. package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  147. package/example/android/gradle/wrapper/gradle-wrapper.properties +0 -7
  148. package/example/android/gradle.properties +0 -47
  149. package/example/android/gradlew +0 -252
  150. package/example/android/gradlew.bat +0 -94
  151. package/example/android/settings.gradle +0 -6
  152. package/example/app.json +0 -4
  153. package/example/babel.config.js +0 -21
  154. package/example/biome.json +0 -47
  155. package/example/deploy.sh +0 -11
  156. package/example/index.html +0 -26
  157. package/example/index.js +0 -9
  158. package/example/index.web.js +0 -8
  159. package/example/jest.config.js +0 -12
  160. package/example/metro.config.js +0 -58
  161. package/example/package-lock.json +0 -14510
  162. package/example/package.json +0 -48
  163. package/example/react-native.config.js +0 -17
  164. package/example/src/components/AppBar.tsx +0 -73
  165. package/example/src/components/Menu.tsx +0 -90
  166. package/example/src/components/SingleChoiceDialog.tsx +0 -120
  167. package/example/src/screens/ConnectScreen.tsx +0 -195
  168. package/example/src/screens/DevicesScreen.tsx +0 -252
  169. package/example/src/screens/TerminalScreen.tsx +0 -572
  170. package/example/src/settings.ts +0 -43
  171. package/example/src/theme.ts +0 -19
  172. package/example/src/util/TextUtil.ts +0 -129
  173. package/example/tsconfig.json +0 -10
  174. package/example/vite.config.mjs +0 -55
  175. package/scripts/deploy-release.sh +0 -127
  176. package/tsconfig.build.json +0 -7
  177. package/tsconfig.json +0 -20
package/TESTING.md ADDED
@@ -0,0 +1,542 @@
1
+ # Testing
2
+
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.
4
+
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.
6
+
7
+ ## At a glance
8
+
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
16
+
17
+ ## Transport layer
18
+
19
+ `Serial` resolves its transport in three ways, in order:
20
+
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 |
26
+
27
+ ```ts
28
+ import {Serial, setUsbSerial, resetUsbSerial} from 'react-native-web-serial-api';
29
+ import {InMemorySerialTransport} from 'react-native-web-serial-api/testing';
30
+
31
+ // Best for unit tests: the transport is explicit.
32
+ const transport = new InMemorySerialTransport();
33
+ const serial = new Serial(transport);
34
+
35
+ // Best when you want the singleton `serial` to use a virtual transport too.
36
+ setUsbSerial(transport);
37
+
38
+ // Restore the native default after the test.
39
+ resetUsbSerial();
40
+ ```
41
+
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
+
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 |
53
+
54
+ ## Fast in-memory test
55
+
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.
57
+
58
+ ```ts
59
+ import {InMemorySerialTransport, LoopbackDevice} from 'react-native-web-serial-api/testing';
60
+
61
+ const transport = new InMemorySerialTransport({
62
+ latencyMs: 0, // 0 = resolve on a microtask, which is nice for Jest
63
+ autoGrantPermission: true,
64
+ });
65
+
66
+ const device = transport.addDevice(
67
+ new LoopbackDevice({
68
+ usbVendorId: 0x0403,
69
+ usbProductId: 0x6001,
70
+ serialNumber: 'DEMO-1',
71
+ }),
72
+ {
73
+ hasPermission: true,
74
+ loopbackSignals: true,
75
+ },
76
+ );
77
+ ```
78
+
79
+ `LoopbackDevice` echoes bytes back to the host. `SinkDevice` accepts writes and produces no output.
80
+
81
+ ### Driving a device from a test
82
+
83
+ ```ts
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
91
+ ```
92
+
93
+ You can also script the next permission prompt:
94
+
95
+ ```ts
96
+ transport.selectNextPort(device);
97
+ transport.rejectNextPortPicker();
98
+ ```
99
+
100
+ ## Host-side protocol test
101
+
102
+ For a more realistic peripheral, extend `SimulatedDevice` and override lifecycle hooks.
103
+
104
+ ```ts
105
+ import {Serial} from 'react-native-web-serial-api';
106
+ import {InMemorySerialTransport, SimulatedDevice} from 'react-native-web-serial-api/testing';
107
+
108
+ class Thermometer extends SimulatedDevice {
109
+ usbVendorId = 0x0403;
110
+ usbProductId = 0x6001;
111
+ #timer?: ReturnType<typeof setInterval>;
112
+
113
+ onOpen() {
114
+ this.send('READY\r\n');
115
+ this.#timer = setInterval(() => this.send(`temp=${20 + Math.random()}C\r\n`), 1000);
116
+ }
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);
130
+ }
131
+ }
132
+
133
+ const transport = new InMemorySerialTransport();
134
+ transport.addDevice(new Thermometer(), {hasPermission: true});
135
+ const serial = new Serial(transport);
136
+ ```
137
+
138
+ Available hooks:
139
+
140
+ - `onOpen(options)`
141
+ - `onData(data)`
142
+ - `onHostSignals(signals)`
143
+ - `onClose()`
144
+
145
+ Helpers on `SimulatedDevice`:
146
+
147
+ - `this.send(bytesOrString)`
148
+ - `this.raiseError(message, name?)`
149
+ - `this.setSignals({dataCarrierDetect, clearToSend, ringIndicator, dataSetReady})`
150
+ - `this.openOptions`
151
+
152
+ ## Writing unit tests
153
+
154
+ Inject the transport and exercise the real `Serial` / `SerialPort` logic.
155
+
156
+ ```ts
157
+ import {Serial} from 'react-native-web-serial-api';
158
+ import {
159
+ InMemorySerialTransport,
160
+ LoopbackDevice,
161
+ } from 'react-native-web-serial-api/testing';
162
+
163
+ it('echoes bytes through the streams', async () => {
164
+ const transport = new InMemorySerialTransport();
165
+ transport.addDevice(
166
+ new LoopbackDevice({usbVendorId: 0x0403, usbProductId: 0x6001}),
167
+ {hasPermission: true},
168
+ );
169
+
170
+ const serial = new Serial(transport);
171
+ const [port] = await serial.getPorts();
172
+ await port.open({baudRate: 115200});
173
+
174
+ const reader = port.readable!.getReader();
175
+ const writer = port.writable!.getWriter();
176
+ await writer.write(Uint8Array.from([1, 2, 3]));
177
+ expect(Array.from((await reader.read()).value!)).toEqual([1, 2, 3]);
178
+ });
179
+ ```
180
+
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`
250
+
251
+ `createDeviceFixture()` wires up a `SimulatedDevice` and returns the handles you usually need in a test.
252
+
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
+ ```
280
+
281
+ Multiple devices at once:
282
+
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.
459
+
460
+ ```ts
461
+ import {runSerialConformance, runRealDeviceSmokeTest} from './conformance-suite';
462
+
463
+ const results = await runSerialConformance();
464
+ const smoke = await runRealDeviceSmokeTest(serial);
465
+ ```
466
+
467
+ ### Web Platform Tests
468
+
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:
472
+
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
479
+
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)
483
+
484
+ The [example app](example) includes two ways to test without hardware:
485
+
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())`.
490
+
491
+ ## E2E in the emulator
492
+
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.
494
+
495
+ ```ts
496
+ import {
497
+ LoopbackDevice,
498
+ installInMemorySerialTransport,
499
+ } from 'react-native-web-serial-api/testing';
500
+ import {MyThermometer} from './devices/MyThermometer';
501
+
502
+ installInMemorySerialTransport({
503
+ enabled: process.env.RNWS_SERIAL_MOCK === '1',
504
+ devices: [new LoopbackDevice(), new MyThermometer()],
505
+ });
506
+ ```
507
+
508
+ `installInMemorySerialTransport()` builds an `InMemorySerialTransport` and calls `setUsbSerial()`, so `navigator.serial` now talks to your simulated devices.
509
+
510
+ The example app ships Maestro tests in [`example/.maestro/`](example/.maestro):
511
+
512
+ ```sh
513
+ npm --prefix example run android
514
+ npm --prefix example run e2e
515
+
516
+ # or run host Jest first, then emulator E2E
517
+ npm run test:host+emulator
518
+ ```
519
+
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.
522
+
523
+ ## Running the tests
524
+
525
+ ```sh
526
+ npm test
527
+ npm run test:watch
528
+ npm run typecheck
529
+ npm run lint
530
+
531
+ npm test --prefix example
532
+ ```
533
+
534
+ CI runs these checks plus a build step on every push or pull request.
535
+
536
+ ## Coverage
537
+
538
+ ```sh
539
+ npm run test:coverage
540
+ ```
541
+
542
+ This prints a summary and writes an HTML report to `coverage/lcov-report/index.html`.
@@ -31,7 +31,7 @@ def getExtOrIntegerDefault(name) {
31
31
  }
32
32
 
33
33
  android {
34
- namespace "dev.webserialapi"
34
+ namespace = "dev.webserialapi"
35
35
 
36
36
  compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
37
37
 
@@ -44,13 +44,20 @@ android {
44
44
  sourceCompatibility JavaVersion.VERSION_17
45
45
  targetCompatibility JavaVersion.VERSION_17
46
46
  }
47
+
48
+ testOptions {
49
+ unitTests {
50
+ includeAndroidResources = true
51
+ returnDefaultValues = true
52
+ }
53
+ }
47
54
  }
48
55
 
49
56
  repositories {
50
57
  mavenCentral()
51
58
  google()
52
59
  // usb-serial-for-android is published on JitPack
53
- maven { url "https://jitpack.io" }
60
+ maven { url = "https://jitpack.io" }
54
61
  }
55
62
 
56
63
  dependencies {
@@ -59,4 +66,11 @@ dependencies {
59
66
  implementation "com.github.mik3y:usb-serial-for-android:3.10.0"
60
67
  // PortPickerActivity extends AppCompatActivity and uses the AppCompat dialog theme
61
68
  implementation "androidx.appcompat:appcompat:1.7.0"
69
+
70
+ // JVM unit tests. Robolectric provides an Android runtime so the TurboModule
71
+ // (which registers/unregisters BroadcastReceivers, talks to UsbManager, etc.)
72
+ // can be exercised without a device or emulator.
73
+ testImplementation "junit:junit:4.13.2"
74
+ testImplementation "org.robolectric:robolectric:4.14.1"
75
+ testImplementation "org.mockito:mockito-core:5.14.2"
62
76
  }