@onekeyfe/hd-core 1.2.2-alpha.100 → 1.2.2-alpha.3

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 (69) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +20 -4
  2. package/__tests__/DeviceCommands.test.ts +13 -0
  3. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +0 -2
  4. package/__tests__/check-firmware-release-protocol-v2.test.ts +0 -2
  5. package/__tests__/device-lifecycle-events.test.ts +19 -0
  6. package/__tests__/deviceUploadNft.test.ts +68 -0
  7. package/__tests__/evmSignTypedData.test.ts +42 -0
  8. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +825 -12
  9. package/__tests__/open-wallet-session.test.ts +144 -37
  10. package/__tests__/pro2HostAssetPackage.test.ts +58 -0
  11. package/__tests__/protocol-v2-resources.test.ts +7 -4
  12. package/__tests__/protocol-v2-unlock-policy.test.ts +35 -1
  13. package/__tests__/protocol-v2.test.ts +720 -51
  14. package/dist/api/FirmwareUpdateV4.d.ts +4 -0
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  17. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  18. package/dist/api/evm/EVMSignTypedData.d.ts.map +1 -1
  19. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +2 -2
  20. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  21. package/dist/api/firmware/getBinary.d.ts +2 -2
  22. package/dist/api/protocol-v2/DeviceGetFirmwareUpdateStatus.d.ts.map +1 -1
  23. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  24. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  25. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  26. package/dist/core/index.d.ts +1 -0
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/device/Device.d.ts +1 -1
  29. package/dist/device/Device.d.ts.map +1 -1
  30. package/dist/device/DeviceConnector.d.ts +1 -1
  31. package/dist/device/DeviceConnector.d.ts.map +1 -1
  32. package/dist/events/ui-request.d.ts +3 -0
  33. package/dist/events/ui-request.d.ts.map +1 -1
  34. package/dist/index.d.ts +21 -5
  35. package/dist/index.js +2288 -1439
  36. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  37. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +2 -0
  38. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
  39. package/dist/protocols/protocol-v2/walletSession.d.ts +3 -0
  40. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  41. package/dist/types/settings.d.ts +2 -2
  42. package/dist/types/settings.d.ts.map +1 -1
  43. package/dist/utils/deviceFeaturesUtils.d.ts +3 -0
  44. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  45. package/dist/utils/patch.d.ts +1 -1
  46. package/dist/utils/patch.d.ts.map +1 -1
  47. package/dist/utils/pro2HostAssetPackage.d.ts +8 -0
  48. package/dist/utils/pro2HostAssetPackage.d.ts.map +1 -0
  49. package/package.json +4 -4
  50. package/src/api/FirmwareUpdateV4.ts +434 -75
  51. package/src/api/OpenWalletSession.ts +39 -9
  52. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +5 -2
  53. package/src/api/evm/EVMSignTypedData.ts +1 -0
  54. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +9 -3
  55. package/src/api/protocol-v2/DeviceGetFirmwareUpdateStatus.ts +7 -1
  56. package/src/api/protocol-v2/DeviceUploadNft.ts +31 -8
  57. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +27 -8
  58. package/src/api/protocol-v2/helpers.ts +8 -0
  59. package/src/core/index.ts +130 -14
  60. package/src/data/messages/messages-protocol-v2.json +1541 -1332
  61. package/src/device/Device.ts +3 -1
  62. package/src/device/DeviceConnector.ts +7 -3
  63. package/src/events/ui-request.ts +3 -0
  64. package/src/protocols/protocol-v2/resources.ts +9 -1
  65. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +8 -1
  66. package/src/protocols/protocol-v2/walletSession.ts +88 -50
  67. package/src/types/settings.ts +4 -2
  68. package/src/utils/deviceFeaturesUtils.ts +4 -0
  69. package/src/utils/pro2HostAssetPackage.ts +354 -0
@@ -254,6 +254,88 @@ describe('DeviceUploadWallpaper', () => {
254
254
  });
255
255
  });
256
256
 
