@onekeyfe/hd-transport-web-device 1.2.0-alpha.3 → 1.2.0-alpha.30

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.
@@ -1,4 +1,6 @@
1
1
  import transport, { PROTOCOL_V2_CHANNEL_BLE_UART, bytesToHex } from '@onekeyfe/hd-transport';
2
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
3
+ import EventEmitter from 'events';
2
4
 
3
5
  import ElectronBleTransport from '../src/electron-ble-transport';
4
6
 
@@ -28,21 +30,25 @@ const protocolV1Schema = {
28
30
 
29
31
  const protocolV2Schema = {
30
32
  nested: {
31
- GetProtoVersion: {
33
+ ProtocolInfoRequest: {
32
34
  fields: {},
33
35
  },
34
- ProtoVersion: {
36
+ ProtocolInfo: {
35
37
  fields: {
36
- major_version: {
38
+ version: {
37
39
  type: 'uint32',
38
40
  id: 1,
39
41
  },
40
- minor_version: {
42
+ supported_messages: {
43
+ rule: 'repeated',
41
44
  type: 'uint32',
42
45
  id: 2,
46
+ options: {
47
+ packed: false,
48
+ },
43
49
  },
44
- patch_version: {
45
- type: 'uint32',
50
+ protobuf_definition: {
51
+ type: 'string',
46
52
  id: 3,
47
53
  },
48
54
  },
@@ -65,8 +71,8 @@ const protocolV2Schema = {
65
71
  },
66
72
  MessageType: {
67
73
  values: {
68
- MessageType_GetProtoVersion: 60200,
69
- MessageType_ProtoVersion: 60201,
74
+ MessageType_ProtocolInfoRequest: 60200,
75
+ MessageType_ProtocolInfo: 60201,
70
76
  MessageType_Ping: 60206,
71
77
  MessageType_Success: 60207,
72
78
  },
@@ -106,7 +112,10 @@ const createNobleBle = (device = { id: 'flaky-pro2-id', name: 'Unknown BLE Devic
106
112
  ),
107
113
  });
108
114
 
109
- const configureTransport = (nobleBle: ReturnType<typeof createNobleBle>) => {
115
+ const configureTransport = (
116
+ nobleBle: ReturnType<typeof createNobleBle>,
117
+ emitter?: EventEmitter
118
+ ) => {
110
119
  (global as any).window = {
111
120
  desktopApi: {
112
121
  nobleBle,
@@ -114,7 +123,7 @@ const configureTransport = (nobleBle: ReturnType<typeof createNobleBle>) => {
114
123
  };
115
124
 
116
125
  const transport = new ElectronBleTransport();
117
- transport.init(createLogger());
126
+ transport.init(createLogger(), emitter);
118
127
  transport.configure(protocolV1Schema);
119
128
  transport.configureProtocolV2(protocolV2Schema);
120
129
  return transport;
@@ -126,6 +135,72 @@ describe('ElectronBleTransport protocol detection', () => {
126
135
  jest.clearAllMocks();
127
136
  });
128
137
 
138
+ test('keeps raw BLE lifecycle payloads off the public device event channel', async () => {
139
+ const device = { id: 'lifecycle-pro2-id', name: 'OneKey Pro 2' };
140
+ const nobleBle = createNobleBle(device);
141
+ const emitter = new EventEmitter();
142
+ let notificationHandler: ((deviceId: string, data: string) => void) | undefined;
143
+ let disconnectHandler: ((device: { id: string; name: string | null }) => void) | undefined;
144
+ let responseSeq = 0;
145
+
146
+ nobleBle.onNotification.mockImplementation(handler => {
147
+ notificationHandler = handler;
148
+ return jest.fn();
149
+ });
150
+ nobleBle.onDeviceDisconnected.mockImplementation(handler => {
151
+ disconnectHandler = handler;
152
+ return jest.fn();
153
+ });
154
+ nobleBle.write.mockImplementation(() => {
155
+ responseSeq += 1;
156
+ const response = ProtocolV2.encodeFrame(
157
+ schemas,
158
+ 'Success',
159
+ { message: 'ok' },
160
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
161
+ );
162
+ setTimeout(() => notificationHandler?.(device.id, bytesToHex(response)), 0);
163
+ return Promise.resolve();
164
+ });
165
+
166
+ const publicConnect = jest.fn();
167
+ const publicDisconnect = jest.fn();
168
+ const transportDisconnect = jest.fn();
169
+ emitter.on('device-connect', publicConnect);
170
+ emitter.on('device-disconnect', publicDisconnect);
171
+ emitter.on('transport-device-disconnect', transportDisconnect);
172
+ const bleTransport = configureTransport(nobleBle, emitter);
173
+
174
+ await bleTransport.acquire({ uuid: device.id, expectedProtocol: 'V2' });
175
+ disconnectHandler?.(device);
176
+
177
+ expect(publicConnect).not.toHaveBeenCalled();
178
+ expect(publicDisconnect).not.toHaveBeenCalled();
179
+ expect(transportDisconnect).toHaveBeenCalledWith({
180
+ id: device.id,
181
+ connectId: device.id,
182
+ name: device.name,
183
+ });
184
+ });
185
+
186
+ test('uses the Protocol V2 BLE writer with the Electron packet size', async () => {
187
+ const device = { id: 'chunked-pro2-id', name: 'OneKey Pro 2' };
188
+ const nobleBle = createNobleBle(device);
189
+ const bleTransport = configureTransport(nobleBle) as any;
190
+ const context = {
191
+ messageName: 'Ping',
192
+ timeoutMs: 1000,
193
+ highVolume: false,
194
+ generation: 1,
195
+ signal: new AbortController().signal,
196
+ };
197
+
198
+ await bleTransport.writeProtocolV2Frame(device.id, new Uint8Array(193), context, jest.fn());
199
+
200
+ expect(nobleBle.write).toHaveBeenCalledTimes(2);
201
+ expect(nobleBle.write.mock.calls.map(([, hex]) => hex.length / 2)).toEqual([192, 1]);
202
+ });
203
+
129
204
  test('detects Protocol V2 after Protocol V1 probe timeout', async () => {
130
205
  const device = { id: 'unknown-pro2-id', name: 'Unknown BLE Device' };
131
206
  const nobleBle = createNobleBle(device);
@@ -136,7 +211,6 @@ describe('ElectronBleTransport protocol detection', () => {
136
211
  { message: 'ok' },
137
212
  { router: PROTOCOL_V2_CHANNEL_BLE_UART }
138
213
  );
139
-
140
214
  nobleBle.onNotification.mockImplementation(handler => {
141
215
  notificationHandler = handler;
142
216
  return jest.fn();
@@ -183,6 +257,7 @@ describe('ElectronBleTransport protocol detection', () => {
183
257
  return Promise.resolve();
184
258
  });
185
259
  const transport = configureTransport(nobleBle);
260
+ const protocolV2Writer = jest.spyOn(transport as any, 'writeProtocolV2Frame');
186
261
 
187
262
  try {
188
263
  await expect(transport.acquire({ uuid: device.id })).resolves.toEqual(
@@ -191,6 +266,7 @@ describe('ElectronBleTransport protocol detection', () => {
191
266
  })
192
267
  );
193
268
  expect(transport.getProtocolType(device.id)).toBe('V1');
269
+ expect(protocolV2Writer).not.toHaveBeenCalled();
194
270
  } finally {
195
271
  await transport.release(device.id);
196
272
  }
@@ -215,19 +291,21 @@ describe('ElectronBleTransport protocol detection', () => {
215
291
  const device = { id: 'named-pro2-id', name: 'OneKey Pro 2' };
216
292
  const nobleBle = createNobleBle(device);
217
293
  let notificationHandler: ((deviceId: string, data: string) => void) | undefined;
218
- const probeResponse = ProtocolV2.encodeFrame(
219
- schemas,
220
- 'Success',
221
- { message: 'ok' },
222
- { router: PROTOCOL_V2_CHANNEL_BLE_UART }
223
- );
224
294
 
225
295
  nobleBle.onNotification.mockImplementation(handler => {
226
296
  notificationHandler = handler;
227
297
  return jest.fn();
228
298
  });
299
+ let responseSeq = 0;
229
300
  nobleBle.write.mockImplementation(() => {
230
- setTimeout(() => notificationHandler?.(device.id, bytesToHex(probeResponse)), 0);
301
+ responseSeq += 1;
302
+ const response = ProtocolV2.encodeFrame(
303
+ schemas,
304
+ 'Success',
305
+ { message: 'ok' },
306
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
307
+ );
308
+ setTimeout(() => notificationHandler?.(device.id, bytesToHex(response)), 0);
231
309
  return Promise.resolve();
232
310
  });
233
311
  const transport = configureTransport(nobleBle);
@@ -241,8 +319,179 @@ describe('ElectronBleTransport protocol detection', () => {
241
319
  );
242
320
  expect(nobleBle.write).toHaveBeenCalledTimes(1);
243
321
  expect(transport.getProtocolType(device.id)).toBe('V2');
322
+ await expect(transport.call(device.id, 'Ping', { message: 'after-probe' })).resolves.toEqual({
323
+ type: 'Success',
324
+ message: { message: 'ok' },
325
+ });
326
+ const sentSeqs = nobleBle.write.mock.calls.map(([, hex]) =>
327
+ Number.parseInt(hex.slice(12, 14), 16)
328
+ );
329
+ expect(sentSeqs).toEqual([1, 2]);
244
330
  } finally {
245
331
  await transport.release(device.id);
246
332
  }
247
333
  });
334
+
335
+ test('rejects the active Protocol V2 reader when pairing is rejected', async () => {
336
+ const device = { id: 'pairing-rejected-pro2-id', name: 'OneKey Pro 2' };
337
+ const nobleBle = createNobleBle(device);
338
+ let notificationHandler: ((deviceId: string, data: string) => void) | undefined;
339
+ let pairingRejected = false;
340
+ const probeResponse = ProtocolV2.encodeFrame(
341
+ schemas,
342
+ 'Success',
343
+ { message: 'ok' },
344
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART }
345
+ );
346
+
347
+ nobleBle.onNotification.mockImplementation(handler => {
348
+ notificationHandler = handler;
349
+ return jest.fn();
350
+ });
351
+ nobleBle.write.mockImplementation(() => {
352
+ setTimeout(
353
+ () =>
354
+ notificationHandler?.(
355
+ device.id,
356
+ pairingRejected ? 'PAIRING_REJECTED' : bytesToHex(probeResponse)
357
+ ),
358
+ 0
359
+ );
360
+ return Promise.resolve();
361
+ });
362
+ const transport = configureTransport(nobleBle);
363
+
364
+ try {
365
+ await transport.acquire({ uuid: device.id });
366
+ pairingRejected = true;
367
+
368
+ await expect(
369
+ transport.call(device.id, 'Ping', { message: 'pairing' }, { timeoutMs: 50 })
370
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceBondedCanceled });
371
+ } finally {
372
+ await transport.release(device.id);
373
+ }
374
+ });
375
+
376
+ test('rebuilds the active link when Core acquires the same device again', async () => {
377
+ const device = { id: 'repeated-acquire-pro2-id', name: 'OneKey Pro 2' };
378
+ const nobleBle = createNobleBle(device);
379
+ let notificationHandler: ((deviceId: string, data: string) => void) | undefined;
380
+ nobleBle.onNotification.mockImplementation(handler => {
381
+ notificationHandler = handler;
382
+ return jest.fn();
383
+ });
384
+ let responseSeq = 0;
385
+ nobleBle.write.mockImplementation(() => {
386
+ responseSeq += 1;
387
+ const sequencedResponse = ProtocolV2.encodeFrame(
388
+ schemas,
389
+ 'Success',
390
+ { message: 'ok' },
391
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
392
+ );
393
+ setTimeout(() => notificationHandler?.(device.id, bytesToHex(sequencedResponse)), 0);
394
+ return Promise.resolve();
395
+ });
396
+ const transport = configureTransport(nobleBle);
397
+
398
+ try {
399
+ await transport.acquire({ uuid: device.id });
400
+ await transport.acquire({ uuid: device.id, expectedProtocol: 'V2' });
401
+ await expect(
402
+ transport.call(device.id, 'Ping', { message: 'after-reacquire' })
403
+ ).resolves.toEqual({
404
+ type: 'Success',
405
+ message: { message: 'ok' },
406
+ });
407
+
408
+ const sentSeqs = nobleBle.write.mock.calls.map(([, hex]) =>
409
+ Number.parseInt(hex.slice(12, 14), 16)
410
+ );
411
+ expect(sentSeqs).toEqual([1, 2, 3]);
412
+ } finally {
413
+ await transport.release(device.id);
414
+ }
415
+ });
416
+
417
+ test('preserves the active Protocol V2 link when the same schema is configured again', async () => {
418
+ const device = { id: 'stable-schema-pro2-id', name: 'OneKey Pro 2' };
419
+ const nobleBle = createNobleBle(device);
420
+ let notificationHandler: ((deviceId: string, data: string) => void) | undefined;
421
+ nobleBle.onNotification.mockImplementation(handler => {
422
+ notificationHandler = handler;
423
+ return jest.fn();
424
+ });
425
+ let responseSeq = 0;
426
+ nobleBle.write.mockImplementation(() => {
427
+ responseSeq += 1;
428
+ const response = ProtocolV2.encodeFrame(
429
+ schemas,
430
+ 'Success',
431
+ { message: 'ok' },
432
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
433
+ );
434
+ setTimeout(() => notificationHandler?.(device.id, bytesToHex(response)), 0);
435
+ return Promise.resolve();
436
+ });
437
+ const bleTransport = configureTransport(nobleBle);
438
+
439
+ try {
440
+ await bleTransport.acquire({ uuid: device.id, expectedProtocol: 'V2' });
441
+ const invalidateAllLinks = jest.spyOn(
442
+ (bleTransport as any).protocolV2Links,
443
+ 'invalidateAllLinks'
444
+ );
445
+ bleTransport.configureProtocolV2(protocolV2Schema);
446
+ await new Promise<void>(resolve => {
447
+ setTimeout(resolve, 0);
448
+ });
449
+ expect(invalidateAllLinks).not.toHaveBeenCalled();
450
+ await expect(
451
+ bleTransport.call(device.id, 'Ping', { message: 'same-schema' })
452
+ ).resolves.toEqual({
453
+ type: 'Success',
454
+ message: { message: 'ok' },
455
+ });
456
+ const sentSeqs = nobleBle.write.mock.calls.map(([, hex]) =>
457
+ Number.parseInt(hex.slice(12, 14), 16)
458
+ );
459
+ expect(sentSeqs).toEqual([1, 2]);
460
+ } finally {
461
+ await bleTransport.release(device.id);
462
+ }
463
+ });
464
+
465
+ test('rejects oversized Protocol V2 requests before writing to Electron BLE', async () => {
466
+ const device = { id: 'oversized-frame-pro2-id', name: 'OneKey Pro 2' };
467
+ const nobleBle = createNobleBle(device);
468
+ let notificationHandler: ((deviceId: string, data: string) => void) | undefined;
469
+ const probeResponse = ProtocolV2.encodeFrame(
470
+ schemas,
471
+ 'Success',
472
+ { message: 'ok' },
473
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART }
474
+ );
475
+ nobleBle.onNotification.mockImplementation(handler => {
476
+ notificationHandler = handler;
477
+ return jest.fn();
478
+ });
479
+ nobleBle.write.mockImplementation(() => {
480
+ setTimeout(() => notificationHandler?.(device.id, bytesToHex(probeResponse)), 0);
481
+ return Promise.resolve();
482
+ });
483
+ const bleTransport = configureTransport(nobleBle);
484
+
485
+ try {
486
+ await bleTransport.acquire({ uuid: device.id, expectedProtocol: 'V2' });
487
+ expect(nobleBle.write).toHaveBeenCalledTimes(1);
488
+
489
+ await expect(
490
+ bleTransport.call(device.id, 'Ping', { message: 'x'.repeat(2048) })
491
+ ).rejects.toThrow(/Protocol V2 frame too large for transport/);
492
+ expect(nobleBle.write).toHaveBeenCalledTimes(1);
493
+ } finally {
494
+ await bleTransport.release(device.id);
495
+ }
496
+ });
248
497
  });
@@ -0,0 +1,288 @@
1
+ import transport, {
2
+ PROTOCOL_V2_CHANNEL_USB,
3
+ ProtocolV2,
4
+ ProtocolV2LinkError,
5
+ } from '@onekeyfe/hd-transport';
6
+
7
+ import WebUsbTransport from '../src/webusb';
8
+
9
+ const schema = {
10
+ nested: {
11
+ Ping: { fields: { message: { type: 'string', id: 1 } } },
12
+ Success: { fields: { message: { type: 'string', id: 1 } } },
13
+ MessageType: {
14
+ values: {
15
+ MessageType_Ping: 60206,
16
+ MessageType_Success: 60207,
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ describe('WebUsbTransport Protocol V2 timeout recovery', () => {
23
+ test('keeps active links when the Protocol V2 schema is configured repeatedly', () => {
24
+ const webusb = new WebUsbTransport() as any;
25
+ webusb.invalidateAllProtocolV2UsbLinks = jest.fn().mockResolvedValue(undefined);
26
+ const schemaSource = JSON.stringify(schema);
27
+
28
+ webusb.configureProtocolV2(schemaSource);
29
+ webusb.configureProtocolV2(schemaSource);
30
+
31
+ expect(webusb.invalidateAllProtocolV2UsbLinks).not.toHaveBeenCalled();
32
+
33
+ webusb.configureProtocolV2(
34
+ JSON.stringify({
35
+ ...schema,
36
+ nested: {
37
+ ...schema.nested,
38
+ Failure: { fields: { message: { type: 'string', id: 1 } } },
39
+ },
40
+ })
41
+ );
42
+ expect(webusb.invalidateAllProtocolV2UsbLinks).toHaveBeenCalledWith(
43
+ 'Protocol V2 schema reconfigured'
44
+ );
45
+ });
46
+
47
+ test('resets the connection between a failed V1 probe and the V2 probe', async () => {
48
+ const webusb = new WebUsbTransport() as any;
49
+ const path = 'pro2-webusb';
50
+ const events: string[] = [];
51
+ webusb.probeProtocolV1 = jest.fn().mockImplementation(() => {
52
+ events.push('probe-v1');
53
+ return Promise.resolve(false);
54
+ });
55
+ webusb.resetConnectionAfterProbe = jest.fn().mockImplementation(() => {
56
+ events.push('reset');
57
+ return Promise.resolve();
58
+ });
59
+ webusb.probeProtocolV2 = jest.fn().mockImplementation(() => {
60
+ events.push('probe-v2');
61
+ return Promise.resolve(true);
62
+ });
63
+
64
+ await expect(webusb.detectProtocol(path)).resolves.toBe('V2');
65
+
66
+ expect(events).toEqual(['probe-v1', 'reset', 'probe-v2']);
67
+ expect(webusb.deviceProtocol.get(path)).toBe('V2');
68
+ });
69
+
70
+ test('retries an expected Protocol V2 probe once after resetting the connection', async () => {
71
+ const webusb = new WebUsbTransport() as any;
72
+ const path = 'pro2-webusb';
73
+ webusb.probeProtocolV1 = jest.fn();
74
+ webusb.probeProtocolV2 = jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
75
+ webusb.resetConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
76
+
77
+ await expect(webusb.detectProtocol(path, 'V2')).resolves.toBe('V2');
78
+
79
+ expect(webusb.probeProtocolV2).toHaveBeenCalledTimes(2);
80
+ expect(webusb.probeProtocolV1).not.toHaveBeenCalled();
81
+ expect(webusb.resetConnectionAfterProbe).toHaveBeenCalledTimes(1);
82
+ expect(webusb.deviceProtocol.get(path)).toBe('V2');
83
+ });
84
+
85
+ test('reports a Protocol V2 probe timeout only after the bounded retry is exhausted', async () => {
86
+ const webusb = new WebUsbTransport() as any;
87
+ const path = 'pro2-webusb';
88
+ webusb.probeProtocolV1 = jest.fn();
89
+ webusb.probeProtocolV2 = jest.fn().mockResolvedValue(false);
90
+ webusb.resetConnectionAfterProbe = jest.fn().mockResolvedValue(undefined);
91
+
92
+ await expect(webusb.detectProtocol(path, 'V2')).rejects.toThrow(
93
+ 'Protocol V2 probe timeout after 2 attempts'
94
+ );
95
+
96
+ expect(webusb.probeProtocolV2).toHaveBeenCalledTimes(2);
97
+ expect(webusb.probeProtocolV1).not.toHaveBeenCalled();
98
+ expect(webusb.resetConnectionAfterProbe).toHaveBeenCalledTimes(2);
99
+ expect(webusb.deviceProtocol.has(path)).toBe(false);
100
+ });
101
+
102
+ test('invalidates and resets the cached connection before another call can start', async () => {
103
+ const webusb = new WebUsbTransport() as any;
104
+ const path = 'pro2-webusb';
105
+ webusb.messages = transport.parseConfigure(schema);
106
+ webusb.messagesV2 = transport.parseConfigure(schema);
107
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
108
+ webusb.readProtocolV2UsbPacket = jest.fn(() => new Promise<void>(() => {}));
109
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
110
+ webusb.resetConnectionAfterProbe = jest.fn();
111
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
112
+
113
+ await expect(
114
+ webusb.callProtocolV2(path, 'Ping', { message: 'timeout' }, { timeoutMs: 10 })
115
+ ).rejects.toThrow('timeout');
116
+
117
+ expect(webusb.resetProtocolV2UsbNativeLink).toHaveBeenCalledWith(
118
+ path,
119
+ expect.stringContaining('timeout')
120
+ );
121
+ expect(webusb.resetConnectionAfterProbe).not.toHaveBeenCalled();
122
+ });
123
+
124
+ test('does not reconnect inside a Protocol V2 frame read after a USB I/O failure', async () => {
125
+ const webusb = new WebUsbTransport() as any;
126
+ const path = 'pro2-webusb';
127
+ webusb.messages = transport.parseConfigure(schema);
128
+ webusb.messagesV2 = transport.parseConfigure(schema);
129
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
130
+ webusb.readProtocolV2UsbPacket = jest
131
+ .fn()
132
+ .mockRejectedValue(new Error('NetworkError: transferIn device disconnected'));
133
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
134
+ webusb.resetConnectionAfterProbe = jest.fn();
135
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
136
+
137
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'read-error' })).rejects.toThrow(
138
+ 'NetworkError'
139
+ );
140
+
141
+ expect(webusb.readProtocolV2UsbPacket).toHaveBeenCalledTimes(1);
142
+ expect(webusb.resetProtocolV2UsbNativeLink).toHaveBeenCalledWith(
143
+ path,
144
+ expect.stringContaining('NetworkError')
145
+ );
146
+ expect(webusb.resetConnectionAfterProbe).not.toHaveBeenCalled();
147
+ });
148
+
149
+ test('rejects an active Protocol V2 read without reconnecting after release', async () => {
150
+ const webusb = new WebUsbTransport() as any;
151
+ const path = 'pro2-webusb';
152
+ let markReadStarted: () => void = () => undefined;
153
+ const readStarted = new Promise<void>(resolve => {
154
+ markReadStarted = resolve;
155
+ });
156
+ webusb.messages = transport.parseConfigure(schema);
157
+ webusb.messagesV2 = transport.parseConfigure(schema);
158
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
159
+ webusb.readProtocolV2UsbPacket = jest.fn().mockImplementation(() => {
160
+ markReadStarted();
161
+ return new Promise<void>(() => {});
162
+ });
163
+ webusb.closeOpenDevice = jest.fn().mockResolvedValue(undefined);
164
+ webusb.connect = jest.fn();
165
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
166
+
167
+ const call = webusb.callProtocolV2(path, 'Ping', { message: 'release' });
168
+ await readStarted;
169
+ await webusb.release(path);
170
+
171
+ await expect(call).rejects.toThrow('WebUSB transport released');
172
+ expect(webusb.connect).not.toHaveBeenCalled();
173
+ });
174
+
175
+ test.each(['router', 'packet-source', 'ack-sequence', 'response-sequence', 'frame'] as const)(
176
+ 'invalidates cached state for typed Protocol V2 %s errors',
177
+ async code => {
178
+ const webusb = new WebUsbTransport() as any;
179
+ const path = 'pro2-webusb';
180
+ webusb.messages = transport.parseConfigure(schema);
181
+ webusb.messagesV2 = transport.parseConfigure(schema);
182
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
183
+ const recoveredResponse = ProtocolV2.encodeFrame(
184
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
185
+ 'Success',
186
+ { message: 'recovered' },
187
+ { seq: 1 }
188
+ );
189
+ webusb.readProtocolV2UsbPacket = jest
190
+ .fn()
191
+ .mockRejectedValueOnce(
192
+ new ProtocolV2LinkError(code, `Protocol V2 ${code} validation failed`)
193
+ )
194
+ .mockResolvedValue(recoveredResponse);
195
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
196
+ webusb.resetConnectionAfterProbe = jest.fn();
197
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
198
+
199
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'mismatch' })).rejects.toThrow(
200
+ `${code} validation failed`
201
+ );
202
+
203
+ expect(webusb.resetProtocolV2UsbNativeLink).toHaveBeenCalledWith(
204
+ path,
205
+ expect.stringContaining(`${code} validation failed`)
206
+ );
207
+ expect(webusb.resetConnectionAfterProbe).not.toHaveBeenCalled();
208
+
209
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test reconnect');
210
+ await expect(
211
+ webusb.callProtocolV2(path, 'Ping', { message: 'after-reset' })
212
+ ).resolves.toMatchObject({
213
+ type: 'Success',
214
+ message: { message: 'recovered' },
215
+ });
216
+ }
217
+ );
218
+
219
+ test('does not discard buffered Protocol V2 frames before each call', async () => {
220
+ const webusb = new WebUsbTransport() as any;
221
+ const path = 'pro2-webusb';
222
+ webusb.messages = transport.parseConfigure(schema);
223
+ webusb.messagesV2 = transport.parseConfigure(schema);
224
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
225
+ const firstResponse = ProtocolV2.encodeFrame(
226
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
227
+ 'Success',
228
+ { message: 'first' },
229
+ { router: PROTOCOL_V2_CHANNEL_USB, seq: 1 }
230
+ );
231
+ const secondResponse = ProtocolV2.encodeFrame(
232
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
233
+ 'Success',
234
+ { message: 'second' },
235
+ { router: PROTOCOL_V2_CHANNEL_USB, seq: 2 }
236
+ );
237
+ const coalescedResponses = new Uint8Array(firstResponse.length + secondResponse.length);
238
+ coalescedResponses.set(firstResponse);
239
+ coalescedResponses.set(secondResponse, firstResponse.length);
240
+ webusb.readProtocolV2UsbPacket = jest.fn().mockResolvedValue(coalescedResponses);
241
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
242
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
243
+
244
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'first' })).resolves.toMatchObject({
245
+ type: 'Success',
246
+ message: { message: 'first' },
247
+ });
248
+ await expect(webusb.callProtocolV2(path, 'Ping', { message: 'second' })).resolves.toMatchObject(
249
+ {
250
+ type: 'Success',
251
+ message: { message: 'second' },
252
+ }
253
+ );
254
+
255
+ expect(webusb.readProtocolV2UsbPacket).toHaveBeenCalledTimes(1);
256
+ });
257
+
258
+ test('keeps queued Protocol V2 read timeouts scoped to each call', async () => {
259
+ const webusb = new WebUsbTransport() as any;
260
+ const path = 'pro2-webusb';
261
+ let responseSequence = 0;
262
+ const readTimeouts: number[] = [];
263
+ webusb.messages = transport.parseConfigure(schema);
264
+ webusb.messagesV2 = transport.parseConfigure(schema);
265
+ webusb.writeProtocolV2UsbPacket = jest.fn().mockResolvedValue(undefined);
266
+ webusb.readProtocolV2UsbPacket = jest.fn().mockImplementation((_path, context) => {
267
+ responseSequence += 1;
268
+ readTimeouts.push(context.timeoutMs);
269
+ return Promise.resolve(
270
+ ProtocolV2.encodeFrame(
271
+ { protocolV1: webusb.messages, protocolV2: webusb.messagesV2 },
272
+ 'Success',
273
+ { message: 'ok' },
274
+ { seq: responseSequence }
275
+ )
276
+ );
277
+ });
278
+ webusb.resetProtocolV2UsbNativeLink = jest.fn().mockResolvedValue(undefined);
279
+ await webusb.rotateProtocolV2UsbGeneration(path, 'test connection');
280
+
281
+ await Promise.all([
282
+ webusb.callProtocolV2(path, 'Ping', { message: 'long' }, { timeoutMs: 1_000 }),
283
+ webusb.callProtocolV2(path, 'Ping', { message: 'short' }, { timeoutMs: 25 }),
284
+ ]);
285
+
286
+ expect(readTimeouts).toEqual([1_000, 25]);
287
+ });
288
+ });