@onekeyfe/hd-transport-web-device 1.2.0-alpha.17 → 1.2.0-alpha.170

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.
@@ -0,0 +1,261 @@
1
+ import transport from '@onekeyfe/hd-transport';
2
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
3
+
4
+ import WebUsbTransport from '../src/webusb';
5
+
6
+ const schema = {
7
+ nested: {
8
+ Ping: { fields: { message: { type: 'string', id: 1 } } },
9
+ Success: { fields: { message: { type: 'string', id: 1 } } },
10
+ MessageType: {
11
+ values: {
12
+ MessageType_Ping: 60206,
13
+ MessageType_Success: 60207,
14
+ },
15
+ },
16
+ },
17
+ };
18
+
19
+ function buildAcquirableTransport(path = 'pro-webusb') {
20
+ const webusb = new WebUsbTransport() as any;
21
+ webusb.Log = { debug: jest.fn() };
22
+ webusb.rotateProtocolV2UsbGeneration = jest.fn().mockResolvedValue(undefined);
23
+ webusb.closeOpenDevice = jest.fn().mockResolvedValue(undefined);
24
+ webusb.connect = jest.fn().mockResolvedValue(undefined);
25
+ // Simulate a device that stayed connected since the probe: same USBDevice
26
+ // object present in the device list and recorded as the probed object.
27
+ const deviceObject = { serialNumber: path } as unknown as USBDevice;
28
+ webusb.deviceList = [{ path, device: deviceObject, commType: 'webusb' }];
29
+ webusb.probedDeviceObjects.set(path, deviceObject);
30
+ return webusb;
31
+ }
32
+
33
+ describe('WebUsbTransport protocol probe cache', () => {
34
+ test('acquire skips the wire probe when the protocol is already cached', async () => {
35
+ const webusb = buildAcquirableTransport();
36
+ const path = 'pro-webusb';
37
+ webusb.deviceProtocol.set(path, 'V1');
38
+ webusb.detectProtocol = jest.fn();
39
+
40
+ await expect(webusb.acquire({ path, expectedProtocol: 'V1' })).resolves.toBe(path);
41
+
42
+ expect(webusb.detectProtocol).not.toHaveBeenCalled();
43
+ expect(webusb.deviceProtocol.get(path)).toBe('V1');
44
+ expect(webusb.acquiredPaths.has(path)).toBe(true);
45
+ });
46
+
47
+ test('acquire re-probes when the caller expects a different protocol than cached', async () => {
48
+ const webusb = buildAcquirableTransport();
49
+ const path = 'pro-webusb';
50
+ webusb.deviceProtocol.set(path, 'V1');
51
+ webusb.detectProtocol = jest.fn().mockImplementation((p: string) => {
52
+ webusb.deviceProtocol.set(p, 'V2');
53
+ return Promise.resolve('V2');
54
+ });
55
+
56
+ await expect(webusb.acquire({ path, expectedProtocol: 'V2' })).resolves.toBe(path);
57
+
58
+ expect(webusb.detectProtocol).toHaveBeenCalledWith(path, 'V2', undefined);
59
+ expect(webusb.deviceProtocol.get(path)).toBe('V2');
60
+ });
61
+
62
+ test('acquire probes when nothing is cached for the path', async () => {
63
+ const webusb = buildAcquirableTransport();
64
+ const path = 'pro-webusb';
65
+ webusb.detectProtocol = jest.fn().mockResolvedValue('V1');
66
+
67
+ await expect(webusb.acquire({ path, expectedProtocol: 'V1' })).resolves.toBe(path);
68
+
69
+ expect(webusb.detectProtocol).toHaveBeenCalledWith(path, 'V1', undefined);
70
+ });
71
+
72
+ test('acquire trusts the expected protocol during an explicit no-probe recovery', async () => {
73
+ const webusb = buildAcquirableTransport();
74
+ const path = 'pro-webusb';
75
+ webusb.deviceProtocol.set(path, 'V1');
76
+ webusb.markProtocolStale(path);
77
+ webusb.detectProtocol = jest.fn();
78
+
79
+ await expect(
80
+ webusb.acquire({ path, expectedProtocol: 'V2', skipProtocolProbe: true })
81
+ ).resolves.toBe(path);
82
+
83
+ expect(webusb.detectProtocol).not.toHaveBeenCalled();
84
+ expect(webusb.deviceProtocol.get(path)).toBe('V2');
85
+ expect(webusb.staleProtocolPaths.has(path)).toBe(false);
86
+ expect(webusb.acquiredPaths.has(path)).toBe(true);
87
+ });
88
+
89
+ test('forced protocol detection bypasses a valid cache and probes on the wire', async () => {
90
+ const webusb = buildAcquirableTransport();
91
+ const path = 'pro-webusb';
92
+ // Valid cache AND unchanged USBDevice object — the strongest cache hit.
93
+ webusb.deviceProtocol.set(path, 'V1');
94
+ webusb.detectProtocol = jest.fn().mockImplementation((p: string) => {
95
+ webusb.deviceProtocol.set(p, 'V1');
96
+ return Promise.resolve('V1');
97
+ });
98
+
99
+ await expect(webusb.acquire({ path, forceProtocolDetection: true })).resolves.toBe(path);
100
+
101
+ // Explicit recovery must always reach the wire probe.
102
+ expect(webusb.detectProtocol).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ test('acquire re-probes when the USBDevice object identity changed since the probe', async () => {
106
+ const webusb = buildAcquirableTransport();
107
+ const path = 'pro-webusb';
108
+ webusb.deviceProtocol.set(path, 'V1');
109
+ // Simulate a replug the transport never saw a disconnect event for: the OS
110
+ // re-enumerated the device, so the list now holds a NEW USBDevice object.
111
+ webusb.deviceList = [
112
+ { path, device: { serialNumber: path } as unknown as USBDevice, commType: 'webusb' },
113
+ ];
114
+ webusb.detectProtocol = jest.fn().mockImplementation((p: string) => {
115
+ webusb.deviceProtocol.set(p, 'V1');
116
+ return Promise.resolve('V1');
117
+ });
118
+
119
+ await expect(webusb.acquire({ path, expectedProtocol: 'V1' })).resolves.toBe(path);
120
+
121
+ expect(webusb.detectProtocol).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ test('acquire re-probes a stale-marked path even when a protocol is cached', async () => {
125
+ const webusb = buildAcquirableTransport();
126
+ const path = 'pro-webusb';
127
+ webusb.deviceProtocol.set(path, 'V1');
128
+ webusb.markProtocolStale(path);
129
+ webusb.detectProtocol = jest.fn().mockImplementation((p: string) => {
130
+ webusb.deviceProtocol.set(p, 'V1');
131
+ return Promise.resolve('V1');
132
+ });
133
+
134
+ await expect(webusb.acquire({ path, expectedProtocol: 'V1' })).resolves.toBe(path);
135
+
136
+ expect(webusb.detectProtocol).toHaveBeenCalledTimes(1);
137
+ expect(webusb.staleProtocolPaths.has(path)).toBe(false);
138
+ });
139
+
140
+ test('release keeps the cached V1 protocol so the next acquire can reuse it', async () => {
141
+ const webusb = new WebUsbTransport() as any;
142
+ const path = 'pro-webusb';
143
+ webusb.deviceProtocol.set(path, 'V1');
144
+ webusb.deviceProtocolHints.set(path, 'V2');
145
+ webusb.deviceEndpoints.set(path, { interfaceNumber: 0, endpointIn: 1, endpointOut: 1 });
146
+ webusb.acquiredPaths.add(path);
147
+ webusb.closeOpenDevice = jest.fn().mockResolvedValue(undefined);
148
+
149
+ await webusb.release(path);
150
+
151
+ expect(webusb.deviceProtocol.get(path)).toBe('V1');
152
+ expect(webusb.deviceProtocolHints.get(path)).toBe('V2');
153
+ expect(webusb.deviceEndpoints.has(path)).toBe(false);
154
+ expect(webusb.acquiredPaths.has(path)).toBe(false);
155
+ });
156
+
157
+ test('release still drops a cached V2 protocol through link invalidation', async () => {
158
+ const webusb = new WebUsbTransport() as any;
159
+ const path = 'pro2-webusb';
160
+ webusb.Log = { debug: jest.fn() };
161
+ webusb.messages = transport.parseConfigure(schema);
162
+ webusb.messagesV2 = transport.parseConfigure(schema);
163
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
164
+ let markReadStarted: () => void = () => undefined;
165
+ const readStarted = new Promise<void>(resolve => {
166
+ markReadStarted = resolve;
167
+ });
168
+ webusb.readProtocolV2UsbPacket = jest.fn().mockImplementation(() => {
169
+ markReadStarted();
170
+ return new Promise<void>(() => {});
171
+ });
172
+ webusb.closeOpenDevice = jest.fn().mockResolvedValue(undefined);
173
+ webusb.deviceProtocol.set(path, 'V2');
174
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
175
+
176
+ const call = webusb.callProtocolV2(path, 'Ping', { message: 'release' });
177
+ await readStarted;
178
+ await webusb.release(path);
179
+
180
+ await expect(call).rejects.toThrow('WebUSB transport released');
181
+ expect(webusb.deviceProtocol.has(path)).toBe(false);
182
+ });
183
+
184
+ test('call and post fail fast for a path that is not acquired', async () => {
185
+ const webusb = new WebUsbTransport() as any;
186
+ const path = 'pro-webusb';
187
+ webusb.Log = { debug: jest.fn() };
188
+ webusb.messages = transport.parseConfigure(schema);
189
+ webusb.messagesV2 = transport.parseConfigure(schema);
190
+ // A surviving protocol cache entry must NOT act as a session token.
191
+ webusb.deviceProtocol.set(path, 'V1');
192
+
193
+ await expect(webusb.call(path, 'Ping', {})).rejects.toMatchObject({
194
+ errorCode: HardwareErrorCode.RuntimeError,
195
+ message: expect.stringContaining('not acquired'),
196
+ });
197
+ await expect(webusb.post(path, 'Ping', {})).rejects.toMatchObject({
198
+ errorCode: HardwareErrorCode.RuntimeError,
199
+ message: expect.stringContaining('not acquired'),
200
+ });
201
+ });
202
+
203
+ test('a transfer-level reconnect marks the protocol stale for the next acquire', async () => {
204
+ const webusb = new WebUsbTransport() as any;
205
+ const path = 'pro-webusb';
206
+ webusb.Log = { debug: jest.fn() };
207
+ webusb.deviceProtocol.set(path, 'V1');
208
+ webusb.findDevice = jest.fn().mockResolvedValue({ opened: false });
209
+ webusb.getConnectedDevices = jest.fn().mockResolvedValue([]);
210
+ webusb.connect = jest.fn().mockResolvedValue(undefined);
211
+
212
+ await webusb.reconnectForPacketIoRetry(path, 'in', 0, new Error('transferIn failed'));
213
+
214
+ // The in-flight call keeps the cached protocol; only the next acquire re-probes.
215
+ expect(webusb.deviceProtocol.get(path)).toBe('V1');
216
+ expect(webusb.staleProtocolPaths.has(path)).toBe(true);
217
+ });
218
+
219
+ test('USB disconnect marks the serial stale and the listener attaches only once', () => {
220
+ const addEventListener = jest.fn();
221
+ const usb = { addEventListener } as unknown as USB;
222
+ const originalNavigator = (globalThis as any).navigator;
223
+ Object.defineProperty(globalThis, 'navigator', {
224
+ value: { usb },
225
+ configurable: true,
226
+ });
227
+ try {
228
+ const first = new WebUsbTransport() as any;
229
+ first.init({ debug: jest.fn() });
230
+ const second = new WebUsbTransport() as any;
231
+ second.init({ debug: jest.fn() });
232
+
233
+ // Module-level listener: one registration across instances.
234
+ expect(addEventListener).toHaveBeenCalledTimes(1);
235
+ const handler = addEventListener.mock.calls[0][1] as (event: {
236
+ device?: { serialNumber?: string | null };
237
+ }) => void;
238
+
239
+ const path = 'pro-webusb';
240
+ first.deviceProtocol.set(path, 'V1');
241
+ second.deviceProtocol.set(path, 'V1');
242
+ handler({ device: { serialNumber: path } });
243
+
244
+ // Routed to the most recently initialized instance; the cached value is
245
+ // retained (in-flight sessions keep working) but marked stale.
246
+ expect(second.staleProtocolPaths.has(path)).toBe(true);
247
+ expect(second.deviceProtocol.get(path)).toBe('V1');
248
+ expect(first.staleProtocolPaths.has(path)).toBe(false);
249
+
250
+ // Events without a device identity are ignored.
251
+ handler({ device: { serialNumber: null } });
252
+ handler({});
253
+ expect(second.staleProtocolPaths.size).toBe(1);
254
+ } finally {
255
+ Object.defineProperty(globalThis, 'navigator', {
256
+ value: originalNavigator,
257
+ configurable: true,
258
+ });
259
+ }
260
+ });
261
+ });
@@ -1,4 +1,9 @@
1
- import transport, { ProtocolV2FrameAssembler } from '@onekeyfe/hd-transport';
1
+ import transport, {
2
+ PROTOCOL_V2_CHANNEL_USB,
3
+ ProtocolV2,
4
+ ProtocolV2LinkError,
5
+ } from '@onekeyfe/hd-transport';
6
+ import { HardwareErrorCode, ONEKEY_WEBUSB_FILTER } from '@onekeyfe/hd-shared';
2
7
 
3
8
  import WebUsbTransport from '../src/webusb';
4
9
 
@@ -16,25 +21,372 @@ const schema = {
16
21
  };
17
22
 
18
23
  describe('WebUsbTransport Protocol V2 timeout recovery', () => {
24
+ test('enumerates OneKey USB devices even when the serial string is omitted', async () => {
25
+ const filter =
26
+ ONEKEY_WEBUSB_FILTER.find(item => item.productId === 0x4f4c) ?? ONEKEY_WEBUSB_FILTER[0];
27
+ const deviceWithSerial = {
28
+ ...filter,
29
+ manufacturerName: 'OneKey',
30
+ productName: 'OneKey Pro 2',
31
+ serialNumber: 'PRO2-SERIAL',
32
+ } as USBDevice;
33
+ const deviceWithoutSerial = {
34
+ ...filter,
35
+ manufacturerName: 'OneKey',
36
+ productName: 'OneKey Neo',
37
+ serialNumber: null,
38
+ } as USBDevice;
39
+ const webusb = new WebUsbTransport();
40
+ webusb.usb = {
41
+ getDevices: jest.fn().mockResolvedValue([deviceWithSerial, deviceWithoutSerial]),
42
+ } as unknown as USB;
43
+
44
+ await expect(webusb.getConnectedDevices()).resolves.toEqual([
45
+ {
46
+ path: 'PRO2-SERIAL',
47
+ device: deviceWithSerial,
48
+ commType: 'webusb',
49
+ },
50
+ {
51
+ path: 'usb-1209-4f4c-onekey-neo',
52
+ device: deviceWithoutSerial,
53
+ commType: 'webusb',
54
+ },
55
+ ]);
56
+ });
57
+
58
+ test('keeps active links when the Protocol V2 schema is configured repeatedly', () => {
59
+ const webusb = new WebUsbTransport() as any;
60
+ webusb.invalidateAllProtocolV2UsbLinks = jest.fn().mockResolvedValue(undefined);
61
+ const schemaSource = JSON.stringify(schema);
62
+
63
+ webusb.configureProtocolV2(schemaSource);
64
+ webusb.configureProtocolV2(schemaSource);
65
+
66
+ expect(webusb.invalidateAllProtocolV2UsbLinks).not.toHaveBeenCalled();
67
+
68
+ webusb.configureProtocolV2(
69
+ JSON.stringify({
70
+ ...schema,
71
+ nested: {
72
+ ...schema.nested,
73
+ Failure: { fields: { message: { type: 'string', id: 1 } } },
74
+ },
75
+ })
76
+ );
77
+ expect(webusb.invalidateAllProtocolV2UsbLinks).toHaveBeenCalledWith(
78
+ 'Protocol V2 schema reconfigured'
79
+ );
80
+ });
81
+
82
+ test('resets the connection between a failed V1 probe and the V2 probe', async () => {
83
+ const webusb = new WebUsbTransport() as any;
84
+ const path = 'pro2-webusb';
85
+ const events: string[] = [];
86
+ webusb.probeProtocolV1 = jest.fn().mockImplementation(() => {
87
+ events.push('probe-v1');
88
+ return Promise.resolve(false);
89
+ });
90
+ webusb.resetConnectionAfterProbe = jest.fn().mockImplementation(() => {
91
+ events.push('reset');
92
+ return Promise.resolve();
93
+ });
94
+ webusb.probeProtocolV2 = jest.fn().mockImplementation(() => {
95
+ events.push('probe-v2');
96
+ return Promise.resolve(true);
97
+ });
98
+
99
+ await expect(webusb.detectProtocol(path)).resolves.toBe('V2');
100
+
101
+ expect(events).toEqual(['probe-v1', 'reset', 'probe-v2']);
102
+ expect(webusb.deviceProtocol.get(path)).toBe('V2');
103
+ });
104
+
105
+ test('reports DeviceNotFound when automatic protocol detection exhausts both probes', async () => {
106
+ const webusb = new WebUsbTransport() as any;
107
+ const path = 'unresponsive-webusb';
108
+ webusb.probeProtocolV1 = jest.fn().mockResolvedValue(false);
109
+ webusb.probeProtocolV2 = jest.fn().mockResolvedValue(false);
110
+ webusb.resetConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
111
+ webusb.closeConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
112
+
113
+ await expect(webusb.detectProtocol(path)).rejects.toMatchObject({
114
+ errorCode: HardwareErrorCode.DeviceNotFound,
115
+ });
116
+
117
+ expect(webusb.probeProtocolV1).toHaveBeenCalledTimes(1);
118
+ expect(webusb.probeProtocolV2).toHaveBeenCalledTimes(1);
119
+ expect(webusb.resetConnectionAfterProbe).toHaveBeenCalledTimes(1);
120
+ expect(webusb.closeConnectionAfterProbe).toHaveBeenCalledTimes(1);
121
+ expect(webusb.deviceProtocol.has(path)).toBe(false);
122
+ });
123
+
124
+ test('allows legacy WebUSB Initialize up to the Node USB probe timeout', async () => {
125
+ const webusb = new WebUsbTransport() as any;
126
+ const path = 'pro-webusb';
127
+ webusb.messages = {};
128
+ webusb.callProtocolV1 = jest.fn().mockResolvedValue({});
129
+
130
+ await expect(webusb.probeProtocolV1(path)).resolves.toBe(true);
131
+
132
+ expect(webusb.callProtocolV1).toHaveBeenCalledWith(
133
+ path,
134
+ 'Initialize',
135
+ {},
136
+ {
137
+ timeoutMs: 5000,
138
+ }
139
+ );
140
+ });
141
+
142
+ test('retries an expected Protocol V2 probe once after resetting the connection', async () => {
143
+ const webusb = new WebUsbTransport() as any;
144
+ const path = 'pro2-webusb';
145
+ webusb.probeProtocolV1 = jest.fn();
146
+ webusb.probeProtocolV2 = jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
147
+ webusb.resetConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
148
+
149
+ await expect(webusb.detectProtocol(path, 'V2')).resolves.toBe('V2');
150
+
151
+ expect(webusb.probeProtocolV2).toHaveBeenCalledTimes(2);
152
+ expect(webusb.probeProtocolV1).not.toHaveBeenCalled();
153
+ expect(webusb.resetConnectionAfterProbe).toHaveBeenCalledTimes(1);
154
+ expect(webusb.deviceProtocol.get(path)).toBe('V2');
155
+ });
156
+
157
+ test('reports a device initialization failure only after the bounded retry is exhausted', async () => {
158
+ const webusb = new WebUsbTransport() as any;
159
+ const path = 'pro2-webusb';
160
+ webusb.probeProtocolV1 = jest.fn();
161
+ webusb.probeProtocolV2 = jest.fn().mockResolvedValue(false);
162
+ webusb.resetConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
163
+ webusb.closeConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
164
+
165
+ await expect(webusb.detectProtocol(path, 'V2')).rejects.toMatchObject({
166
+ errorCode: HardwareErrorCode.DeviceInitializeFailed,
167
+ message: 'Protocol V2 probe timeout after 2 attempts',
168
+ });
169
+
170
+ expect(webusb.probeProtocolV2).toHaveBeenCalledTimes(2);
171
+ expect(webusb.probeProtocolV1).not.toHaveBeenCalled();
172
+ expect(webusb.resetConnectionAfterProbe).toHaveBeenCalledTimes(1);
173
+ expect(webusb.closeConnectionAfterProbe).toHaveBeenCalledTimes(1);
174
+ expect(webusb.deviceProtocol.has(path)).toBe(false);
175
+ });
176
+
177
+ test('closes the reopened device when acquire exhausts the expected Protocol V2 probe', async () => {
178
+ const webusb = new WebUsbTransport() as any;
179
+ const path = 'pro2-webusb';
180
+ const device = {
181
+ opened: false,
182
+ releaseInterface: jest.fn().mockResolvedValue(undefined),
183
+ close: jest.fn().mockImplementation(() => {
184
+ device.opened = false;
185
+ return Promise.resolve();
186
+ }),
187
+ };
188
+ webusb.deviceList = [{ path, device }];
189
+ webusb.Log = { debug: jest.fn() };
190
+ webusb.rotateProtocolV2UsbGeneration = jest.fn().mockResolvedValue(undefined);
191
+ webusb.connect = jest.fn().mockImplementation(() => {
192
+ device.opened = true;
193
+ return Promise.resolve();
194
+ });
195
+ webusb.detectProtocol = jest.fn().mockRejectedValue(new Error('terminal probe failure'));
196
+
197
+ await expect(webusb.acquire({ path, expectedProtocol: 'V2' })).rejects.toThrow(
198
+ 'terminal probe failure'
199
+ );
200
+
201
+ expect(device.releaseInterface).toHaveBeenCalledTimes(1);
202
+ expect(device.close).toHaveBeenCalledTimes(1);
203
+ expect(device.opened).toBe(false);
204
+ });
205
+
19
206
  test('invalidates and resets the cached connection before another call can start', async () => {
20
207
  const webusb = new WebUsbTransport() as any;
21
208
  const path = 'pro2-webusb';
22
209
  webusb.messages = transport.parseConfigure(schema);
23
210
  webusb.messagesV2 = transport.parseConfigure(schema);
24
- webusb.protocolV2Assemblers.set(path, new ProtocolV2FrameAssembler());
25
- webusb.transferOutOnce = jest.fn().mockResolvedValue(undefined);
26
- webusb.receiveProtocolV2Frame = jest.fn(() => new Promise(() => undefined));
27
- webusb.resetConnectionAfterProbe = jest.fn().mockImplementation(async () => {
28
- webusb.protocolV2Sessions.delete(path);
29
- webusb.protocolV2ReadTimeouts.delete(path);
30
- webusb.protocolV2Assemblers.get(path)?.reset();
31
- });
211
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
212
+ webusb.readProtocolV2UsbPacket = jest.fn(() => new Promise<void>(() => {}));
213
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
214
+ webusb.resetConnectionAfterProbe = jest.fn();
215
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
32
216
 
33
217
  await expect(
34
218
  webusb.callProtocolV2(path, 'Ping', { message: 'timeout' }, { timeoutMs: 10 })
35
219
  ).rejects.toThrow('timeout');
36
220
 
37
- expect(webusb.resetConnectionAfterProbe).toHaveBeenCalledWith(path);
38
- expect(webusb.protocolV2Sessions.has(path)).toBe(false);
221
+ expect(webusb.resetProtocolV2UsbNativeLink).toHaveBeenCalledWith(
222
+ path,
223
+ expect.stringContaining('timeout')
224
+ );
225
+ expect(webusb.resetConnectionAfterProbe).not.toHaveBeenCalled();
226
+ });
227
+
228
+ test('does not reconnect inside a Protocol V2 frame read after a USB I/O failure', async () => {
229
+ const webusb = new WebUsbTransport() as any;
230
+ const path = 'pro2-webusb';
231
+ webusb.messages = transport.parseConfigure(schema);
232
+ webusb.messagesV2 = transport.parseConfigure(schema);
233
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
234
+ webusb.readProtocolV2UsbPacket = jest
235
+ .fn()
236
+ .mockRejectedValue(new Error('NetworkError: transferIn device disconnected'));
237
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
238
+ webusb.resetConnectionAfterProbe = jest.fn();
239
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
240
+
241
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'read-error' })).rejects.toThrow(
242
+ 'NetworkError'
243
+ );
244
+
245
+ expect(webusb.readProtocolV2UsbPacket).toHaveBeenCalledTimes(1);
246
+ expect(webusb.resetProtocolV2UsbNativeLink).toHaveBeenCalledWith(
247
+ path,
248
+ expect.stringContaining('NetworkError')
249
+ );
250
+ expect(webusb.resetConnectionAfterProbe).not.toHaveBeenCalled();
251
+ });
252
+
253
+ test('rejects an active Protocol V2 read without reconnecting after release', async () => {
254
+ const webusb = new WebUsbTransport() as any;
255
+ const path = 'pro2-webusb';
256
+ let markReadStarted: () => void = () => undefined;
257
+ const readStarted = new Promise<void>(resolve => {
258
+ markReadStarted = resolve;
259
+ });
260
+ webusb.messages = transport.parseConfigure(schema);
261
+ webusb.messagesV2 = transport.parseConfigure(schema);
262
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
263
+ webusb.readProtocolV2UsbPacket = jest.fn().mockImplementation(() => {
264
+ markReadStarted();
265
+ return new Promise<void>(() => {});
266
+ });
267
+ webusb.closeOpenDevice = jest.fn().mockResolvedValue(undefined);
268
+ webusb.connect = jest.fn();
269
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
270
+
271
+ const call = webusb.callProtocolV2(path, 'Ping', { message: 'release' });
272
+ await readStarted;
273
+ await webusb.release(path);
274
+
275
+ await expect(call).rejects.toThrow('WebUSB transport released');
276
+ expect(webusb.connect).not.toHaveBeenCalled();
277
+ });
278
+
279
+ test.each(['router', 'packet-source', 'ack-sequence', 'response-sequence', 'frame'] as const)(
280
+ 'invalidates cached state for typed Protocol V2 %s errors',
281
+ async code => {
282
+ const webusb = new WebUsbTransport() as any;
283
+ const path = 'pro2-webusb';
284
+ webusb.messages = transport.parseConfigure(schema);
285
+ webusb.messagesV2 = transport.parseConfigure(schema);
286
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
287
+ const recoveredResponse = ProtocolV2.encodeFrame(
288
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
289
+ 'Success',
290
+ { message: 'recovered' },
291
+ { seq: 1 }
292
+ );
293
+ webusb.readProtocolV2UsbPacket = jest
294
+ .fn()
295
+ .mockRejectedValueOnce(
296
+ new ProtocolV2LinkError(code, `Protocol V2 ${code} validation failed`)
297
+ )
298
+ .mockResolvedValue(recoveredResponse);
299
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
300
+ webusb.resetConnectionAfterProbe = jest.fn();
301
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
302
+
303
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'mismatch' })).rejects.toThrow(
304
+ `${code} validation failed`
305
+ );
306
+
307
+ expect(webusb.resetProtocolV2UsbNativeLink).toHaveBeenCalledWith(
308
+ path,
309
+ expect.stringContaining(`${code} validation failed`)
310
+ );
311
+ expect(webusb.resetConnectionAfterProbe).not.toHaveBeenCalled();
312
+
313
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test reconnect');
314
+ await expect(
315
+ webusb.callProtocolV2(path, 'Ping', { message: 'after-reset' })
316
+ ).resolves.toMatchObject({
317
+ type: 'Success',
318
+ message: { message: 'recovered' },
319
+ });
320
+ }
321
+ );
322
+
323
+ test('does not discard buffered Protocol V2 frames before each call', async () => {
324
+ const webusb = new WebUsbTransport() as any;
325
+ const path = 'pro2-webusb';
326
+ webusb.messages = transport.parseConfigure(schema);
327
+ webusb.messagesV2 = transport.parseConfigure(schema);
328
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
329
+ const firstResponse = ProtocolV2.encodeFrame(
330
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
331
+ 'Success',
332
+ { message: 'first' },
333
+ { router: PROTOCOL_V2_CHANNEL_USB, seq: 1 }
334
+ );
335
+ const secondResponse = ProtocolV2.encodeFrame(
336
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
337
+ 'Success',
338
+ { message: 'second' },
339
+ { router: PROTOCOL_V2_CHANNEL_USB, seq: 2 }
340
+ );
341
+ const coalescedResponses = new Uint8Array(firstResponse.length + secondResponse.length);
342
+ coalescedResponses.set(firstResponse);
343
+ coalescedResponses.set(secondResponse, firstResponse.length);
344
+ webusb.readProtocolV2UsbPacket = jest.fn().mockResolvedValue(coalescedResponses);
345
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
346
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
347
+
348
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'first' })).resolves.toMatchObject({
349
+ type: 'Success',
350
+ message: { message: 'first' },
351
+ });
352
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'second' })).resolves.toMatchObject(
353
+ {
354
+ type: 'Success',
355
+ message: { message: 'second' },
356
+ }
357
+ );
358
+
359
+ expect(webusb.readProtocolV2UsbPacket).toHaveBeenCalledTimes(1);
360
+ });
361
+
362
+ test('keeps queued Protocol V2 read timeouts scoped to each call', async () => {
363
+ const webusb = new WebUsbTransport() as any;
364
+ const path = 'pro2-webusb';
365
+ let responseSequence = 0;
366
+ const readTimeouts: number[] = [];
367
+ webusb.messages = transport.parseConfigure(schema);
368
+ webusb.messagesV2 = transport.parseConfigure(schema);
369
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
370
+ webusb.readProtocolV2UsbPacket = jest.fn().mockImplementation((_path, context) => {
371
+ responseSequence += 1;
372
+ readTimeouts.push(context.timeoutMs);
373
+ return Promise.resolve(
374
+ ProtocolV2.encodeFrame(
375
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
376
+ 'Success',
377
+ { message: 'ok' },
378
+ { seq: responseSequence }
379
+ )
380
+ );
381
+ });
382
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
383
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
384
+
385
+ await Promise.all([
386
+ webusb.callProtocolV2(path, 'Ping', { message: 'long' }, { timeoutMs: 1_000 }),
387
+ webusb.callProtocolV2(path, 'Ping', { message: 'short' }, { timeoutMs: 25 }),
388
+ ]);
389
+
390
+ expect(readTimeouts).toEqual([1_000, 25]);
39
391
  });
40
392
  });
@@ -0,0 +1,2 @@
1
+ export declare function resolveBlePacketCapacity(mtu: number | null | undefined, maximumPacketCapacity: number, fallbackPacketCapacity: number): number;
2
+ //# sourceMappingURL=ble-packet-capacity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ble-packet-capacity.d.ts","sourceRoot":"","sources":["../src/ble-packet-capacity.ts"],"names":[],"mappings":"AAEA,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC9B,qBAAqB,EAAE,MAAM,EAC7B,sBAAsB,EAAE,MAAM,GAC7B,MAAM,CAMR"}