257
+ test('uploads and applies the fixed wallpaper package on firmware 1.0.1', async () => {
258
+ const typedCall = jest.fn().mockImplementation((request, _response, params) => {
259
+ if (request === 'FilesystemDirMake') return { message: {} };
260
+ if (request === 'FilesystemFileWrite') {
261
+ const file = params.file as { data: Uint8Array; offset: number };
262
+ return { message: { processed_byte: file.offset + file.data.byteLength } };
263
+ }
264
+ if (request === 'DeviceSettingsSet') {
265
+ return { message: { message: 'wallpaper applied' } };
266
+ }
267
+ throw new Error(`Unexpected request: ${request}`);
268
+ });
269
+ const method = new DeviceUploadWallpaper({
270
+ id: 1,
271
+ payload: {
272
+ method: 'deviceUploadWallpaper',
273
+ jpegBase64: createJpegBase64(604, 1024),
274
+ fileName: 'ignored-on-package-firmware.bin',
275
+ },
276
+ });
277
+ const device = stubWallpaperDevice({
278
+ commands: { typedCall },
279
+ state: { versions: { firmware: '1.0.1' } },
280
+ getCurrentFirmwareVersionString: jest.fn(() => '1.0.1'),
281
+ });
282
+ (method as any).device = device;
283
+ method.postMessage = jest.fn();
284
+
285
+ method.init();
286
+ const result = await method.run();
287
+
288
+ const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite');
289
+ expect(new Set(fileWrites.map(call => call[2].file.path))).toEqual(
290
+ new Set(['vol1:/wallpapers/wallpaper.okpkg'])
291
+ );
292
+ expect(fileWrites[0][2].file.data.subarray(0, 4)).toEqual(
293
+ new Uint8Array([0x4f, 0x4b, 0x50, 0x50])
294
+ );
295
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSettingsSet', 'Success', {
296
+ settings: { wallpaper_path: 'vol1:/wallpapers/wallpaper.okpkg' },
297
+ });
298
+ expect(result).toMatchObject({
299
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
300
+ colorFormat: 'RGB565',
301
+ message: 'wallpaper applied',
302
+ });
303
+ });
304
+
305
+ test('keeps the legacy wallpaper path on a 1.0.1 prerelease', async () => {
306
+ const typedCall = jest.fn().mockImplementation((request, _response, params) => {
307
+ if (request === 'FilesystemDirMake') return { message: {} };
308
+ if (request === 'FilesystemFileWrite') {
309
+ const file = params.file as { data: Uint8Array; offset: number };
310
+ return { message: { processed_byte: file.offset + file.data.byteLength } };
311
+ }
312
+ if (request === 'DeviceSettingsSet') return { message: {} };
313
+ throw new Error(`Unexpected request: ${request}`);
314
+ });
315
+ const method = new DeviceUploadWallpaper({
316
+ id: 1,
317
+ payload: {
318
+ method: 'deviceUploadWallpaper',
319
+ jpegBase64: createJpegBase64(604, 1024),
320
+ fileName: 'prerelease-wallpaper.bin',
321
+ },
322
+ });
323
+ const device = stubWallpaperDevice({
324
+ commands: { typedCall },
325
+ state: { versions: { firmware: '1.0.1-beta.1' } },
326
+ });
327
+ (method as any).device = device;
328
+
329
+ method.init();
330
+ const result = await method.run();
331
+
332
+ expect(result.path).toBe('vol1:/wallpapers/prerelease-wallpaper.bin');
333
+ const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite');
334
+ expect(new Set(fileWrites.map(call => call[2].file.path))).toEqual(
335
+ new Set(['vol1:/wallpapers/prerelease-wallpaper.bin'])
336
+ );
337
+ });
338
+
257
339
  test('文件上传失败时不修改 wallpaper_path', async () => {
258
340
  const typedCall = jest.fn().mockImplementation(request => {
259
341
  if (request === 'FilesystemDirMake') return { message: {} };
@@ -1022,6 +1104,7 @@ describe('Protocol V2 feature adapter', () => {
1022
1104
  newSession: 'session-1',
1023
1105
  unlockedAttachPin: false,
1024
1106
  resumed: false,
1107
+ walletStatusRefreshed: true,
1025
1108
  });
1026
1109
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1027
1110
  eventless_wallet_session: true,
@@ -1032,10 +1115,9 @@ describe('Protocol V2 feature adapter', () => {
1032
1115
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
1033
1116
  passphrase: '',
1034
1117
  on_device: false,
1035
- });
1036
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1037
1118
  seed_domains: [],
1038
1119
  });
1120
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1039
1121
  device.passphraseState = 'state-1';
1040
1122
  expect(device.getInternalState()).toBe('session-1');
1041
1123
  });
@@ -1088,13 +1170,10 @@ describe('Protocol V2 feature adapter', () => {
1088
1170
  expect(
1089
1171
  typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1090
1172
  ).toHaveLength(1);
1091
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1092
- seed_domains: [],
1093
- });
1173
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1094
1174
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1095
1175
  session_id: 'standard-session',
1096
1176
  btc_test_address: 'standard-state',
1097
- seed_domains: [],
1098
1177
  });
1099
1178
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
1100
1179
  passphraseState: 'standard-state',
@@ -1285,6 +1364,7 @@ describe('Protocol V2 feature adapter', () => {
1285
1364
  newSession: 'main-wallet-session',
1286
1365
  unlockedAttachPin: undefined,
1287
1366
  resumed: false,
1367
+ walletStatusRefreshed: true,
1288
1368
  });
1289
1369
 
1290
1370
  expect(promptPassphrase).toHaveBeenCalled();
@@ -1371,6 +1451,7 @@ describe('Protocol V2 feature adapter', () => {
1371
1451
  newSession: 'hidden-after-unlock-session',
1372
1452
  unlockedAttachPin: undefined,
1373
1453
  resumed: false,
1454
+ walletStatusRefreshed: true,
1374
1455
  });
1375
1456
 
1376
1457
  expect(unlockDevice).not.toHaveBeenCalled();
@@ -1457,10 +1538,9 @@ describe('Protocol V2 feature adapter', () => {
1457
1538
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
1458
1539
  passphrase: 'host hidden wallet',
1459
1540
  on_device: false,
1460
- });
1461
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1462
1541
  seed_domains: [],
1463
1542
  });
1543
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1464
1544
  });
1465
1545
 
1466
1546
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1569,7 +1649,6 @@ describe('Protocol V2 feature adapter', () => {
1569
1649
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1570
1650
  session_id: 'session-a',
1571
1651
  btc_test_address: 'state-a',
1572
- seed_domains: [],
1573
1652
  });
1574
1653
  expect(device.getInternalState()).toBe('session-b');
1575
1654
  });
@@ -1643,7 +1722,6 @@ describe('Protocol V2 feature adapter', () => {
1643
1722
  {
1644
1723
  session_id: 'session-a',
1645
1724
  btc_test_address: 'state-a',
1646
- seed_domains: [],
1647
1725
  },
1648
1726
  ],
1649
1727
  ]);
@@ -1690,10 +1768,10 @@ describe('Protocol V2 feature adapter', () => {
1690
1768
  [
1691
1769
  'DeviceSessionAskPassphrase',
1692
1770
  'Success',
1693
- { passphrase: 'host hidden wallet', on_device: false },
1771
+ { passphrase: 'host hidden wallet', on_device: false, seed_domains: [] },
1694
1772
  ],
1695
1773
  ['DeviceStatusGet', 'DeviceStatus', {}],
1696
- ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1774
+ ['DeviceSessionGet', 'DeviceSession', {}],
1697
1775
  ]);
1698
1776
  });
1699
1777
 
@@ -1855,7 +1933,6 @@ describe('Protocol V2 feature adapter', () => {
1855
1933
  expect(promptPassphrase).not.toHaveBeenCalled();
1856
1934
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1857
1935
  btc_test_address: 'expected-state',
1858
- seed_domains: [],
1859
1936
  });
1860
1937
  expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1861
1938
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
@@ -2246,7 +2323,6 @@ describe('Protocol V2 feature adapter', () => {
2246
2323
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2247
2324
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2248
2325
  btc_test_address: 'expected-state',
2249
- seed_domains: [],
2250
2326
  });
