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.
- package/README.md +198 -104
- package/TESTING.md +542 -0
- package/android/build.gradle +16 -2
- 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 +58 -26
- package/lib/commonjs/UsbSerial.js.map +1 -1
- package/lib/commonjs/WebSerial.js +273 -77
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +15 -3
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/dom-exception.js +176 -0
- package/lib/commonjs/lib/dom-exception.js.map +1 -0
- package/lib/commonjs/lib/event-target.js +140 -0
- package/lib/commonjs/lib/event-target.js.map +1 -0
- package/lib/commonjs/lib/promise.js +23 -0
- package/lib/commonjs/lib/promise.js.map +1 -0
- 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/in-memory-serial-transport.js +653 -0
- package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/index.js +153 -0
- package/lib/commonjs/testing/index.js.map +1 -0
- 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/simulated-device.js +164 -0
- 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 +61 -0
- package/lib/commonjs/transport.js.map +1 -0
- 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 +24 -67
- package/lib/typescript/src/UsbSerial.d.ts.map +1 -1
- package/lib/typescript/src/WebSerial.d.ts +16 -7
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/dom-exception.d.ts +100 -0
- package/lib/typescript/src/lib/dom-exception.d.ts.map +1 -0
- package/lib/typescript/src/lib/event-target.d.ts +55 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -0
- package/lib/typescript/src/lib/promise.d.ts +11 -0
- package/lib/typescript/src/lib/promise.d.ts.map +1 -0
- 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/in-memory-serial-transport.d.ts +216 -0
- package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +33 -0
- package/lib/typescript/src/testing/index.d.ts.map +1 -0
- 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/simulated-device.d.ts +127 -0
- 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 +131 -0
- package/lib/typescript/src/transport.d.ts.map +1 -0
- 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 +57 -3
- package/src/UsbSerial.ts +65 -90
- package/src/WebSerial.ts +351 -113
- package/src/index.ts +6 -8
- package/src/lib/dom-exception.ts +129 -60
- package/src/lib/event-target.ts +58 -21
- package/src/lib/promise.ts +7 -7
- 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/in-memory-serial-transport.ts +840 -0
- package/src/testing/index.ts +90 -0
- package/src/testing/install-in-memory-serial-transport.ts +65 -0
- package/src/testing/serial-client.ts +313 -0
- package/src/testing/simulated-device.ts +193 -0
- package/src/testing/test-suite.ts +186 -0
- package/src/transport.ts +200 -0
- 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/babel.config.js +0 -3
- package/biome.json +0 -35
- package/example/.watchmanconfig +0 -1
- package/example/App.tsx +0 -71
- package/example/__tests__/App.test.tsx +0 -16
- package/example/__tests__/connectEvents.test.tsx +0 -81
- package/example/__tests__/getPorts.test.tsx +0 -140
- package/example/android/app/build.gradle +0 -120
- package/example/android/app/debug.keystore +0 -0
- package/example/android/app/proguard-rules.pro +0 -10
- package/example/android/app/src/debug/AndroidManifest.xml +0 -9
- package/example/android/app/src/main/AndroidManifest.xml +0 -38
- package/example/android/app/src/main/java/dev/uzlopak/MainActivity.kt +0 -22
- package/example/android/app/src/main/java/dev/uzlopak/MainApplication.kt +0 -41
- package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +0 -37
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/values/strings.xml +0 -3
- package/example/android/app/src/main/res/values/styles.xml +0 -9
- package/example/android/build.gradle +0 -22
- package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/example/android/gradle/wrapper/gradle-wrapper.properties +0 -7
- package/example/android/gradle.properties +0 -47
- package/example/android/gradlew +0 -252
- package/example/android/gradlew.bat +0 -94
- package/example/android/settings.gradle +0 -6
- package/example/app.json +0 -4
- package/example/babel.config.js +0 -21
- package/example/biome.json +0 -47
- package/example/deploy.sh +0 -11
- package/example/index.html +0 -26
- package/example/index.js +0 -9
- package/example/index.web.js +0 -8
- package/example/jest.config.js +0 -12
- package/example/metro.config.js +0 -58
- package/example/package-lock.json +0 -14510
- package/example/package.json +0 -48
- package/example/react-native.config.js +0 -17
- package/example/src/components/AppBar.tsx +0 -73
- package/example/src/components/Menu.tsx +0 -90
- package/example/src/components/SingleChoiceDialog.tsx +0 -120
- package/example/src/screens/ConnectScreen.tsx +0 -195
- package/example/src/screens/DevicesScreen.tsx +0 -252
- package/example/src/screens/TerminalScreen.tsx +0 -572
- package/example/src/settings.ts +0 -43
- package/example/src/theme.ts +0 -19
- package/example/src/util/TextUtil.ts +0 -129
- package/example/tsconfig.json +0 -10
- package/example/vite.config.mjs +0 -55
- package/scripts/deploy-release.sh +0 -127
- package/tsconfig.build.json +0 -7
- 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`.
|
package/android/build.gradle
CHANGED
|
@@ -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
|
}
|