2251
2327
  expect(promptPassphrase).not.toHaveBeenCalled();
2252
2328
  });
@@ -2320,7 +2396,6 @@ describe('Protocol V2 feature adapter', () => {
2320
2396
  {
2321
2397
  session_id: 'session-pro2-app',
2322
2398
  btc_test_address: 'state-pro2-app',
2323
- seed_domains: [],
2324
2399
  },
2325
2400
  ],
2326
2401
  ]);
@@ -2510,10 +2585,9 @@ describe('Protocol V2 feature adapter', () => {
2510
2585
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2511
2586
  passphrase: 'host hidden wallet',
2512
2587
  on_device: false,
2513
- });
2514
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2515
2588
  seed_domains: [],
2516
2589
  });
2590
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2517
2591
  });
2518
2592
 
2519
2593
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2593,16 +2667,14 @@ describe('Protocol V2 feature adapter', () => {
2593
2667
  expect(device.getInternalState()).toBeUndefined();
2594
2668
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2595
2669
  btc_test_address: 'expected-state',
2596
- seed_domains: [],
2597
2670
  });
2598
2671
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2599
2672
  passphrase: 'host hidden wallet',
2600
2673
  on_device: false,
2601
- });
2602
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2603
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2604
2674
  seed_domains: [],
2605
2675
  });
2676
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2677
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2606
2678
  });
2607
2679
 
2608
2680
  test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
@@ -2704,12 +2776,11 @@ describe('Protocol V2 feature adapter', () => {
2704
2776
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2705
2777
  eventless_wallet_session: true,
2706
2778
  });
2707
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2708
- seed_domains: [],
2709
- });
2779
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2710
2780
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2711
2781
  passphrase: '',
2712
2782
  on_device: false,
2783
+ seed_domains: [],
2713
2784
  });
2714
2785
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2715
2786
  expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
@@ -4820,6 +4891,23 @@ describe('Protocol V2 firmware update targets', () => {
4820
4891
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4821
4892
  });
4822
4893
 
4894
+ test('stops V4 bootloader reconnect polling on a stale BLE bond', async () => {
4895
+ const method = new FirmwareUpdateV4({
4896
+ id: 1,
4897
+ payload: {
4898
+ method: 'firmwareUpdateV4',
4899
+ },
4900
+ });
4901
+ const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
4902
+ const reconnectProtocolV2Device = jest.fn().mockRejectedValue(staleBondError);
4903
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4904
+
4905
+ await expect((method as any).waitForProtocolV2BootloaderMode(60_000, 0)).rejects.toBe(
4906
+ staleBondError
4907
+ );
4908
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4909
+ });
4910
+
4823
4911
  test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
4824
4912
  const method = new FirmwareUpdateV4({
4825
4913
  id: 1,
@@ -4901,6 +4989,87 @@ describe('Protocol V2 firmware update targets', () => {
4901
4989
  expect(initialize).not.toHaveBeenCalled();
4902
4990
  });
4903
4991
 
4992
+ test('reacquires the same WebUSB device without a protocol probe during install recovery', async () => {
4993
+ const method = new FirmwareUpdateV4({
4994
+ id: 1,
4995
+ payload: {
4996
+ method: 'firmwareUpdateV4',
4997
+ },
4998
+ });
4999
+ const acquire = jest.fn().mockResolvedValue('usb-path');
5000
+ const enumerate = jest.fn().mockResolvedValue({
5001
+ descriptors: [
5002
+ { id: 'other-usb', path: 'other-usb', protocolType: 'V2' },
5003
+ { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
5004
+ ],
5005
+ });
5006
+ const commands = { disposed: true, mainId: '' };
5007
+ const device = stubDevice({
5008
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
5009
+ deviceConnector: { acquire, enumerate },
5010
+ updateDescriptor: jest.fn(),
5011
+ acquire: jest.fn(),
5012
+ commands,
5013
+ getCommands: () => commands,
5014
+ mainId: 'old-usb-path',
5015
+ });
5016
+ const getDevices = jest.spyOn(DevicePool, 'getDevices');
5017
+ const isBrowserWebUsb = jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(false);
5018
+ const isDesktopWebUsb = jest.spyOn(DataManager, 'isDesktopWebUsb').mockReturnValue(true);
5019
+ (method as any).device = device;
5020
+ (method as any).protocolV2ExpectedPath = 'usb-path';
5021
+
5022
+ try {
5023
+ await (method as any).reconnectProtocolV2Device({ skipProtocolProbe: true });
5024
+ } finally {
5025
+ getDevices.mockRestore();
5026
+ isBrowserWebUsb.mockRestore();
5027
+ isDesktopWebUsb.mockRestore();
5028
+ }
5029
+
5030
+ expect(getDevices).not.toHaveBeenCalled();
5031
+ expect(acquire).toHaveBeenCalledWith(
5032
+ 'usb-path',
5033
+ null,
5034
+ undefined,
5035
+ 'V2',
5036
+ undefined,
5037
+ undefined,
5038
+ true
5039
+ );
5040
+ expect(device.acquire).not.toHaveBeenCalled();
5041
+ expect(commands.disposed).toBe(false);
5042
+ expect(commands.mainId).toBe('usb-path');
5043
+ });
5044
+
5045
+ test('reacquires the same BLE peripheral by id without scanning during install recovery', async () => {
5046
+ const method = new FirmwareUpdateV4({
5047
+ id: 1,
5048
+ payload: {
5049
+ method: 'firmwareUpdateV4',
5050
+ },
5051
+ });
5052
+ const acquire = jest.fn().mockResolvedValue({ uuid: 'ble-id', protocolType: 'V2' });
5053
+ const enumerate = jest.fn().mockResolvedValue({
5054
+ descriptors: [{ id: 'ble-id', name: 'OneKey Pro 2', protocolType: 'V2' }],
5055
+ });
5056
+ const commands = { disposed: true, mainId: '' };
5057
+ (method as any).isBleReconnect = jest.fn(() => true);
5058
+ (method as any).device = stubDevice({
5059
+ originalDescriptor: { id: 'ble-id', path: 'ble-path', protocolType: 'V2' },
5060
+ deviceConnector: { acquire, enumerate },
5061
+ commands,
5062
+ getCommands: () => commands,
5063
+ });
5064
+
5065
+ await (method as any).reconnectProtocolV2Device({ skipProtocolProbe: true });
5066
+
5067
+ expect(enumerate).not.toHaveBeenCalled();
5068
+ expect(acquire).toHaveBeenCalledWith('ble-id', null, true, 'V2', undefined, undefined, true);
5069
+ expect(commands.disposed).toBe(false);
5070
+ expect(commands.mainId).toBe('ble-id');
5071
+ });
5072
+
4904
5073
  test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
4905
5074
  const method = new FirmwareUpdateV4({
4906
5075
  id: 1,
@@ -5290,6 +5459,108 @@ describe('Protocol V2 firmware update targets', () => {
5290
5459
  });
5291
5460
  });
5292
5461
 
5462
+ test('treats a typed BLE disconnect during Protocol V2 normal reboot as expected', async () => {
5463
+ const method = new FirmwareUpdateV4({
5464
+ id: 1,
5465
+ payload: {
5466
+ method: 'firmwareUpdateV4',
5467
+ },
5468
+ });
5469
+ const typedCall = jest
5470
+ .fn()
5471
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
5472
+
5473
+ (method as any).device = stubDevice({
5474
+ getCommands: () => ({ typedCall }),
5475
+ });
5476
+
5477
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
5478
+ message: 'Device rebooted successfully',
5479
+ });
5480
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
5481
+ DeviceRebootType.Normal
5482
+ );
5483
+ });
5484
+
5485
+ test('uses a short timeout and continues after a BLE reboot response timeout', async () => {
5486
+ const method = new FirmwareUpdateV4({
5487
+ id: 1,
5488
+ payload: {
5489
+ method: 'firmwareUpdateV4',
5490
+ },
5491
+ });
5492
+ const typedCall = jest
5493
+ .fn()
5494
+ .mockRejectedValue(
5495
+ ERRORS.TypedError(
5496
+ HardwareErrorCode.BleTimeoutError,
5497
+ 'Lowlevel response timeout after 5000ms for DeviceReboot'
5498
+ )
5499
+ );
5500
+
5501
+ (method as any).device = stubDevice({
5502
+ getCommands: () => ({ typedCall }),
5503
+ });
5504
+
5505
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
5506
+ message: 'Device rebooted successfully',
5507
+ });
5508
+ expect(typedCall).toHaveBeenCalledWith(
5509
+ 'DeviceReboot',
5510
+ 'Success',
5511
+ { reboot_type: DeviceRebootType.Normal },
5512
+ { timeoutMs: 5000 }
5513
+ );
5514
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
5515
+ DeviceRebootType.Normal
5516
+ );
5517
+ });
5518
+
5519
+ test('continues after a USB Protocol V2 reboot response timeout', async () => {
5520
+ const method = new FirmwareUpdateV4({
5521
+ id: 1,
5522
+ payload: {
5523
+ method: 'firmwareUpdateV4',
5524
+ },
5525
+ });
5526
+ const timeoutError = Object.assign(
5527
+ new Error('Protocol V2 response timeout after 5000ms for DeviceReboot'),
5528
+ { code: 'response-timeout' }
5529
+ );
5530
+ const typedCall = jest.fn().mockRejectedValue(timeoutError);
5531
+
5532
+ (method as any).device = stubDevice({
5533
+ getCommands: () => ({ typedCall }),
5534
+ });
5535
+
5536
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
5537
+ message: 'Device rebooted successfully',
5538
+ });
5539
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
5540
+ DeviceRebootType.Normal
5541
+ );
5542
+ });
5543
+
5544
+ test('propagates non-transport errors from a Protocol V2 reboot', async () => {
5545
+ const method = new FirmwareUpdateV4({
5546
+ id: 1,
5547
+ payload: {
5548
+ method: 'firmwareUpdateV4',
5549
+ },
5550
+ });
5551
+ const rebootError = new Error('Device rejected reboot');
5552
+ const typedCall = jest.fn().mockRejectedValue(rebootError);
5553
+
5554
+ (method as any).device = stubDevice({
5555
+ getCommands: () => ({ typedCall }),
5556
+ });
5557
+
5558
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).rejects.toBe(
5559
+ rebootError
5560
+ );
5561
+ expect((method as any).device.markProtocolV2Reboot).not.toHaveBeenCalled();
5562
+ });
5563
+
5293
5564
  test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
5294
5565
  const method = new FirmwareUpdateV4({
5295
5566
  id: 1,
@@ -5365,7 +5636,11 @@ describe('Protocol V2 firmware update targets', () => {
5365
5636
  expect(call).toHaveBeenCalledWith(
5366
5637
  'DeviceFirmwareUpdateRequest',
5367
5638
  {},
5368
- { returnAfterWrite: true }
5639
+ expect.objectContaining({
5640
+ returnAfterWrite: true,
5641
+ expectedTypes: ['Success'],
5642
+ onResponseAfterWrite: expect.any(Function),
5643
+ })
5369
5644
  );
5370
5645
  expect(typedCall.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
5371
5646
  expect((method.postMessage as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
@@ -5406,7 +5681,7 @@ describe('Protocol V2 firmware update targets', () => {
5406
5681
  expect(method.postProgressMessage).not.toHaveBeenCalled();
5407
5682
  });
5408
5683
 
5409
- test('does not enter install state when writing the empty request fails', async () => {
5684
+ test('does not enter install state when the empty request fails', async () => {
5410
5685
  const method = new FirmwareUpdateV4({
5411
5686
  id: 1,
5412
5687
  payload: {
@@ -5414,7 +5689,7 @@ describe('Protocol V2 firmware update targets', () => {
5414
5689
  },
5415
5690
  });
5416
5691
  const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5417
- const call = jest.fn().mockRejectedValue(new Error('write failed'));
5692
+ const call = jest.fn().mockRejectedValue(new Error('request failed'));
5418
5693
 
5419
5694
  (method as any).device = stubDevice({
5420
5695
  getCommands: () => ({ typedCall, call }),
@@ -5429,13 +5704,118 @@ describe('Protocol V2 firmware update targets', () => {
5429
5704
  (method as any).protocolV2StartFirmwareUpdate({
5430
5705
  targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5431
5706
  })
5432
- ).rejects.toThrow('write failed');
5707
+ ).rejects.toThrow('request failed');
5433
5708
 
5434
- expect(call).toHaveBeenCalledTimes(1);
5709
+ expect(call).toHaveBeenCalledWith(
5710
+ 'DeviceFirmwareUpdateRequest',
5711
+ {},
5712
+ expect.objectContaining({
5713
+ returnAfterWrite: true,
5714
+ expectedTypes: ['Success'],
5715
+ onResponseAfterWrite: expect.any(Function),
5716
+ })
5717
+ );
5435
5718
  expect(method.postTipMessage).not.toHaveBeenCalled();
5436
5719
  expect(method.postProgressMessage).not.toHaveBeenCalled();
5437
5720
  });
5438
5721
 
5722
+ test('continues to install polling when the empty request releases BLE transport', async () => {
5723
+ const method = new FirmwareUpdateV4({
5724
+ id: 1,
5725
+ payload: {
5726
+ method: 'firmwareUpdateV4',
5727
+ },
5728
+ });
5729
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5730
+ const call = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
5731
+
5732
+ (method as any).device = stubDevice({
5733
+ getCommands: () => ({ typedCall, call }),
5734
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
5735
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
5736
+ });
5737
+ (method as any).isBleReconnect = jest.fn(() => true);
5738
+ method.postMessage = jest.fn();
5739
+
5740
+ await expect(
5741
+ (method as any).protocolV2StartFirmwareUpdate({
5742
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5743
+ })
5744
+ ).resolves.toBeUndefined();
5745
+
5746
+ expect(call).toHaveBeenCalledWith(
5747
+ 'DeviceFirmwareUpdateRequest',
5748
+ {},
5749
+ expect.objectContaining({
5750
+ returnAfterWrite: true,
5751
+ expectedTypes: ['Success'],
5752
+ onResponseAfterWrite: expect.any(Function),
5753
+ })
5754
+ );
5755
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceFirmwareUpdateRequest', 'Success', {});
5756
+ expect((method as any).protocolV2InstallNeedsReconnect).toBe(true);
5757
+ expect((method as any).protocolV2InstallDisconnectObserved).toBe(true);
5758
+ });
5759
+
5760
+ test.each([
5761
+ { name: 'bootloader', targetId: 3, path: 'vol0:/bootloader.bin' },
5762
+ { name: 'application', targetId: 4, path: 'vol0:/application_p1.bin' },
5763
+ { name: 'secure element', targetId: 7, path: 'vol0:/se01.bin' },
5764
+ ])(
5765
+ 'reconnects a released BLE install link before polling $name status without DeviceInfo',
5766
+ async ({ targetId, path }) => {
5767
+ const method = new FirmwareUpdateV4({
5768
+ id: 1,
5769
+ payload: {
5770
+ method: 'firmwareUpdateV4',
5771
+ },
5772
+ });
5773
+ const targets = [{ target_id: targetId, path }];
5774
+ const typedCall = jest
5775
+ .fn()
5776
+ .mockResolvedValueOnce({
5777
+ type: 'DeviceFirmwareUpdateStatus',
5778
+ message: { records: [{ ...targets[0], status: 1, payload_version: 0 }] },
5779
+ })
5780
+ .mockResolvedValueOnce({
5781
+ type: 'DeviceFirmwareUpdateStatus',
5782
+ message: { records: [{ ...targets[0], status: 2, payload_version: 0x010000 }] },
5783
+ });
5784
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5785
+ const verifyProtocolV2ReconnectIdentity = jest.fn();
5786
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5787
+ callback: () => void
5788
+ ) => {
5789
+ callback();
5790
+ return 0 as any;
5791
+ }) as typeof setTimeout);
5792
+
5793
+ (method as any).device = stubDevice({
5794
+ getCommands: () => ({ typedCall }),
5795
+ });
5796
+ (method as any).isBleReconnect = jest.fn(() => true);
5797
+ (method as any).protocolV2InstallNeedsReconnect = true;
5798
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5799
+ (method as any).verifyProtocolV2ReconnectIdentity = verifyProtocolV2ReconnectIdentity;
5800
+ method.postProgressMessage = jest.fn();
5801
+
5802
+ try {
5803
+ await expect(
5804
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(targets, true)
5805
+ ).resolves.toBeUndefined();
5806
+ } finally {
5807
+ setTimeoutSpy.mockRestore();
5808
+ }
5809
+
5810
+ expect(reconnectProtocolV2Device).toHaveBeenCalledWith({ skipProtocolProbe: true });
5811
+ expect(verifyProtocolV2ReconnectIdentity).not.toHaveBeenCalled();
5812
+ expect(typedCall.mock.calls.map(callArgs => callArgs[0])).toEqual([
5813
+ 'DeviceFirmwareUpdateStatusGet',
5814
+ 'DeviceFirmwareUpdateStatusGet',
5815
+ ]);
5816
+ }
5817
+ );
5818
+
5439
5819
  test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
5440
5820
  const method = new FirmwareUpdateV4({
5441
5821
  id: 1,
@@ -5479,7 +5859,7 @@ describe('Protocol V2 firmware update targets', () => {
5479
5859
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5480
5860
  });
5481
5861
 
5482
- test('keeps polling after install ACK until the target finishes', async () => {
5862
+ test('keeps polling after an empty status response until the target finishes', async () => {
5483
5863
  const method = new FirmwareUpdateV4({
5484
5864
  id: 1,
5485
5865
  payload: {
@@ -5488,7 +5868,10 @@ describe('Protocol V2 firmware update targets', () => {
5488
5868
  });
5489
5869
  const typedCall = jest
5490
5870
  .fn()
5491
- .mockResolvedValueOnce({ type: 'Success', message: {} })
5871
+ .mockResolvedValueOnce({
5872
+ type: 'DeviceFirmwareUpdateStatus',
5873
+ message: { records: [] },
5874
+ })
5492
5875
  .mockResolvedValueOnce({
5493
5876
  type: 'DeviceFirmwareUpdateStatus',
5494
5877
  message: { records: [] },
@@ -5630,6 +6013,38 @@ describe('Protocol V2 firmware update targets', () => {
5630
6013
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5631
6014
  });
5632
6015
 
6016
+ test('does not count a stale finished target when only another target was live', () => {
6017
+ const method = new FirmwareUpdateV4({
6018
+ id: 1,
6019
+ payload: {
6020
+ method: 'firmwareUpdateV4',
6021
+ },
6022
+ });
6023
+ const assertProtocolV2TargetStatus = (method as any).assertProtocolV2TargetStatus.bind(method);
6024
+ const expectedTargetIds = new Set([3, 4]);
6025
+ const expectedPaths = new Map([
6026
+ [3, 'vol0:/bootloader.bin'],
6027
+ [4, 'vol0:/application_p1.bin'],
6028
+ ]);
6029
+ const liveTargetIds = new Set([4]);
6030
+ method.postProgressMessage = jest.fn();
6031
+
6032
+ const completed = assertProtocolV2TargetStatus(
6033
+ [
6034
+ { target_id: 3, status: 2, path: 'vol0:/bootloader.bin' },
6035
+ { target_id: 4, status: 2, path: 'vol0:/application_p1.bin' },
6036
+ ],
6037
+ expectedTargetIds,
6038
+ expectedPaths,
6039
+ liveTargetIds
6040
+ );
6041
+
6042
+ expect(completed).toBe(false);
6043
+ expect((method as any).protocolV2CompletedTargetIds).toEqual(new Set([4]));
6044
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
6045
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
6046
+ });
6047
+
5633
6048
  test('ignores stale failed records until the current install starts', async () => {
5634
6049
  const method = new FirmwareUpdateV4({
5635
6050
  id: 1,
@@ -5678,6 +6093,141 @@ describe('Protocol V2 firmware update targets', () => {
5678
6093
  expect(typedCall).toHaveBeenCalledTimes(3);
5679
6094
  });
5680
6095
 
6096
+ test('ignores leftover failed records while another leftover target is still pending', async () => {
6097
+ const method = new FirmwareUpdateV4({
6098
+ id: 1,
6099
+ payload: {
6100
+ method: 'firmwareUpdateV4',
6101
+ },
6102
+ });
6103
+ const typedCall = jest
6104
+ .fn()
6105
+ .mockResolvedValueOnce({
6106
+ type: 'DeviceFirmwareUpdateStatus',
6107
+ message: {
6108
+ records: [
6109
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6110
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6111
+ ],
6112
+ },
6113
+ })
6114
+ .mockResolvedValueOnce({
6115
+ type: 'DeviceFirmwareUpdateStatus',
6116
+ message: {
6117
+ records: [
6118
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6119
+ { target_id: 4, status: 0, path: 'vol0:/application_p1.bin' },
6120
+ ],
6121
+ },
6122
+ })
6123
+ .mockResolvedValueOnce({
6124
+ type: 'DeviceFirmwareUpdateStatus',
6125
+ message: {
6126
+ records: [
6127
+ { target_id: 3, status: 2, path: 'vol0:/bootloader.bin' },
6128
+ { target_id: 4, status: 2, path: 'vol0:/application_p1.bin' },
6129
+ ],
6130
+ },
6131
+ });
6132
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6133
+ callback: () => void
6134
+ ) => {
6135
+ callback();
6136
+ return 0 as any;
6137
+ }) as typeof setTimeout);
6138
+
6139
+ (method as any).device = stubDevice({
6140
+ getCommands: () => ({ typedCall }),
6141
+ });
6142
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6143
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6144
+ method.postProgressMessage = jest.fn();
6145
+
6146
+ try {
6147
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
6148
+ [
6149
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6150
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6151
+ ],
6152
+ true
6153
+ );
6154
+ } finally {
6155
+ setTimeoutSpy.mockRestore();
6156
+ }
6157
+
6158
+ expect(typedCall).toHaveBeenCalledTimes(3);
6159
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6160
+ });
6161
+
6162
+ test('reports failed only after that target was live in the current install', async () => {
6163
+ const method = new FirmwareUpdateV4({
6164
+ id: 1,
6165
+ payload: {
6166
+ method: 'firmwareUpdateV4',
6167
+ },
6168
+ });
6169
+ const typedCall = jest
6170
+ .fn()
6171
+ .mockResolvedValueOnce({
6172
+ type: 'DeviceFirmwareUpdateStatus',
6173
+ message: {
6174
+ records: [
6175
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6176
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6177
+ ],
6178
+ },
6179
+ })
6180
+ .mockResolvedValueOnce({
6181
+ type: 'DeviceFirmwareUpdateStatus',
6182
+ message: {
6183
+ records: [
6184
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6185
+ { target_id: 4, status: 0, path: 'vol0:/application_p1.bin' },
6186
+ ],
6187
+ },
6188
+ })
6189
+ .mockResolvedValueOnce({
6190
+ type: 'DeviceFirmwareUpdateStatus',
6191
+ message: {
6192
+ records: [
6193
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6194
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6195
+ ],
6196
+ },
6197
+ });
6198
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6199
+ callback: () => void
6200
+ ) => {
6201
+ callback();
6202
+ return 0 as any;
6203
+ }) as typeof setTimeout);
6204
+
6205
+ (method as any).device = stubDevice({
6206
+ getCommands: () => ({ typedCall }),
6207
+ });
6208
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6209
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6210
+
6211
+ try {
6212
+ await expect(
6213
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6214
+ [
6215
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6216
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6217
+ ],
6218
+ true
6219
+ )
6220
+ ).rejects.toMatchObject({
6221
+ errorCode: HardwareErrorCode.FirmwareError,
6222
+ message: 'Protocol V2 firmware install failed',
6223
+ });
6224
+ } finally {
6225
+ setTimeoutSpy.mockRestore();
6226
+ }
6227
+
6228
+ expect(typedCall).toHaveBeenCalledTimes(3);
6229
+ });
6230
+
5681
6231
  test('waits five minutes before rejecting normal mode without install evidence', async () => {
5682
6232
  const method = new FirmwareUpdateV4({
5683
6233
  id: 1,
@@ -5784,7 +6334,6 @@ describe('Protocol V2 firmware update targets', () => {
5784
6334
  });
5785
6335
  const typedCall = jest
5786
6336
  .fn()
5787
- .mockResolvedValueOnce({ type: 'Success', message: {} })
5788
6337
  .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
5789
6338
  .mockResolvedValueOnce({
5790
6339
  type: 'DeviceFirmwareUpdateStatus',
@@ -5801,6 +6350,7 @@ describe('Protocol V2 firmware update targets', () => {
5801
6350
  (method as any).device = stubDevice({
5802
6351
  getCommands: () => ({ typedCall }),
5803
6352
  });
6353
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
5804
6354
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5805
6355
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5806
6356
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
@@ -5821,6 +6371,54 @@ describe('Protocol V2 firmware update targets', () => {
5821
6371
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5822
6372
  });
5823
6373
 
6374
+ test('uses the confirmed install request as evidence after the same device returns', async () => {
6375
+ const method = new FirmwareUpdateV4({
6376
+ id: 1,
6377
+ payload: {
6378
+ method: 'firmwareUpdateV4',
6379
+ },
6380
+ });
6381
+ const typedCall = jest
6382
+ .fn()
6383
+ .mockRejectedValueOnce(new Error('The USB device was disconnected'))
6384
+ .mockResolvedValueOnce({
6385
+ type: 'DeviceFirmwareUpdateStatus',
6386
+ message: { records: [] },
6387
+ });
6388
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6389
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6390
+ callback: () => void
6391
+ ) => {
6392
+ callback();
6393
+ return 0 as any;
6394
+ }) as typeof setTimeout);
6395
+
6396
+ (method as any).device = stubDevice({
6397
+ getCommands: () => ({ typedCall }),
6398
+ });
6399
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
6400
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6401
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
6402
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
6403
+ method.postProgressMessage = jest.fn();
6404
+
6405
+ try {
6406
+ await expect(
6407
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6408
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6409
+ true
6410
+ )
6411
+ ).resolves.toBeUndefined();
6412
+ } finally {
6413
+ setTimeoutSpy.mockRestore();
6414
+ }
6415
+
6416
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledWith({
6417
+ skipProtocolProbe: true,
6418
+ });
6419
+ expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'installingFirmware');
6420
+ });
6421
+
5824
6422
  test.each([
5825
6423
  [{ mode: 'normal', bootloaderMode: false }, true],
5826
6424
  [{ mode: 'notInitialized', bootloaderMode: false }, true],
@@ -6183,7 +6781,7 @@ describe('Protocol V2 firmware update targets', () => {
6183
6781
  expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
6184
6782
  expect(typedCall).toHaveBeenCalledWith(
6185
6783
  'DeviceFirmwareUpdateStatusGet',
6186
- ['DeviceFirmwareUpdateStatus', 'Success'],
6784
+ 'DeviceFirmwareUpdateStatus',
6187
6785
  expect.anything(),
6188
6786
  expect.anything()
6189
6787
  );
@@ -6465,10 +7063,12 @@ describe('Protocol V2 firmware update targets', () => {
6465
7063
  });
6466
7064
  expect(typedCall).toHaveBeenCalledWith(
6467
7065
  'DeviceFirmwareUpdateStatusGet',
6468
- ['DeviceFirmwareUpdateStatus', 'Success'],
7066
+ 'DeviceFirmwareUpdateStatus',
6469
7067
  {
6470
7068
  fields: {
6471
7069
  status: true,
7070
+ progress_percent: true,
7071
+ phase_info: true,
6472
7072
  payload_version: true,
6473
7073
  path: true,
6474
7074
  },
@@ -8183,6 +8783,36 @@ describe('Protocol V2 firmware update targets', () => {
8183
8783
  });
8184
8784
  });
8185
8785
 
8786
+ test('does not retry or wrap a stale BLE bond during a V4 file transfer', async () => {
8787
+ const method = new FirmwareUpdateV4({
8788
+ id: 1,
8789
+ payload: {
8790
+ method: 'firmwareUpdateV4',
8791
+ },
8792
+ });
8793
+ const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
8794
+ (method as any).fileWriteChunk = jest.fn().mockRejectedValue(staleBondError);
8795
+ const recoverProtocolV2FileTransfer = jest.fn();
8796
+ (method as any).recoverProtocolV2FileTransfer = recoverProtocolV2FileTransfer;
8797
+ const source = await openFirmwareByteSource({
8798
+ binary: new Uint8Array([1]).buffer,
8799
+ });
8800
+
8801
+ try {
8802
+ await expect(
8803
+ (method as any).protocolV2SourceUpdateProcess({
8804
+ source,
8805
+ filePath: 'vol1:firmware.bin',
8806
+ processedSize: 0,
8807
+ totalSize: 1,
8808
+ })
8809
+ ).rejects.toBe(staleBondError);
8810
+ } finally {
8811
+ await source?.close();
8812
+ }
8813
+ expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
8814
+ });
8815
+
8186
8816
  test('throttles repeated transfer progress while preserving file completion', async () => {
8187
8817
  const method = new FirmwareUpdateV4({
8188
8818
  id: 1,
@@ -8689,26 +9319,28 @@ describe('Protocol V2 firmware update targets', () => {
8689
9319
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
8690
9320
  });
8691
9321
 
8692
- test('keeps the confirmation UI active after Stage and the empty Request are written', async () => {
9322
+ test('keeps confirmation active after the Request write until terminal Success arrives', async () => {
8693
9323
  const method = new FirmwareUpdateV4({
8694
9324
  id: 1,
8695
9325
  payload: {
8696
9326
  method: 'firmwareUpdateV4',
8697
9327
  },
8698
9328
  });
8699
- let resolveWrite: ((value: unknown) => void) | undefined;
9329
+ let onResponseAfterWrite:
9330
+ | ((response: { type: string; message: Record<string, never> }) => void)
9331
+ | undefined;
8700
9332
  const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
8701
- const call = jest.fn().mockImplementation(
8702
- () =>
8703
- new Promise(resolve => {
8704
- resolveWrite = resolve;
8705
- })
8706
- );
9333
+ const call = jest.fn().mockImplementation((_type, _message, options) => {
9334
+ onResponseAfterWrite = options.onResponseAfterWrite;
9335
+ return Promise.resolve({ type: 'WriteCompleted', message: {} });
9336
+ });
9337
+ const clearCancelableAction = jest.fn();
8707
9338
 
8708
9339
  (method as any).device = stubDevice({
8709
- getCommands: () => ({ typedCall, call }),
9340
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
8710
9341
  createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
8711
9342
  toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
9343
+ clearCancelableAction,
8712
9344
  });
8713
9345
  method.postMessage = jest.fn();
8714
9346
  method.postProgressMessage = jest.fn();
@@ -8729,18 +9361,25 @@ describe('Protocol V2 firmware update targets', () => {
8729
9361
  expect(call).toHaveBeenCalledWith(
8730
9362
  'DeviceFirmwareUpdateRequest',
8731
9363
  {},
8732
- { returnAfterWrite: true }
9364
+ expect.objectContaining({
9365
+ returnAfterWrite: true,
9366
+ expectedTypes: ['Success'],
9367
+ onResponseAfterWrite: expect.any(Function),
9368
+ })
8733
9369
  );
8734
9370
  expect(method.postTipMessage).not.toHaveBeenCalled();
8735
9371
  expect(method.postProgressMessage).not.toHaveBeenCalled();
9372
+ expect(clearCancelableAction).not.toHaveBeenCalled();
8736
9373
 
8737
- resolveWrite?.({ type: 'WriteCompleted', message: {} });
8738
9374
  await startPromise;
9375
+ onResponseAfterWrite?.({ type: 'Success', message: {} });
9376
+ expect(clearCancelableAction).toHaveBeenCalledTimes(1);
9377
+ expect((method as any).protocolV2InstallTerminalSuccessObserved).toBe(true);
8739
9378
  expect(method.postTipMessage).not.toHaveBeenCalled();
8740
9379
  expect(method.postProgressMessage).not.toHaveBeenCalled();
8741
9380
  });
8742
9381
 
8743
- test('sends an empty Protocol V2 firmware install request without waiting for its response', async () => {
9382
+ test('returns after the empty Protocol V2 firmware install request is written', async () => {
8744
9383
  const method = new FirmwareUpdateV4({
8745
9384
  id: 1,
8746
9385
  payload: {
@@ -8751,10 +9390,13 @@ describe('Protocol V2 firmware update targets', () => {
8751
9390
  type: 'Success',
8752
9391
  message: { message: 'accepted' },
8753
9392
  });
8754
- const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
9393
+ const call = jest.fn().mockResolvedValue({
9394
+ type: 'WriteCompleted',
9395
+ message: {},
9396
+ });
8755
9397
 
8756
9398
  (method as any).device = stubDevice({
8757
- getCommands: () => ({ typedCall, call }),
9399
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
8758
9400
  createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
8759
9401
  toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
8760
9402
  });
@@ -8770,9 +9412,14 @@ describe('Protocol V2 firmware update targets', () => {
8770
9412
  expect(call).toHaveBeenCalledWith(
8771
9413
  'DeviceFirmwareUpdateRequest',
8772
9414
  {},
8773
- { returnAfterWrite: true }
9415
+ expect.objectContaining({
9416
+ returnAfterWrite: true,
9417
+ expectedTypes: ['Success'],
9418
+ onResponseAfterWrite: expect.any(Function),
9419
+ })
8774
9420
  );
8775
9421
  expect(method.postTipMessage).not.toHaveBeenCalled();
9422
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
8776
9423
  });
8777
9424
  });
8778
9425
 
@@ -8955,7 +9602,16 @@ describe('Protocol V2 firmware update method', () => {
8955
9602
  test('passes firmware update status fields through to Protocol V2', async () => {
8956
9603
  const typedCall = jest.fn().mockResolvedValue({
8957
9604
  message: {
8958
- records: [{ target_id: 4, status: 2, payload_version: 1, path: 'vol1:application_p1.bin' }],
9605
+ records: [
9606
+ {
9607
+ target_id: 4,
9608
+ status: 1,
9609
+ progress_percent: 42,
9610
+ phase_info: { phase: 1, progress_percent: 60 },
9611
+ payload_version: 1,
9612
+ path: 'vol1:application_p1.bin',
9613
+ },
9614
+ ],
8959
9615
  },
8960
9616
  });
8961
9617
  const method = new DeviceGetFirmwareUpdateStatus({
@@ -8964,6 +9620,8 @@ describe('Protocol V2 firmware update method', () => {
8964
9620
  method: 'deviceGetFirmwareUpdateStatus',
8965
9621
  fields: {
8966
9622
  status: true,
9623
+ progress_percent: true,
9624
+ phase_info: true,
8967
9625
  payload_version: true,
8968
9626
  path: true,
8969
9627
  },
@@ -8975,7 +9633,16 @@ describe('Protocol V2 firmware update method', () => {
8975
9633
  });
8976
9634
 
8977
9635
  await expect(method.run()).resolves.toEqual({
8978
- records: [{ target_id: 4, status: 2, payload_version: 1, path: 'vol1:application_p1.bin' }],
9636
+ records: [
9637
+ {
9638
+ target_id: 4,
9639
+ status: 1,
9640
+ progress_percent: 42,
9641
+ phase_info: { phase: 1, progress_percent: 60 },
9642
+ payload_version: 1,
9643
+ path: 'vol1:application_p1.bin',
9644
+ },
9645
+ ],
8979
9646
  });
8980
9647
  expect(typedCall).toHaveBeenCalledWith(
8981
9648
  'DeviceFirmwareUpdateStatusGet',
@@ -8983,6 +9650,8 @@ describe('Protocol V2 firmware update method', () => {
8983
9650
  {
8984
9651
  fields: {
8985
9652
  status: true,
9653
+ progress_percent: true,
9654
+ phase_info: true,
8986
9655
  payload_version: true,
8987
9656
  path: true,
8988
9657
  },