@onekeyfe/hd-core 1.2.0-alpha.50 → 1.2.0-alpha.52

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 (238) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +0 -78
  2. package/__tests__/DeviceEventRegistration.test.ts +0 -6
  3. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +3 -111
  4. package/__tests__/connectSettings.test.ts +47 -5
  5. package/__tests__/device-initialize-timeout.test.ts +56 -0
  6. package/__tests__/device-lifecycle-events.test.ts +3 -105
  7. package/__tests__/firmware-update/check-all-firmware-release.test.ts +175 -0
  8. package/__tests__/firmware-update/firmware-artifact-source.test.ts +119 -0
  9. package/__tests__/firmware-update/firmware-host-binding.test.ts +70 -0
  10. package/__tests__/firmware-update/firmware-preparation-error.test.ts +27 -0
  11. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +200 -0
  12. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +13 -0
  13. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +294 -0
  14. package/__tests__/firmware-update/firmware-update-plan.test.ts +593 -0
  15. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +213 -0
  16. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +59 -31
  17. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +58 -0
  18. package/__tests__/firmware-update/firmware-upload-source.test.ts +70 -0
  19. package/__tests__/get-device-state.test.ts +12 -132
  20. package/__tests__/homescreen.test.ts +1 -41
  21. package/__tests__/logBlockEvent.test.ts +1 -1
  22. package/__tests__/method-protocol-support.test.ts +0 -19
  23. package/__tests__/open-wallet-session.test.ts +33 -446
  24. package/__tests__/protocol-v2-unlock-policy.test.ts +35 -196
  25. package/__tests__/protocol-v2.test.ts +1032 -1056
  26. package/__tests__/protocolV2UiInteraction.test.ts +0 -63
  27. package/__tests__/public-pro2-api-boundary.test.ts +0 -1
  28. package/__tests__/search-devices.test.ts +3 -12
  29. package/dist/api/BaseMethod.d.ts +1 -3
  30. package/dist/api/BaseMethod.d.ts.map +1 -1
  31. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  32. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  33. package/dist/api/FirmwareUpdateV2.d.ts +11 -0
  34. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  35. package/dist/api/FirmwareUpdateV3.d.ts +7 -3
  36. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  37. package/dist/api/FirmwareUpdateV4.d.ts +25 -9
  38. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  39. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  40. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  41. package/dist/api/SearchDevices.d.ts.map +1 -1
  42. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  43. package/dist/api/device/DeviceUpdateBootloader.d.ts +5 -1
  44. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  45. package/dist/api/firmware/FirmwareArtifactSource.d.ts +25 -0
  46. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -0
  47. package/dist/api/firmware/FirmwareHostBinding.d.ts +6 -0
  48. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -0
  49. package/dist/api/firmware/FirmwarePreparationError.d.ts +3 -0
  50. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +1 -0
  51. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +9 -0
  52. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  53. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +3 -0
  54. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +1 -0
  55. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +25 -0
  56. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -0
  57. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +32 -0
  58. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -0
  59. package/dist/api/firmware/getBinary.d.ts +7 -0
  60. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  61. package/dist/api/firmware/progressThrottle.d.ts +3 -0
  62. package/dist/api/firmware/progressThrottle.d.ts.map +1 -0
  63. package/dist/api/firmware/updateBootloader.d.ts +2 -0
  64. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  65. package/dist/api/firmware/uploadFirmware.d.ts +9 -1
  66. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  67. package/dist/api/helpers/protocolV2FileWrite.d.ts +0 -2
  68. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  69. package/dist/api/index.d.ts +0 -2
  70. package/dist/api/index.d.ts.map +1 -1
  71. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  72. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  73. package/dist/core/deviceEventRegistration.d.ts +0 -2
  74. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  75. package/dist/core/index.d.ts.map +1 -1
  76. package/dist/data-manager/DataManager.d.ts +2 -4
  77. package/dist/data-manager/DataManager.d.ts.map +1 -1
  78. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  79. package/dist/device/Device.d.ts +6 -29
  80. package/dist/device/Device.d.ts.map +1 -1
  81. package/dist/device/DeviceCommands.d.ts +4 -4
  82. package/dist/device/DevicePool.d.ts.map +1 -1
  83. package/dist/events/device.d.ts +0 -4
  84. package/dist/events/device.d.ts.map +1 -1
  85. package/dist/events/logBlockEvent.d.ts.map +1 -1
  86. package/dist/events/ui-promise.d.ts +2 -4
  87. package/dist/events/ui-promise.d.ts.map +1 -1
  88. package/dist/events/ui-request.d.ts +2 -31
  89. package/dist/events/ui-request.d.ts.map +1 -1
  90. package/dist/events/ui-response.d.ts +2 -10
  91. package/dist/events/ui-response.d.ts.map +1 -1
  92. package/dist/index.d.ts +230 -203
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +3469 -2419
  95. package/dist/inject.d.ts +1 -6
  96. package/dist/inject.d.ts.map +1 -1
  97. package/dist/lowLevelInject.d.ts.map +1 -1
  98. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  99. package/dist/protocols/protocol-v2/lockedError.d.ts +2 -0
  100. package/dist/protocols/protocol-v2/lockedError.d.ts.map +1 -0
  101. package/dist/protocols/protocol-v2/uiInteraction.d.ts +4 -7
  102. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  103. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +3 -1
  104. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  105. package/dist/protocols/protocol-v2/unlockRetry.d.ts +10 -0
  106. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -0
  107. package/dist/protocols/protocol-v2/walletSession.d.ts +1 -4
  108. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  109. package/dist/topLevelInject.d.ts.map +1 -1
  110. package/dist/types/api/checkAllFirmwareRelease.d.ts +4 -1
  111. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  112. package/dist/types/api/deviceUpdateBootloader.d.ts +6 -0
  113. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  114. package/dist/types/api/export.d.ts +4 -1
  115. package/dist/types/api/export.d.ts.map +1 -1
  116. package/dist/types/api/firmwareUpdate.d.ts +70 -2
  117. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  118. package/dist/types/api/firmwareUpdateCapabilities.d.ts +9 -0
  119. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +1 -0
  120. package/dist/types/api/firmwareUpdatePlan.d.ts +28 -0
  121. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -0
  122. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +42 -0
  123. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +1 -0
  124. package/dist/types/api/index.d.ts +10 -7
  125. package/dist/types/api/index.d.ts.map +1 -1
  126. package/dist/types/api/protocolV2.d.ts +0 -3
  127. package/dist/types/api/protocolV2.d.ts.map +1 -1
  128. package/dist/types/params.d.ts +0 -1
  129. package/dist/types/params.d.ts.map +1 -1
  130. package/dist/types/settings.d.ts +13 -22
  131. package/dist/types/settings.d.ts.map +1 -1
  132. package/dist/utils/deviceFeaturesCompat.d.ts +1 -0
  133. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  134. package/dist/utils/deviceFeaturesUtils.d.ts +0 -2
  135. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  136. package/dist/utils/homescreen.d.ts +0 -4
  137. package/dist/utils/homescreen.d.ts.map +1 -1
  138. package/dist/utils/index.d.ts +1 -1
  139. package/dist/utils/index.d.ts.map +1 -1
  140. package/dist/utils/patch.d.ts +1 -1
  141. package/dist/utils/patch.d.ts.map +1 -1
  142. package/dist/utils/pro2Wallpaper.d.ts +0 -10
  143. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  144. package/package.json +4 -4
  145. package/src/api/BaseMethod.ts +10 -8
  146. package/src/api/CheckAllFirmwareRelease.ts +53 -41
  147. package/src/api/FirmwareUpdate.ts +15 -7
  148. package/src/api/FirmwareUpdateV2.ts +316 -126
  149. package/src/api/FirmwareUpdateV3.ts +265 -63
  150. package/src/api/FirmwareUpdateV4.ts +937 -430
  151. package/src/api/GetPassphraseState.ts +1 -7
  152. package/src/api/OpenWalletSession.ts +17 -54
  153. package/src/api/SearchDevices.ts +1 -3
  154. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +11 -32
  155. package/src/api/device/DeviceUnlock.ts +1 -1
  156. package/src/api/device/DeviceUpdateBootloader.ts +122 -6
  157. package/src/api/firmware/FirmwareArtifactSource.ts +278 -0
  158. package/src/api/firmware/FirmwareHostBinding.ts +100 -0
  159. package/src/api/firmware/FirmwarePreparationError.ts +12 -0
  160. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +83 -16
  161. package/src/api/firmware/FirmwareUpdateCapabilities.ts +9 -0
  162. package/src/api/firmware/FirmwareUpdatePlan.ts +772 -0
  163. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +415 -0
  164. package/src/api/firmware/progressThrottle.ts +44 -0
  165. package/src/api/firmware/updateBootloader.ts +19 -4
  166. package/src/api/firmware/uploadFirmware.ts +144 -39
  167. package/src/api/helpers/protocolV2FileWrite.ts +4 -11
  168. package/src/api/index.ts +0 -2
  169. package/src/api/protocol-v2/ProtocolInfoRequest.ts +1 -3
  170. package/src/core/deviceEventRegistration.ts +0 -4
  171. package/src/core/index.ts +83 -167
  172. package/src/data/messages/messages-protocol-v2.json +0 -25
  173. package/src/data-manager/DataManager.ts +38 -67
  174. package/src/data-manager/connectSettings.ts +11 -0
  175. package/src/device/Device.ts +42 -257
  176. package/src/device/DevicePool.ts +2 -7
  177. package/src/events/device.ts +0 -4
  178. package/src/events/logBlockEvent.ts +0 -1
  179. package/src/events/ui-promise.ts +2 -4
  180. package/src/events/ui-request.ts +2 -36
  181. package/src/events/ui-response.ts +2 -12
  182. package/src/index.ts +5 -0
  183. package/src/inject.ts +24 -60
  184. package/src/lowLevelInject.ts +8 -7
  185. package/src/protocols/protocol-v2/features.ts +2 -9
  186. package/src/protocols/protocol-v2/lockedError.ts +10 -0
  187. package/src/protocols/protocol-v2/uiInteraction.ts +6 -46
  188. package/src/protocols/protocol-v2/unlockPolicy.ts +105 -1
  189. package/src/protocols/protocol-v2/unlockRetry.ts +109 -0
  190. package/src/protocols/protocol-v2/walletSession.ts +30 -169
  191. package/src/topLevelInject.ts +8 -7
  192. package/src/types/api/checkAllFirmwareRelease.ts +4 -1
  193. package/src/types/api/deviceUpdateBootloader.ts +6 -0
  194. package/src/types/api/export.ts +18 -0
  195. package/src/types/api/firmwareUpdate.ts +76 -3
  196. package/src/types/api/firmwareUpdateCapabilities.ts +9 -0
  197. package/src/types/api/firmwareUpdatePlan.ts +38 -0
  198. package/src/types/api/firmwareUpdatePreparedPlan.ts +53 -0
  199. package/src/types/api/index.ts +14 -13
  200. package/src/types/api/protocolV2.ts +0 -13
  201. package/src/types/params.ts +1 -7
  202. package/src/types/settings.ts +13 -42
  203. package/src/utils/deviceFeaturesCompat.ts +6 -0
  204. package/src/utils/deviceFeaturesUtils.ts +0 -4
  205. package/src/utils/homescreen.ts +6 -32
  206. package/src/utils/index.ts +1 -6
  207. package/src/utils/pro2Wallpaper.ts +8 -35
  208. package/__tests__/deviceUploadNft.test.ts +0 -293
  209. package/__tests__/pro2Nft.test.ts +0 -108
  210. package/__tests__/protocol-binding.test.ts +0 -89
  211. package/__tests__/protocol-v2-resources.test.ts +0 -284
  212. package/__tests__/protocol-v2-ui-lifecycle.test.ts +0 -91
  213. package/__tests__/uiProgressThrottle.test.ts +0 -74
  214. package/__tests__/uiPromiseRegistry.test.ts +0 -86
  215. package/dist/api/DetectDeviceConnectProtocol.d.ts +0 -7
  216. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +0 -1
  217. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +0 -31
  218. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +0 -1
  219. package/dist/core/uiPromiseRegistry.d.ts +0 -4
  220. package/dist/core/uiPromiseRegistry.d.ts.map +0 -1
  221. package/dist/protocols/protocol-v2/resources.d.ts +0 -30
  222. package/dist/protocols/protocol-v2/resources.d.ts.map +0 -1
  223. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +0 -22
  224. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +0 -1
  225. package/dist/types/api/detectDeviceConnectProtocol.d.ts +0 -4
  226. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +0 -1
  227. package/dist/utils/pro2Nft.d.ts +0 -31
  228. package/dist/utils/pro2Nft.d.ts.map +0 -1
  229. package/dist/utils/uiProgressThrottle.d.ts +0 -3
  230. package/dist/utils/uiProgressThrottle.d.ts.map +0 -1
  231. package/src/api/DetectDeviceConnectProtocol.ts +0 -18
  232. package/src/api/protocol-v2/DeviceUploadNft.ts +0 -189
  233. package/src/core/uiPromiseRegistry.ts +0 -41
  234. package/src/protocols/protocol-v2/resources.ts +0 -359
  235. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +0 -124
  236. package/src/types/api/detectDeviceConnectProtocol.ts +0 -7
  237. package/src/utils/pro2Nft.ts +0 -142
  238. package/src/utils/uiProgressThrottle.ts +0 -63
@@ -5,7 +5,6 @@ import {
5
5
  DeviceSettingsPage,
6
6
  DeviceType,
7
7
  } from '@onekeyfe/hd-transport';
8
- import { sha256 } from '@noble/hashes/sha256';
9
8
 
10
9
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
11
10
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -62,7 +61,6 @@ import { createCoreApi } from '../src/inject';
62
61
  import { Device, preloadSessionCache } from '../src/device/Device';
63
62
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
64
63
  import { DevicePool } from '../src/device/DevicePool';
65
- import { DEVICE } from '../src/events';
66
64
  import { UI_REQUEST } from '../src/events/ui-request';
67
65
  import {
68
66
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -76,15 +74,11 @@ import {
76
74
  requestProtocolV2ProtocolInfo,
77
75
  supportsProtocolV2Message,
78
76
  } from '../src/protocols/protocol-v2/features';
79
- import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
80
77
  import {
81
78
  getProtocolV2WalletSession,
82
79
  refreshProtocolV2DeviceStatus,
83
80
  } from '../src/protocols/protocol-v2/walletSession';
84
- import {
85
- createProtocolV2UnlockContext,
86
- runMethodWithUnlockPolicy,
87
- } from '../src/protocols/protocol-v2/unlockPolicyRunner';
81
+ import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
88
82
  import { BaseMethod } from '../src/api/BaseMethod';
89
83
  import {
90
84
  buildProtocolV1FeaturesPayload,
@@ -98,6 +92,7 @@ import {
98
92
  getMethodVersionRange,
99
93
  } from '../src/utils';
100
94
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
+ import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
101
96
  import {
102
97
  getPassphraseState,
103
98
  getPassphraseStateWithRefreshDeviceInfo,
@@ -246,7 +241,7 @@ describe('UploadPortfolio', () => {
246
241
  (method as any).device = device;
247
242
 
248
243
  method.init();
249
- await runMethodWithUnlockPolicy(method, device as any);
244
+ await runMethodWithUnlockRetry(method, device as any);
250
245
 
251
246
  expect(unlockDevice).not.toHaveBeenCalled();
252
247
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -422,16 +417,14 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
422
417
  }
423
418
  return d.features;
424
419
  };
425
- d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
426
- requestProtocolV2ProtocolInfo({
427
- commands: d.getCommands?.() ?? d.commands,
428
- timeoutMs,
429
- });
430
420
  d.probeProtocolV2RuntimeState ??= async (
431
421
  deviceInfo: ProtocolV2DeviceInfo,
432
422
  timeoutMs?: number
433
423
  ) => {
434
- const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
424
+ const protocolInfo = await requestProtocolV2ProtocolInfo({
425
+ commands: d.getCommands(),
426
+ timeoutMs,
427
+ });
435
428
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
436
429
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
437
430
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -788,9 +781,6 @@ describe('Protocol V2 feature adapter', () => {
788
781
  if (request === 'DeviceStatusGet') {
789
782
  return { type: 'DeviceStatus', message: { unlocked: true } };
790
783
  }
791
- if (request === 'DeviceSessionAskPassphrase') {
792
- return { type: 'Success', message: {} };
793
- }
794
784
  if (request === 'DeviceSessionGet') {
795
785
  return {
796
786
  type: 'DeviceSession',
@@ -815,7 +805,7 @@ describe('Protocol V2 feature adapter', () => {
815
805
  ).resolves.toEqual({
816
806
  passphraseState: 'state-1',
817
807
  newSession: 'session-1',
818
- unlockedAttachPin: false,
808
+ unlockedAttachPin: undefined,
819
809
  resumed: false,
820
810
  });
821
811
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -824,13 +814,7 @@ describe('Protocol V2 feature adapter', () => {
824
814
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
825
815
  type: DeviceSessionPinType.Main,
826
816
  });
827
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
828
- passphrase: '',
829
- on_device: false,
830
- });
831
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
832
- seed_domains: [],
833
- });
817
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
834
818
  device.passphraseState = 'state-1';
835
819
  expect(device.getInternalState()).toBe('session-1');
836
820
  });
@@ -863,7 +847,6 @@ describe('Protocol V2 feature adapter', () => {
863
847
  },
864
848
  };
865
849
  }
866
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
867
850
  if (request === 'DeviceSessionGet') {
868
851
  return {
869
852
  message: {
@@ -880,16 +863,9 @@ describe('Protocol V2 feature adapter', () => {
880
863
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
881
864
 
882
865
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
883
- expect(
884
- typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
885
- ).toHaveLength(1);
886
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
887
- seed_domains: [],
888
- });
866
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
889
867
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
890
868
  session_id: 'standard-session',
891
- btc_test_address: 'standard-state',
892
- seed_domains: [],
893
869
  });
894
870
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
895
871
  passphraseState: 'standard-state',
@@ -931,7 +907,6 @@ describe('Protocol V2 feature adapter', () => {
931
907
  },
932
908
  };
933
909
  }
934
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
935
910
  if (request === 'DeviceSessionAskPin') {
936
911
  return { type: 'Success', message: { message: 'PIN verified' } };
937
912
  }
@@ -1026,7 +1001,6 @@ describe('Protocol V2 feature adapter', () => {
1026
1001
  if (request === 'DeviceStatusGet') {
1027
1002
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1028
1003
  }
1029
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1030
1004
  if (request === 'DeviceSessionGet') {
1031
1005
  sessionGetCalls += 1;
1032
1006
  return {
@@ -1253,9 +1227,7 @@ describe('Protocol V2 feature adapter', () => {
1253
1227
  passphrase: 'host hidden wallet',
1254
1228
  on_device: false,
1255
1229
  });
1256
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1257
- seed_domains: [],
1258
- });
1230
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1259
1231
  });
1260
1232
 
1261
1233
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1363,8 +1335,6 @@ describe('Protocol V2 feature adapter', () => {
1363
1335
 
1364
1336
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1365
1337
  session_id: 'session-a',
1366
- btc_test_address: 'state-a',
1367
- seed_domains: [],
1368
1338
  });
1369
1339
  expect(device.getInternalState()).toBe('session-b');
1370
1340
  });
@@ -1432,15 +1402,7 @@ describe('Protocol V2 feature adapter', () => {
1432
1402
  });
1433
1403
  expect(typedCall.mock.calls).toEqual([
1434
1404
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1435
- [
1436
- 'DeviceSessionGet',
1437
- 'DeviceSession',
1438
- {
1439
- session_id: 'session-a',
1440
- btc_test_address: 'state-a',
1441
- seed_domains: [],
1442
- },
1443
- ],
1405
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1444
1406
  ]);
1445
1407
  expect(promptPassphrase).not.toHaveBeenCalled();
1446
1408
  expect(device.getInternalState()).toBeUndefined();
@@ -1488,7 +1450,7 @@ describe('Protocol V2 feature adapter', () => {
1488
1450
  { passphrase: 'host hidden wallet', on_device: false },
1489
1451
  ],
1490
1452
  ['DeviceStatusGet', 'DeviceStatus', {}],
1491
- ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1453
+ ['DeviceSessionGet', 'DeviceSession', {}],
1492
1454
  ]);
1493
1455
  });
1494
1456
 
@@ -1591,73 +1553,6 @@ describe('Protocol V2 feature adapter', () => {
1591
1553
  expect(device.getInternalState()).toBeUndefined();
1592
1554
  });
1593
1555
 
1594
- test.each([false, true])(
1595
- 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1596
- async lockFails => {
1597
- const deviceId = 'attach-mismatch-device';
1598
- const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1599
- (device as any).features = normalizeProtocolV2Features(
1600
- { ...descriptor, protocolType: 'V2' } as any,
1601
- {
1602
- status: {
1603
- device_id: deviceId,
1604
- unlocked: true,
1605
- passphrase_enabled: true,
1606
- attach_to_pin_enabled: true,
1607
- unlocked_by_attach_to_pin: false,
1608
- },
1609
- }
1610
- );
1611
- device.passphraseState = 'expected-state';
1612
-
1613
- const typedCall = jest.fn((request: string) => {
1614
- if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1615
- if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1616
- if (request === 'DeviceStatusGet') {
1617
- return {
1618
- message: {
1619
- device_id: deviceId,
1620
- unlocked: true,
1621
- passphrase_enabled: true,
1622
- attach_to_pin_enabled: true,
1623
- unlocked_by_attach_to_pin: true,
1624
- },
1625
- };
1626
- }
1627
- if (request === 'DeviceSessionGet') {
1628
- return {
1629
- message: {
1630
- btc_test_address: 'wrong-attach-state',
1631
- session_id: 'wrong-attach-session',
1632
- },
1633
- };
1634
- }
1635
- if (request === 'LockDevice') {
1636
- if (lockFails) throw new Error('Lock unsupported');
1637
- return { message: { message: 'Device locked' } };
1638
- }
1639
- throw new Error(`Unexpected request: ${request}`);
1640
- });
1641
- const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1642
- (device as any).commands = { typedCall, promptPassphrase };
1643
-
1644
- await expect(
1645
- getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1646
- ).rejects.toMatchObject({
1647
- errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1648
- });
1649
-
1650
- expect(promptPassphrase).not.toHaveBeenCalled();
1651
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1652
- btc_test_address: 'expected-state',
1653
- seed_domains: [],
1654
- });
1655
- expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1656
- expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1657
- expect(device.getInternalState()).toBeUndefined();
1658
- }
1659
- );
1660
-
1661
1556
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1662
1557
  const device = Device.fromDescriptor({
1663
1558
  id: 'cache-device-attach-recovery',
@@ -1714,17 +1609,7 @@ describe('Protocol V2 feature adapter', () => {
1714
1609
  }),
1715
1610
  { cancelDeviceOnReject: false }
1716
1611
  );
1717
- expect(unlockDevice).toHaveBeenCalledWith(
1718
- DeviceSessionPinType.AttachToPin,
1719
- expect.objectContaining({
1720
- emitUiEvent: false,
1721
- interaction: expect.objectContaining({
1722
- phase: 'pin',
1723
- transition: 'start',
1724
- protocol: 'V2',
1725
- }),
1726
- })
1727
- );
1612
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1728
1613
  expect(device.getInternalState()).toBe('new-attach-session');
1729
1614
  });
1730
1615
 
@@ -2008,10 +1893,12 @@ describe('Protocol V2 feature adapter', () => {
2008
1893
  expect(getFeatures).not.toHaveBeenCalled();
2009
1894
  });
2010
1895
 
2011
- test('uses the cached wallet address when the local session is missing', async () => {
1896
+ test('marks an interactive cache miss as a wallet-session recovery', async () => {
2012
1897
  const typedCall = jest
2013
1898
  .fn()
2014
1899
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
2015
1902
  .mockResolvedValueOnce({
2016
1903
  message: {
2017
1904
  btc_test_address: 'expected-state',
@@ -2039,11 +1926,16 @@ describe('Protocol V2 feature adapter', () => {
2039
1926
  await expect(
2040
1927
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
2041
1928
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2042
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2043
- btc_test_address: 'expected-state',
2044
- seed_domains: [],
2045
- });
2046
- expect(promptPassphrase).not.toHaveBeenCalled();
1929
+ expect(promptPassphrase).toHaveBeenCalledWith(
1930
+ {
1931
+ existsAttachPinUser: false,
1932
+ deviceOnly: false,
1933
+ source: 'wallet-session-coordinator',
1934
+ reason: 'session-recovery',
1935
+ expectedPassphraseState: 'expected-state',
1936
+ },
1937
+ { cancelDeviceOnReject: false }
1938
+ );
2047
1939
  });
2048
1940
 
2049
1941
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2109,15 +2001,8 @@ describe('Protocol V2 feature adapter', () => {
2109
2001
  true
2110
2002
  );
2111
2003
  expect(typedCall.mock.calls).toEqual([
2112
- [
2113
- 'DeviceSessionGet',
2114
- 'DeviceSession',
2115
- {
2116
- session_id: 'session-pro2-app',
2117
- btc_test_address: 'state-pro2-app',
2118
- seed_domains: [],
2119
- },
2120
- ],
2004
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2121
2006
  ]);
2122
2007
  });
2123
2008
 
@@ -2306,9 +2191,7 @@ describe('Protocol V2 feature adapter', () => {
2306
2191
  passphrase: 'host hidden wallet',
2307
2192
  on_device: false,
2308
2193
  });
2309
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2310
- seed_domains: [],
2311
- });
2194
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2312
2195
  });
2313
2196
 
2314
2197
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2386,18 +2269,12 @@ describe('Protocol V2 feature adapter', () => {
2386
2269
  );
2387
2270
 
2388
2271
  expect(device.getInternalState()).toBeUndefined();
2389
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2390
- btc_test_address: 'expected-state',
2391
- seed_domains: [],
2392
- });
2393
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2272
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2394
2273
  passphrase: 'host hidden wallet',
2395
2274
  on_device: false,
2396
2275
  });
2397
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2398
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2399
- seed_domains: [],
2400
- });
2276
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
+ expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2401
2278
  });
2402
2279
 
2403
2280
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2434,15 +2311,6 @@ describe('Protocol V2 feature adapter', () => {
2434
2311
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2435
2312
  eventless_wallet_session: true,
2436
2313
  });
2437
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2438
- seed_domains: [],
2439
- });
2440
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2441
- passphrase: '',
2442
- on_device: false,
2443
- });
2444
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2445
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2446
2314
  });
2447
2315
 
2448
2316
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2813,9 +2681,7 @@ describe('Protocol V2 feature adapter', () => {
2813
2681
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2814
2682
  }
2815
2683
  );
2816
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2817
- eventless_wallet_session: true,
2818
- });
2684
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2819
2685
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2820
2686
  });
2821
2687
 
@@ -2853,9 +2719,7 @@ describe('Protocol V2 feature adapter', () => {
2853
2719
  }),
2854
2720
  expect.anything()
2855
2721
  );
2856
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2857
- eventless_wallet_session: true,
2858
- });
2722
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2859
2723
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2860
2724
  expect(device.features).toMatchObject({
2861
2725
  deviceId: null,
@@ -3121,6 +2985,10 @@ describe('Protocol V2 feature adapter', () => {
3121
2985
  fw: { application: { version: '1.2.4' } },
3122
2986
  },
3123
2987
  })
2988
+ .mockResolvedValueOnce({
2989
+ type: 'ProtocolInfo',
2990
+ message: protocolV2ApplicationInfo,
2991
+ })
3124
2992
  .mockResolvedValueOnce({
3125
2993
  type: 'DeviceStatus',
3126
2994
  message: { init_states: true, unlocked: true },
@@ -3142,7 +3010,7 @@ describe('Protocol V2 feature adapter', () => {
3142
3010
  firmwareVersion: '1.2.4',
3143
3011
  passphraseProtection: null,
3144
3012
  });
3145
- expect(typedCall).toHaveBeenCalledTimes(5);
3013
+ expect(typedCall).toHaveBeenCalledTimes(6);
3146
3014
  expect(typedCall).toHaveBeenNthCalledWith(
3147
3015
  4,
3148
3016
  'DeviceInfoGet',
@@ -3256,23 +3124,9 @@ describe('Protocol V2 feature adapter', () => {
3256
3124
  status: { init_states: true },
3257
3125
  }
3258
3126
  );
3259
- const pinOnDevice = jest.fn();
3260
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3261
3127
 
3262
3128
  const features = await device.unlockDevice();
3263
3129
 
3264
- expect(pinOnDevice).toHaveBeenCalledWith(
3265
- device,
3266
- DeviceSessionPinType.Main,
3267
- expect.objectContaining({
3268
- source: 'unlock-coordinator',
3269
- reason: 'device-unlock',
3270
- deviceOnly: true,
3271
- })
3272
- );
3273
- expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3274
- typedCall.mock.invocationCallOrder[0]
3275
- );
3276
3130
  expect(typedCall.mock.calls).toEqual([
3277
3131
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3278
3132
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3318,12 +3172,9 @@ describe('Protocol V2 feature adapter', () => {
3318
3172
  throw new Error(`Unexpected request: ${requestType}`);
3319
3173
  });
3320
3174
  (device as any).commands = { typedCall };
3321
- const pinOnDevice = jest.fn();
3322
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3323
3175
 
3324
- await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3176
+ await device.unlockDevice();
3325
3177
 
3326
- expect(pinOnDevice).not.toHaveBeenCalled();
3327
3178
  expect(typedCall.mock.calls).toEqual([
3328
3179
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3329
3180
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3726,15 +3577,6 @@ describe('API compatibility handling', () => {
3726
3577
 
3727
3578
  describe('Protocol V2 firmware update targets', () => {
3728
3579
  const OKPP_HEADER_SIZE = 0x52a0;
3729
- let forceReloadDataSpy: jest.SpyInstance<Promise<void>, []>;
3730
-
3731
- beforeEach(() => {
3732
- forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3733
- });
3734
-
3735
- afterEach(() => {
3736
- jest.restoreAllMocks();
3737
- });
3738
3580
 
3739
3581
  const buildOkppHeader = ({
3740
3582
  type = 'RESC',
@@ -3875,7 +3717,7 @@ describe('Protocol V2 firmware update targets', () => {
3875
3717
  });
3876
3718
  });
3877
3719
 
3878
- test('enters Protocol V2 bootloader before upload', async () => {
3720
+ test('delegates Protocol V2 mode transition to the phase executor', async () => {
3879
3721
  const method = new FirmwareUpdateV4({
3880
3722
  id: 1,
3881
3723
  payload: {
@@ -3910,7 +3752,7 @@ describe('Protocol V2 firmware update targets', () => {
3910
3752
 
3911
3753
  await method.run();
3912
3754
 
3913
- expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3755
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3914
3756
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3915
3757
  fwBinaryMap: [
3916
3758
  {
@@ -3923,68 +3765,6 @@ describe('Protocol V2 firmware update targets', () => {
3923
3765
  });
3924
3766
  });
3925
3767
 
3926
- test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
3927
- const method = new FirmwareUpdateV4({
3928
- id: 1,
3929
- payload: {
3930
- method: 'firmwareUpdateV4',
3931
- platform: 'web',
3932
- targetsToUpdate: ['resource'],
3933
- },
3934
- });
3935
- method.init();
3936
-
3937
- (method as any).device = stubDevice({
3938
- originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
3939
- features: {
3940
- deviceType: 'pro2',
3941
- firmwareVersion: '1.0.0',
3942
- mode: 'normal',
3943
- bootloaderMode: false,
3944
- capabilities: [],
3945
- },
3946
- isBootloader: () => false,
3947
- isRomloader: () => false,
3948
- });
3949
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3950
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3951
- bootloaderBinary: null,
3952
- fwBinaryMap: [],
3953
- installItems: [],
3954
- });
3955
-
3956
- const order: string[] = [];
3957
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
3958
- order.push('enter-bootloader');
3959
- return Promise.resolve(true);
3960
- });
3961
- (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
3962
- order.push('prepare-resources');
3963
- return Promise.resolve([
3964
- {
3965
- name: 'images.okpkg',
3966
- binary: new Uint8Array([1]).buffer,
3967
- devicePath: 'vol0:/bundles/images/images.okpkg',
3968
- },
3969
- ]);
3970
- });
3971
- (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
3972
- order.push('execute-update');
3973
- return Promise.resolve();
3974
- });
3975
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3976
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3977
- bootloaderVersion: '1.0.0',
3978
- bleVersion: '1.0.0',
3979
- firmwareVersion: '1.0.0',
3980
- });
3981
- method.postTipMessage = jest.fn();
3982
-
3983
- await method.run();
3984
-
3985
- expect(order).toEqual(['enter-bootloader', 'prepare-resources', 'execute-update']);
3986
- });
3987
-
3988
3768
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
3989
3769
  const method = new FirmwareUpdateV4({
3990
3770
  id: 1,
@@ -4034,33 +3814,6 @@ describe('Protocol V2 firmware update targets', () => {
4034
3814
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4035
3815
  });
4036
3816
 
4037
- test('keeps Protocol V2 bootloader active before firmware transfer', async () => {
4038
- const method = new FirmwareUpdateV4({
4039
- id: 1,
4040
- payload: {
4041
- method: 'firmwareUpdateV4',
4042
- },
4043
- });
4044
- (method as any).device = stubDevice({
4045
- originalDescriptor: { id: 'usb-id', path: 'bootloader-path', protocolType: 'V2' },
4046
- features: {
4047
- deviceType: 'pro2',
4048
- mode: 'bootloader',
4049
- bootloaderMode: true,
4050
- capabilities: [],
4051
- },
4052
- isBootloader: () => true,
4053
- isRomloader: () => false,
4054
- });
4055
- (method as any).protocolV2Reboot = jest.fn();
4056
- method.postTipMessage = jest.fn();
4057
-
4058
- await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4059
-
4060
- expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4061
- expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
4062
- });
4063
-
4064
3817
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
4065
3818
  const method = new FirmwareUpdateV4({
4066
3819
  id: 1,
@@ -4113,9 +3866,6 @@ describe('Protocol V2 firmware update targets', () => {
4113
3866
  },
4114
3867
  });
4115
3868
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4116
- const prepareResourceBundles = jest
4117
- .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4118
- .mockResolvedValue(undefined);
4119
3869
  (method as any).protocolV2Reboot = jest.fn();
4120
3870
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
4121
3871
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -4128,7 +3878,6 @@ describe('Protocol V2 firmware update targets', () => {
4128
3878
 
4129
3879
  await method.run();
4130
3880
 
4131
- expect(prepareResourceBundles).not.toHaveBeenCalled();
4132
3881
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4133
3882
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4134
3883
  bootloaderBinary: null,
@@ -4211,10 +3960,7 @@ describe('Protocol V2 firmware update targets', () => {
4211
3960
  getCommands: () => commands,
4212
3961
  mainId: 'usb-path',
4213
3962
  });
4214
- const cachedDevice = {
4215
- getConnectId: () => 'usb-path',
4216
- getCurrentSerialNo: () => '',
4217
- };
3963
+ const cachedDevice = { getConnectId: () => 'usb-path' };
4218
3964
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4219
3965
  devices: {},
4220
3966
  deviceList: [cachedDevice],
@@ -4231,259 +3977,83 @@ describe('Protocol V2 firmware update targets', () => {
4231
3977
  expect(initialize).not.toHaveBeenCalled();
4232
3978
  });
4233
3979
 
4234
- test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
3980
+ test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4235
3981
  const method = new FirmwareUpdateV4({
4236
3982
  id: 1,
4237
3983
  payload: {
4238
3984
  method: 'firmwareUpdateV4',
4239
3985
  },
4240
3986
  });
4241
- const commands = { disposed: true, mainId: '' };
4242
- const updateFromCache = jest.fn();
3987
+ const commands = { disposed: false, mainId: 'usb-session' };
3988
+ const acquire = jest.fn().mockResolvedValue(undefined);
4243
3989
  const device = stubDevice({
4244
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
3990
+ originalDescriptor: {
3991
+ id: 'usb-id',
3992
+ path: 'usb-path',
3993
+ session: 'usb-session',
3994
+ protocolType: 'V2',
3995
+ },
4245
3996
  deviceConnector: {
4246
3997
  enumerate: jest.fn().mockResolvedValue({
4247
3998
  descriptors: [
4248
- { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4249
- { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
3999
+ {
4000
+ id: 'usb-id',
4001
+ path: 'usb-path',
4002
+ session: 'usb-session',
4003
+ protocolType: 'V2',
4004
+ },
4250
4005
  ],
4251
4006
  }),
4252
4007
  },
4253
- updateFromCache,
4254
- hasDeviceAcquire: jest.fn(() => false),
4255
- acquire: jest.fn().mockResolvedValue(undefined),
4008
+ updateDescriptor: jest.fn(),
4009
+ updateFromCache: jest.fn(),
4010
+ hasDeviceAcquire: jest.fn(() => true),
4011
+ acquire,
4256
4012
  commands,
4257
4013
  getCommands: () => commands,
4258
- mainId: 'old-usb-path',
4014
+ mainId: 'usb-session',
4259
4015
  });
4260
- const otherDevice = {
4261
- getConnectId: () => 'usb-a',
4262
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4263
- };
4264
- const expectedDevice = {
4265
- getConnectId: () => 'usb-b',
4266
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4267
- };
4268
4016
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4269
4017
  devices: {},
4270
- deviceList: [otherDevice, expectedDevice],
4018
+ deviceList: [{ getConnectId: () => 'usb-path' }],
4271
4019
  } as any);
4272
4020
  (method as any).device = device;
4273
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4274
4021
 
4275
4022
  try {
4276
4023
  await (method as any).reconnectProtocolV2Device();
4024
+ await (method as any).reconnectProtocolV2Device();
4277
4025
  } finally {
4278
4026
  getDevices.mockRestore();
4279
4027
  }
4280
4028
 
4281
- expect(updateFromCache).toHaveBeenCalledWith(expectedDevice);
4029
+ expect(acquire).not.toHaveBeenCalled();
4030
+ expect(device.hasDeviceAcquire).toHaveBeenCalledTimes(2);
4282
4031
  });
4283
4032
 
4284
- test('rejects ambiguous Protocol V2 USB reconnect candidates without an identity match', async () => {
4033
+ test('stops final reconnect polling immediately when firmware changes device identity', async () => {
4285
4034
  const method = new FirmwareUpdateV4({
4286
4035
  id: 1,
4287
4036
  payload: {
4288
4037
  method: 'firmwareUpdateV4',
4289
4038
  },
4290
4039
  });
4291
- const updateFromCache = jest.fn();
4292
- const device = stubDevice({
4293
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4294
- deviceConnector: {
4295
- enumerate: jest.fn().mockResolvedValue({
4296
- descriptors: [
4297
- { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4298
- { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4299
- ],
4300
- }),
4040
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4041
+ const typedCall = jest.fn().mockResolvedValue({
4042
+ type: 'DeviceInfo',
4043
+ message: {
4044
+ hw: { serial_no: 'PR2SERIAL' },
4045
+ fw: { application: { version: '1.0.0' } },
4301
4046
  },
4302
- updateFromCache,
4303
- });
4304
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4305
- devices: {},
4306
- deviceList: [
4307
- {
4308
- getConnectId: () => 'usb-a',
4309
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4310
- },
4311
- {
4312
- getConnectId: () => 'usb-b',
4313
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4314
- },
4315
- ],
4316
- } as any);
4317
- (method as any).device = device;
4318
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-C';
4319
-
4320
- try {
4321
- await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4322
- } finally {
4323
- getDevices.mockRestore();
4324
- }
4325
-
4326
- expect(updateFromCache).not.toHaveBeenCalled();
4327
- });
4328
-
4329
- test('allows a single Protocol V2 USB reconnect candidate when its physical serial is unavailable', async () => {
4330
- const method = new FirmwareUpdateV4({
4331
- id: 1,
4332
- payload: {
4333
- method: 'firmwareUpdateV4',
4334
- },
4335
- });
4336
- const commands = { disposed: true, mainId: '' };
4337
- const updateFromCache = jest.fn();
4338
- const device = stubDevice({
4339
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4340
- deviceConnector: {
4341
- enumerate: jest.fn().mockResolvedValue({
4342
- descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4343
- }),
4344
- },
4345
- updateFromCache,
4346
- hasDeviceAcquire: jest.fn(() => false),
4347
- acquire: jest.fn().mockResolvedValue(undefined),
4348
- commands,
4349
- getCommands: () => commands,
4350
- mainId: 'old-usb-path',
4351
- });
4352
- const candidate = {
4353
- getConnectId: () => 'usb-a',
4354
- getCurrentSerialNo: () => '',
4355
- };
4356
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4357
- devices: {},
4358
- deviceList: [candidate],
4359
- } as any);
4360
- (method as any).device = device;
4361
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-A';
4362
-
4363
- try {
4364
- await (method as any).reconnectProtocolV2Device();
4365
- } finally {
4366
- getDevices.mockRestore();
4367
- }
4368
-
4369
- expect(updateFromCache).toHaveBeenCalledWith(candidate);
4370
- });
4371
-
4372
- test('rejects a single Protocol V2 USB reconnect candidate with a different physical serial', async () => {
4373
- const method = new FirmwareUpdateV4({
4374
- id: 1,
4375
- payload: {
4376
- method: 'firmwareUpdateV4',
4377
- },
4378
- });
4379
- const updateFromCache = jest.fn();
4380
- const device = stubDevice({
4381
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4382
- deviceConnector: {
4383
- enumerate: jest.fn().mockResolvedValue({
4384
- descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4385
- }),
4386
- },
4387
- updateFromCache,
4388
- });
4389
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4390
- devices: {},
4391
- deviceList: [
4392
- {
4393
- getConnectId: () => 'usb-a',
4394
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4395
- },
4396
- ],
4397
- } as any);
4398
- (method as any).device = device;
4399
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4400
-
4401
- try {
4402
- await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4403
- } finally {
4404
- getDevices.mockRestore();
4405
- }
4406
-
4407
- expect(updateFromCache).not.toHaveBeenCalled();
4408
- });
4409
-
4410
- test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4411
- const method = new FirmwareUpdateV4({
4412
- id: 1,
4413
- payload: {
4414
- method: 'firmwareUpdateV4',
4415
- },
4416
- });
4417
- const commands = { disposed: false, mainId: 'usb-session' };
4418
- const acquire = jest.fn().mockResolvedValue(undefined);
4419
- const device = stubDevice({
4420
- originalDescriptor: {
4421
- id: 'usb-id',
4422
- path: 'usb-path',
4423
- session: 'usb-session',
4424
- protocolType: 'V2',
4425
- },
4426
- deviceConnector: {
4427
- enumerate: jest.fn().mockResolvedValue({
4428
- descriptors: [
4429
- {
4430
- id: 'usb-id',
4431
- path: 'usb-path',
4432
- session: 'usb-session',
4433
- protocolType: 'V2',
4434
- },
4435
- ],
4436
- }),
4437
- },
4438
- updateDescriptor: jest.fn(),
4439
- updateFromCache: jest.fn(),
4440
- hasDeviceAcquire: jest.fn(() => true),
4441
- acquire,
4442
- commands,
4443
- getCommands: () => commands,
4444
- mainId: 'usb-session',
4445
- });
4446
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4447
- devices: {},
4448
- deviceList: [{ getConnectId: () => 'usb-path' }],
4449
- } as any);
4450
- (method as any).device = device;
4451
-
4452
- try {
4453
- await (method as any).reconnectProtocolV2Device();
4454
- await (method as any).reconnectProtocolV2Device();
4455
- } finally {
4456
- getDevices.mockRestore();
4457
- }
4458
-
4459
- expect(acquire).not.toHaveBeenCalled();
4460
- expect(device.hasDeviceAcquire).toHaveBeenCalledTimes(2);
4461
- });
4462
-
4463
- test('stops final reconnect polling immediately when firmware changes device identity', async () => {
4464
- const method = new FirmwareUpdateV4({
4465
- id: 1,
4466
- payload: {
4467
- method: 'firmwareUpdateV4',
4468
- },
4469
- });
4470
- const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4471
- const typedCall = jest.fn().mockResolvedValue({
4472
- type: 'DeviceInfo',
4473
- message: {
4474
- hw: { serial_no: 'PR2SERIAL' },
4475
- fw: { application: { version: '1.0.0' } },
4476
- },
4477
- });
4478
- (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
4479
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4480
- (method as any).device = stubDevice({
4481
- getCommands: () => ({ typedCall }),
4482
- probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4483
- deviceId: 'changed-device',
4484
- mode: 'normal',
4485
- bootloaderMode: false,
4486
- }),
4047
+ });
4048
+ (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
4049
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4050
+ (method as any).device = stubDevice({
4051
+ getCommands: () => ({ typedCall }),
4052
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4053
+ deviceId: 'changed-device',
4054
+ mode: 'normal',
4055
+ bootloaderMode: false,
4056
+ }),
4487
4057
  });
4488
4058
 
4489
4059
  await expect((method as any).waitForProtocolV2ReconnectAndFeatures(60_000)).rejects.toThrow(
@@ -4565,9 +4135,9 @@ describe('Protocol V2 firmware update targets', () => {
4565
4135
  const typedCall = jest
4566
4136
  .fn()
4567
4137
  .mockRejectedValue(
4568
- new DOMException(
4569
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4570
- 'NotFoundError'
4138
+ Object.assign(
4139
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
+ { name: 'NotFoundError' }
4571
4141
  )
4572
4142
  );
4573
4143
 
@@ -4748,6 +4318,77 @@ describe('Protocol V2 firmware update targets', () => {
4748
4318
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4749
4319
  });
4750
4320
 
4321
+ test('rejects a finished target whose payload version conflicts with the plan', () => {
4322
+ const method = new FirmwareUpdateV4({
4323
+ id: 1,
4324
+ payload: {
4325
+ method: 'firmwareUpdateV4',
4326
+ platform: 'desktop',
4327
+ expectedTargetVersions: {
4328
+ app_v1: '2.0.0',
4329
+ },
4330
+ },
4331
+ });
4332
+ method.init();
4333
+ method.postProgressMessage = jest.fn();
4334
+
4335
+ expect(() =>
4336
+ (method as any).assertProtocolV2TargetStatus(
4337
+ [{ target_id: 4, status: 2, payload_version: 0x010000 }],
4338
+ new Set([4])
4339
+ )
4340
+ ).toThrow('expected 131072');
4341
+ });
4342
+
4343
+ test('does not fail a completed multi-app install when final app versions are unobservable', () => {
4344
+ const method = new FirmwareUpdateV4({
4345
+ id: 1,
4346
+ payload: {
4347
+ method: 'firmwareUpdateV4',
4348
+ platform: 'desktop',
4349
+ targetsToUpdate: ['app_v1', 'app_v2'],
4350
+ expectedTargetVersions: {
4351
+ app_v1: '1.0.0',
4352
+ app_v2: '2.0.0',
4353
+ },
4354
+ },
4355
+ });
4356
+ method.init();
4357
+ (method as any).protocolV2LatestFinalFeatures = {
4358
+ major_version: 3,
4359
+ minor_version: 0,
4360
+ patch_version: 0,
4361
+ };
4362
+ (method as any).protocolV2FinalStatusVerified = true;
4363
+
4364
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
4365
+ });
4366
+
4367
+ test('rejects unobservable final versions after the status endpoint fallback', () => {
4368
+ const method = new FirmwareUpdateV4({
4369
+ id: 1,
4370
+ payload: {
4371
+ method: 'firmwareUpdateV4',
4372
+ platform: 'desktop',
4373
+ targetsToUpdate: ['app_v1', 'app_v2'],
4374
+ expectedTargetVersions: {
4375
+ app_v1: '1.0.0',
4376
+ app_v2: '2.0.0',
4377
+ },
4378
+ },
4379
+ });
4380
+ method.init();
4381
+ (method as any).protocolV2LatestFinalFeatures = {
4382
+ major_version: 3,
4383
+ minor_version: 0,
4384
+ patch_version: 0,
4385
+ };
4386
+
4387
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
4388
+ 'has no observable final version after status fallback'
4389
+ );
4390
+ });
4391
+
4751
4392
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4752
4393
  const method = new FirmwareUpdateV4({
4753
4394
  id: 1,
@@ -4824,7 +4465,7 @@ describe('Protocol V2 firmware update targets', () => {
4824
4465
  ).toBe(true);
4825
4466
 
4826
4467
  (method.postProgressMessage as jest.Mock).mockClear();
4827
- expect(
4468
+ expect(() =>
4828
4469
  (method as any).assertProtocolV2TargetStatus(
4829
4470
  [
4830
4471
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4832,8 +4473,8 @@ describe('Protocol V2 firmware update targets', () => {
4832
4473
  ],
4833
4474
  new Set([4, 10])
4834
4475
  )
4835
- ).toBe(false);
4836
- expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4476
+ ).toThrow('Protocol V2 install status conflicts with target 4');
4477
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
4837
4478
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4838
4479
 
4839
4480
  expect(
@@ -4937,10 +4578,14 @@ describe('Protocol V2 firmware update targets', () => {
4937
4578
  const writtenPaths: string[] = [];
4938
4579
  method.postTipMessage = jest.fn();
4939
4580
  method.postProgressMessage = jest.fn();
4940
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4581
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
4941
4582
  writtenPaths.push(params.filePath);
4942
- return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4583
+ return Number(params.processedSize ?? 0) + Number(params.source.size);
4943
4584
  });
4585
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4586
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4587
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
4588
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4944
4589
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4945
4590
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4946
4591
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4980,10 +4625,12 @@ describe('Protocol V2 firmware update targets', () => {
4980
4625
  'vol0:/coprocessor.bin',
4981
4626
  1
4982
4627
  );
4983
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
4984
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4628
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
4629
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
4630
+ targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
4631
+ });
4632
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
4985
4633
  targets: [
4986
- { target_id: 3, path: 'vol0:/bootloader.bin' },
4987
4634
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4988
4635
  { target_id: 7, path: 'vol0:/se01.bin' },
4989
4636
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4993,7 +4640,7 @@ describe('Protocol V2 firmware update targets', () => {
4993
4640
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4994
4641
  });
4995
4642
 
4996
- test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4643
+ test('announces separate resource and component transfer phases', async () => {
4997
4644
  const method = new FirmwareUpdateV4({
4998
4645
  id: 1,
4999
4646
  payload: {
@@ -5003,11 +4650,14 @@ describe('Protocol V2 firmware update targets', () => {
5003
4650
 
5004
4651
  method.postTipMessage = jest.fn();
5005
4652
  method.postProgressMessage = jest.fn();
5006
- (method as any).protocolV2CommonUpdateProcess = jest
4653
+ (method as any).protocolV2SourceUpdateProcess = jest
5007
4654
  .fn()
5008
4655
  .mockImplementation(params =>
5009
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4656
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5010
4657
  );
4658
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4659
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4660
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5011
4661
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5012
4662
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5013
4663
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5027,21 +4677,22 @@ describe('Protocol V2 firmware update targets', () => {
5027
4677
  {
5028
4678
  fileName: 'application_p1.bin',
5029
4679
  binary: new Uint8Array([3]).buffer,
5030
- targetId: 3,
4680
+ targetId: 4,
5031
4681
  },
5032
4682
  ],
5033
4683
  });
5034
4684
 
5035
- expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4685
+ expect(method.postTipMessage).toHaveBeenCalledTimes(3);
5036
4686
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5037
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5038
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4687
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5039
4690
  1,
5040
- expect.objectContaining({ processedSize: 0, totalSize: 3 })
4691
+ expect.objectContaining({ processedSize: 0, totalSize: 2 })
5041
4692
  );
5042
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4693
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5043
4694
  2,
5044
- expect.objectContaining({ processedSize: 2, totalSize: 3 })
4695
+ expect.objectContaining({ processedSize: 0, totalSize: 1 })
5045
4696
  );
5046
4697
  });
5047
4698
 
@@ -5055,7 +4706,8 @@ describe('Protocol V2 firmware update targets', () => {
5055
4706
 
5056
4707
  method.postTipMessage = jest.fn();
5057
4708
  method.postProgressMessage = jest.fn();
5058
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4709
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5059
4711
  (method as any).device = stubDevice({
5060
4712
  getCommands: () => ({
5061
4713
  typedCall: jest.fn().mockResolvedValue({
@@ -5101,11 +4753,14 @@ describe('Protocol V2 firmware update targets', () => {
5101
4753
 
5102
4754
  method.postTipMessage = jest.fn();
5103
4755
  method.postProgressMessage = jest.fn();
5104
- (method as any).protocolV2CommonUpdateProcess = jest
4756
+ (method as any).protocolV2SourceUpdateProcess = jest
5105
4757
  .fn()
5106
4758
  .mockImplementation(params =>
5107
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4759
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5108
4760
  );
4761
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4762
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4763
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5109
4764
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5110
4765
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5111
4766
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5320,42 +4975,33 @@ describe('Protocol V2 firmware update targets', () => {
5320
4975
  getFirmwareLatestReleaseSpy.mockRestore();
5321
4976
  });
5322
4977
 
5323
- test('does not download Pro2 firmware components that App did not select', async () => {
4978
+ test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5324
4979
  const method = new FirmwareUpdateV4({
5325
4980
  id: 1,
5326
4981
  payload: {
5327
4982
  method: 'firmwareUpdateV4',
5328
4983
  platform: 'web',
4984
+ targetsToUpdate: ['resource'],
5329
4985
  },
5330
4986
  });
5331
4987
  method.init();
5332
4988
 
5333
- const getSysResourceBinarySpy = jest
5334
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5335
- .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5336
4989
  const getFirmwareLatestReleaseSpy = jest
5337
4990
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5338
4991
  .mockReturnValue({
5339
4992
  required: false,
5340
4993
  version: [1, 0, 0],
5341
- url: 'https://example.com/applicationP1.pp.bin',
5342
- bootloaderVersion: [1, 0, 0],
5343
- upgradeType: 'payload-package-set',
5344
- installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5345
- components: {
5346
- bootloader: {
5347
- target: 'BOOTLOADER',
5348
- url: 'https://example.com/bootloader.pp.bin',
5349
- },
5350
- applicationP1: {
5351
- target: 'APPLICATION_P1',
5352
- url: 'https://example.com/applicationP1.pp.bin',
5353
- },
5354
- applicationP2: {
5355
- target: 'APPLICATION_P2',
5356
- url: 'https://example.com/applicationP2.pp.bin',
4994
+ url: '',
4995
+ resourceBundles: [
4996
+ {
4997
+ name: 'images',
4998
+ url: 'https://example.com/images.okpkg',
4999
+ devicePath: 'VOL0:/resource/images/images.okpkg',
5000
+ version: [2, 0, 0],
5001
+ payloadHash: 'ab'.repeat(64),
5002
+ headerHash: 'cd'.repeat(64),
5357
5003
  },
5358
- },
5004
+ ],
5359
5005
  fingerprint: '',
5360
5006
  changelog: {
5361
5007
  'zh-CN': '',
@@ -5363,53 +5009,187 @@ describe('Protocol V2 firmware update targets', () => {
5363
5009
  },
5364
5010
  });
5365
5011
 
5366
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5012
+ const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5367
5013
  deviceType: 'pro2',
5368
5014
  firmwareVersion: '1.0.0',
5369
- bootloaderVersion: '1.0.0',
5370
5015
  capabilities: [],
5371
5016
  });
5372
5017
 
5373
- expect(remoteBinaries).toEqual({
5374
- bootloaderBinary: null,
5375
- fwBinaryMap: [],
5376
- installItems: [],
5377
- });
5378
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5018
+ expect(bundles).toEqual([
5019
+ {
5020
+ name: 'images',
5021
+ binary: expect.any(ArrayBuffer),
5022
+ devicePath: 'vol0:/resource/images/images.okpkg',
5023
+ url: 'https://example.com/images.okpkg',
5024
+ version: [2, 0, 0],
5025
+ payloadHash: 'ab'.repeat(64),
5026
+ headerHash: 'cd'.repeat(64),
5027
+ },
5028
+ ]);
5029
+ expect(bundles[0].binary.byteLength).toBe(0);
5379
5030
 
5380
- getSysResourceBinarySpy.mockRestore();
5381
5031
  getFirmwareLatestReleaseSpy.mockRestore();
5382
5032
  });
5383
5033
 
5384
- test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5034
+ test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5385
5035
  const method = new FirmwareUpdateV4({
5386
5036
  id: 1,
5387
5037
  payload: {
5388
5038
  method: 'firmwareUpdateV4',
5389
5039
  platform: 'web',
5040
+ targetsToUpdate: ['resource'],
5390
5041
  },
5391
5042
  });
5392
5043
  method.init();
5393
5044
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5394
5045
 
5395
- const remoteBinaries = {
5396
- bootloaderBinary: new Uint8Array([1]).buffer,
5397
- fwBinaryMap: [
5398
- {
5399
- fileName: 'application_p1.bin',
5400
- binary: new Uint8Array([2]).buffer,
5401
- targetId: 3,
5046
+ const getSysResourceBinarySpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
5047
+ const getFirmwareLatestReleaseSpy = jest
5048
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5049
+ .mockReturnValue({
5050
+ required: false,
5051
+ version: [1, 0, 0],
5052
+ url: '',
5053
+ resourceBundles: [
5054
+ {
5055
+ name: 'images',
5056
+ url: 'https://example.com/images.okpkg',
5057
+ devicePath: 'vol0:/resource/../images.okpkg',
5058
+ },
5059
+ ],
5060
+ fingerprint: '',
5061
+ changelog: {
5062
+ 'zh-CN': '',
5063
+ 'en-US': '',
5402
5064
  },
5403
- ],
5404
- };
5065
+ });
5405
5066
 
5406
5067
  (method as any).device = stubDevice({
5407
5068
  originalDescriptor: { protocolType: 'V2' },
5408
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5069
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5409
5070
  });
5410
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5411
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5412
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5071
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
+ bootloaderBinary: null,
5073
+ fwBinaryMap: [],
5074
+ });
5075
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5076
+ method.postTipMessage = jest.fn();
5077
+
5078
+ await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5081
+
5082
+ getSysResourceBinarySpy.mockRestore();
5083
+ getFirmwareLatestReleaseSpy.mockRestore();
5084
+ });
5085
+
5086
+ test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5087
+ const method = new FirmwareUpdateV4({
5088
+ id: 1,
5089
+ payload: {
5090
+ method: 'firmwareUpdateV4',
5091
+ },
5092
+ });
5093
+ const openPreparedSource = jest.fn();
5094
+ (method as any).params = {
5095
+ targetsToUpdate: ['resource'],
5096
+ resourceBundleArtifacts: [
5097
+ {
5098
+ name: 'images',
5099
+ artifact: {
5100
+ artifactRef: `fw:${'a'.repeat(64)}`,
5101
+ size: 1,
5102
+ sha256: 'a'.repeat(64),
5103
+ },
5104
+ },
5105
+ ],
5106
+ };
5107
+ (method as any).openProtocolV2PreparedSource = openPreparedSource;
5108
+ const getFirmwareLatestReleaseSpy = jest
5109
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5110
+ .mockReturnValue({
5111
+ required: false,
5112
+ version: [1, 0, 0],
5113
+ url: '',
5114
+ resourceBundles: [
5115
+ {
5116
+ name: 'images',
5117
+ url: 'https://example.com/images.okpkg',
5118
+ devicePath: 'vol0:/resource/../images.okpkg',
5119
+ },
5120
+ ],
5121
+ fingerprint: '',
5122
+ changelog: {
5123
+ 'zh-CN': '',
5124
+ 'en-US': '',
5125
+ },
5126
+ });
5127
+
5128
+ await expect(
5129
+ (method as any).prepareProtocolV2ResourceSources('universal', {
5130
+ deviceType: 'pro2',
5131
+ })
5132
+ ).rejects.toThrow('resourceBundles[].devicePath');
5133
+ expect(openPreparedSource).not.toHaveBeenCalled();
5134
+
5135
+ getFirmwareLatestReleaseSpy.mockRestore();
5136
+ });
5137
+
5138
+ test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5139
+ const method = new FirmwareUpdateV4({
5140
+ id: 1,
5141
+ payload: {
5142
+ method: 'firmwareUpdateV4',
5143
+ platform: 'web',
5144
+ targetsToUpdate: ['resource'],
5145
+ },
5146
+ });
5147
+ method.init();
5148
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5149
+
5150
+ const order: string[] = [];
5151
+ const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5152
+ const getSysResourceBinarySpy = jest
5153
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5154
+ .mockImplementation(() => {
5155
+ order.push('download');
5156
+ return Promise.resolve({ binary: resourceBinary });
5157
+ });
5158
+ const getFirmwareLatestReleaseSpy = jest
5159
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5160
+ .mockReturnValue({
5161
+ required: false,
5162
+ version: [1, 0, 0],
5163
+ url: '',
5164
+ resourceBundles: [
5165
+ {
5166
+ name: 'images',
5167
+ url: 'https://example.com/images.okpkg',
5168
+ devicePath: ' VOL0:/resource/images/images.okpkg ',
5169
+ },
5170
+ ],
5171
+ fingerprint: '',
5172
+ changelog: {
5173
+ 'zh-CN': '',
5174
+ 'en-US': '',
5175
+ },
5176
+ });
5177
+
5178
+ (method as any).device = stubDevice({
5179
+ originalDescriptor: { protocolType: 'V2' },
5180
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5181
+ });
5182
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5183
+ bootloaderBinary: null,
5184
+ fwBinaryMap: [],
5185
+ });
5186
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
5187
+ order.push('bootloader');
5188
+ return Promise.resolve(true);
5189
+ });
5190
+ const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5191
+ (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5192
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5413
5193
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5414
5194
  (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5415
5195
  bootloaderVersion: '1.0.0',
@@ -5420,37 +5200,208 @@ describe('Protocol V2 firmware update targets', () => {
5420
5200
 
5421
5201
  await method.run();
5422
5202
 
5423
- expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5424
- expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5425
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5426
- bootloaderBinary: remoteBinaries.bootloaderBinary,
5427
- fwBinaryMap: remoteBinaries.fwBinaryMap,
5203
+ expect(order).toEqual(['download', 'bootloader']);
5204
+ expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5205
+ bootloaderBinary: null,
5206
+ fwBinaryMap: [],
5207
+ resourceBundles: [
5208
+ {
5209
+ name: 'images',
5210
+ binary: resourceBinary,
5211
+ devicePath: 'vol0:/resource/images/images.okpkg',
5212
+ url: 'https://example.com/images.okpkg',
5213
+ },
5214
+ ],
5428
5215
  });
5216
+
5217
+ executeProtocolV2UpdateSpy.mockRestore();
5218
+ getSysResourceBinarySpy.mockRestore();
5219
+ getFirmwareLatestReleaseSpy.mockRestore();
5429
5220
  });
5430
5221
 
5431
- test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5222
+ test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5223
+ const method = new FirmwareUpdateV4({
5224
+ id: 1,
5225
+ payload: {
5226
+ method: 'firmwareUpdateV4',
5227
+ },
5228
+ });
5229
+ method.init();
5230
+ const getSysResourceBinarySpy = jest
5231
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5232
+ .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5233
+
5234
+ await expect(
5235
+ (method as any).materializeProtocolV2ResourceBundles([
5236
+ {
5237
+ name: 'images',
5238
+ binary: new ArrayBuffer(0),
5239
+ devicePath: 'vol0:/resource/images/images.okpkg',
5240
+ url: 'https://example.com/images.okpkg',
5241
+ },
5242
+ ])
5243
+ ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5244
+
5245
+ getSysResourceBinarySpy.mockRestore();
5246
+ });
5247
+
5248
+ test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5432
5249
  const method = new FirmwareUpdateV4({
5433
5250
  id: 1,
5434
5251
  payload: {
5435
5252
  method: 'firmwareUpdateV4',
5436
5253
  platform: 'web',
5254
+ targetsToUpdate: ['resource'],
5437
5255
  },
5438
5256
  });
5439
5257
  method.init();
5440
5258
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5259
+
5260
+ const getSysResourceBinarySpy = jest
5261
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5262
+ .mockResolvedValue({ binary: new ArrayBuffer(0) });
5263
+ const getFirmwareLatestReleaseSpy = jest
5264
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5265
+ .mockReturnValue({
5266
+ required: false,
5267
+ version: [1, 0, 0],
5268
+ url: '',
5269
+ resourceBundles: [
5270
+ {
5271
+ name: 'images',
5272
+ url: 'https://example.com/images.okpkg',
5273
+ devicePath: 'vol0:/resource/images/images.okpkg',
5274
+ },
5275
+ ],
5276
+ fingerprint: '',
5277
+ changelog: {
5278
+ 'zh-CN': '',
5279
+ 'en-US': '',
5280
+ },
5281
+ });
5441
5282
  (method as any).device = stubDevice({
5442
5283
  originalDescriptor: { protocolType: 'V2' },
5443
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5284
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5285
+ });
5286
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5287
+ bootloaderBinary: null,
5288
+ fwBinaryMap: [],
5444
5289
  });
5445
- forceReloadDataSpy.mockRejectedValue(new Error('latest config unavailable'));
5446
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5447
5290
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
5448
5291
  method.postTipMessage = jest.fn();
5449
5292
 
5450
- await expect(method.run()).rejects.toThrow('latest config unavailable');
5451
-
5452
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5293
+ await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5453
5294
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5295
+
5296
+ getSysResourceBinarySpy.mockRestore();
5297
+ getFirmwareLatestReleaseSpy.mockRestore();
5298
+ });
5299
+
5300
+ test('does not download Pro2 firmware components that App did not select', async () => {
5301
+ const method = new FirmwareUpdateV4({
5302
+ id: 1,
5303
+ payload: {
5304
+ method: 'firmwareUpdateV4',
5305
+ platform: 'web',
5306
+ },
5307
+ });
5308
+ method.init();
5309
+
5310
+ const getSysResourceBinarySpy = jest
5311
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5312
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5313
+ const getFirmwareLatestReleaseSpy = jest
5314
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5315
+ .mockReturnValue({
5316
+ required: false,
5317
+ version: [1, 0, 0],
5318
+ url: 'https://example.com/applicationP1.pp.bin',
5319
+ bootloaderVersion: [1, 0, 0],
5320
+ upgradeType: 'payload-package-set',
5321
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5322
+ components: {
5323
+ bootloader: {
5324
+ target: 'BOOTLOADER',
5325
+ url: 'https://example.com/bootloader.pp.bin',
5326
+ },
5327
+ applicationP1: {
5328
+ target: 'APPLICATION_P1',
5329
+ url: 'https://example.com/applicationP1.pp.bin',
5330
+ },
5331
+ applicationP2: {
5332
+ target: 'APPLICATION_P2',
5333
+ url: 'https://example.com/applicationP2.pp.bin',
5334
+ },
5335
+ },
5336
+ fingerprint: '',
5337
+ changelog: {
5338
+ 'zh-CN': '',
5339
+ 'en-US': '',
5340
+ },
5341
+ });
5342
+
5343
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5344
+ deviceType: 'pro2',
5345
+ firmwareVersion: '1.0.0',
5346
+ bootloaderVersion: '1.0.0',
5347
+ capabilities: [],
5348
+ });
5349
+
5350
+ expect(remoteBinaries).toEqual({
5351
+ bootloaderBinary: null,
5352
+ fwBinaryMap: [],
5353
+ installItems: [],
5354
+ });
5355
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5356
+
5357
+ getSysResourceBinarySpy.mockRestore();
5358
+ getFirmwareLatestReleaseSpy.mockRestore();
5359
+ });
5360
+
5361
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5362
+ const method = new FirmwareUpdateV4({
5363
+ id: 1,
5364
+ payload: {
5365
+ method: 'firmwareUpdateV4',
5366
+ platform: 'web',
5367
+ },
5368
+ });
5369
+ method.init();
5370
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5371
+
5372
+ const remoteBinaries = {
5373
+ bootloaderBinary: new Uint8Array([1]).buffer,
5374
+ fwBinaryMap: [
5375
+ {
5376
+ fileName: 'application_p1.bin',
5377
+ binary: new Uint8Array([2]).buffer,
5378
+ targetId: 3,
5379
+ },
5380
+ ],
5381
+ };
5382
+
5383
+ (method as any).device = stubDevice({
5384
+ originalDescriptor: { protocolType: 'V2' },
5385
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5386
+ });
5387
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5388
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5389
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5390
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5391
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5392
+ bootloaderVersion: '1.0.0',
5393
+ bleVersion: '0.0.0',
5394
+ firmwareVersion: '1.0.0',
5395
+ });
5396
+ method.postTipMessage = jest.fn();
5397
+
5398
+ await method.run();
5399
+
5400
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5401
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5402
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5403
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5404
+ });
5454
5405
  });
5455
5406
 
5456
5407
  test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
@@ -5578,7 +5529,10 @@ describe('Protocol V2 firmware update targets', () => {
5578
5529
 
5579
5530
  method.postTipMessage = jest.fn();
5580
5531
  method.postProgressMessage = jest.fn();
5581
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5532
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5533
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5534
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5535
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5582
5536
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5583
5537
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5584
5538
 
@@ -5594,7 +5548,7 @@ describe('Protocol V2 firmware update targets', () => {
5594
5548
  fwBinaryMap: [],
5595
5549
  });
5596
5550
 
5597
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5551
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5598
5552
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5599
5553
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5600
5554
  });
@@ -5625,12 +5579,19 @@ describe('Protocol V2 firmware update targets', () => {
5625
5579
  });
5626
5580
  method.postProgressMessage = jest.fn();
5627
5581
 
5628
- await (method as any).protocolV2CommonUpdateProcess({
5629
- payload: new Uint8Array(4097).buffer,
5630
- filePath: 'vol1:firmware.bin',
5631
- processedSize: 0,
5632
- totalSize: 4097,
5582
+ const source = await openFirmwareByteSource({
5583
+ binary: new Uint8Array(4097).buffer,
5633
5584
  });
5585
+ try {
5586
+ await (method as any).protocolV2SourceUpdateProcess({
5587
+ source,
5588
+ filePath: 'vol1:firmware.bin',
5589
+ processedSize: 0,
5590
+ totalSize: 4097,
5591
+ });
5592
+ } finally {
5593
+ await source?.close();
5594
+ }
5634
5595
 
5635
5596
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5636
5597
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5687,12 +5648,16 @@ describe('Protocol V2 firmware update targets', () => {
5687
5648
  method.postProgressMessage = jest.fn();
5688
5649
 
5689
5650
  try {
5690
- await (method as any).protocolV2CommonUpdateProcess({
5691
- payload: new Uint8Array(8001).buffer,
5651
+ const source = await openFirmwareByteSource({
5652
+ binary: new Uint8Array(8001).buffer,
5653
+ });
5654
+ await (method as any).protocolV2SourceUpdateProcess({
5655
+ source,
5692
5656
  filePath: 'vol0:/firmware.bin',
5693
5657
  processedSize: 0,
5694
5658
  totalSize: 8001,
5695
5659
  });
5660
+ await source?.close();
5696
5661
  } finally {
5697
5662
  setTimeoutSpy.mockRestore();
5698
5663
  }
@@ -5727,16 +5692,21 @@ describe('Protocol V2 firmware update targets', () => {
5727
5692
  });
5728
5693
  method.postProgressMessage = jest.fn();
5729
5694
  method.postTipMessage = jest.fn();
5695
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5730
5696
 
5697
+ const source = await openFirmwareByteSource({
5698
+ binary: new Uint8Array(4097).buffer,
5699
+ });
5731
5700
  await expect(
5732
- (method as any).protocolV2WriteWholeFile({
5733
- payload: new Uint8Array(4097).buffer,
5701
+ (method as any).protocolV2SourceUpdateProcess({
5702
+ source,
5734
5703
  filePath: 'vol0:/firmware.bin',
5735
5704
  processedSize: 0,
5736
5705
  totalSize: 4097,
5737
5706
  })
5738
5707
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5739
- expect(typedCall).toHaveBeenCalledTimes(2);
5708
+ await source?.close();
5709
+ expect(typedCall).toHaveBeenCalledTimes(6);
5740
5710
  });
5741
5711
 
5742
5712
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5769,12 +5739,19 @@ describe('Protocol V2 firmware update targets', () => {
5769
5739
  });
5770
5740
  method.postProgressMessage = jest.fn();
5771
5741
 
5772
- await (method as any).protocolV2CommonUpdateProcess({
5773
- payload: new Uint8Array(1801).buffer,
5774
- filePath: 'vol1:ble-firmware.bin',
5775
- processedSize: 0,
5776
- totalSize: 1801,
5742
+ const source = await openFirmwareByteSource({
5743
+ binary: new Uint8Array(1801).buffer,
5777
5744
  });
5745
+ try {
5746
+ await (method as any).protocolV2SourceUpdateProcess({
5747
+ source,
5748
+ filePath: 'vol1:ble-firmware.bin',
5749
+ processedSize: 0,
5750
+ totalSize: 1801,
5751
+ });
5752
+ } finally {
5753
+ await source?.close();
5754
+ }
5778
5755
 
5779
5756
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5780
5757
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -5891,9 +5868,9 @@ describe('Protocol V2 firmware update method', () => {
5891
5868
  const typedCall = jest
5892
5869
  .fn()
5893
5870
  .mockRejectedValue(
5894
- new DOMException(
5895
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
5896
- 'NotFoundError'
5871
+ Object.assign(
5872
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
+ { name: 'NotFoundError' }
5897
5874
  )
5898
5875
  );
5899
5876
 
@@ -6058,63 +6035,20 @@ describe('Protocol V2 firmware update method', () => {
6058
6035
  });
6059
6036
 
6060
6037
  describe('Protocol V2 firmware reconnect identity', () => {
6061
- const createResourceFilesystemTypedCall = (
6062
- stable: Array<{ type: string; size: number; headerHash: string }>,
6063
- missingTypes: string[] = []
6064
- ) => {
6065
- const missing = new Set(missingTypes);
6066
- const resourceByPath = new Map(
6067
- stable.map(resource => [
6068
- PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6069
- resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6070
- ],
6071
- resource,
6072
- ])
6073
- );
6074
- return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6075
- if (requestType === 'ResourceInventoryGet') {
6076
- throw new Error('ResourceInventoryGet is unavailable on released firmware');
6077
- }
6078
- if (requestType === 'FilesystemPathInfoQuery') {
6079
- const resource = resourceByPath.get(payload.path);
6080
- const exists = Boolean(resource && !missing.has(resource.type));
6081
- return {
6082
- message: {
6083
- exist: exists,
6084
- directory: false,
6085
- size: exists ? resource?.size : 0,
6086
- },
6087
- };
6088
- }
6089
- if (requestType === 'FilesystemFileRead') {
6090
- const resource = resourceByPath.get(payload.file.path);
6091
- if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6092
- const header = new Uint8Array(0x52a0);
6093
- const view = new DataView(header.buffer);
6094
- 'OKPP'.split('').forEach((char, index) => {
6095
- header[index] = char.charCodeAt(0);
6096
- });
6097
- 'RESC'.split('').forEach((char, index) => {
6098
- header[0x08 + index] = char.charCodeAt(0);
6099
- });
6100
- view.setUint32(0x0c, header.byteLength, true);
6101
- for (let index = 0; index < resource.headerHash.length / 2; index++) {
6102
- header[0x240 + index] = Number.parseInt(
6103
- resource.headerHash.slice(index * 2, index * 2 + 2),
6104
- 16
6105
- );
6106
- }
6107
- const offset = Number(payload.file.offset);
6108
- const chunkLength = Number(payload.chunk_len);
6109
- return {
6110
- message: {
6111
- data: header.slice(offset, offset + chunkLength),
6112
- },
6113
- };
6114
- }
6115
- throw new Error(`Unexpected request: ${requestType}`);
6038
+ test('keeps the host plan identity as the V4 reconnect anchor', () => {
6039
+ const method = new FirmwareUpdateV4({
6040
+ id: 1,
6041
+ payload: {
6042
+ method: 'firmwareUpdateV4',
6043
+ platform: 'native',
6044
+ expectedDeviceId: 'persisted-device-id',
6045
+ },
6116
6046
  });
6117
- };
6047
+
6048
+ method.init();
6049
+
6050
+ expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6051
+ });
6118
6052
 
6119
6053
  test('rejects a different physical serial before firmware transfer resumes', () => {
6120
6054
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
@@ -6125,10 +6059,16 @@ describe('Protocol V2 firmware reconnect identity', () => {
6125
6059
  ).not.toThrow();
6126
6060
  });
6127
6061
 
6128
- test('allows reconnect when either physical serial is unavailable', () => {
6129
- expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).not.toThrow();
6130
- expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).not.toThrow();
6131
- expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).not.toThrow();
6062
+ test('fails closed when the physical serial is unavailable', () => {
6063
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
6064
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6065
+ );
6066
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).toThrow(
6067
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6068
+ );
6069
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).toThrow(
6070
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6071
+ );
6132
6072
  });
6133
6073
 
6134
6074
  test('captures physical identity from active DeviceInfo instead of wallet deviceId', async () => {
@@ -6161,7 +6101,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6161
6101
  );
6162
6102
  });
6163
6103
 
6164
- test('allows firmware update startup when DeviceInfo has no physical serial', async () => {
6104
+ test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
6165
6105
  const method = new FirmwareUpdateV4({
6166
6106
  id: 1,
6167
6107
  payload: { method: 'firmwareUpdateV4' },
@@ -6174,209 +6114,39 @@ describe('Protocol V2 firmware reconnect identity', () => {
6174
6114
  getCommands: () => ({ typedCall }),
6175
6115
  });
6176
6116
 
6177
- await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
6178
- expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
6179
- });
6180
-
6181
- test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6182
- const method = new FirmwareUpdateV4({
6183
- id: 1,
6184
- payload: {
6185
- method: 'firmwareUpdateV4',
6186
- platform: 'web',
6187
- targetsToUpdate: ['resource'],
6188
- },
6117
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
6118
+ errorCode: HardwareErrorCode.DeviceNotFound,
6189
6119
  });
6190
- method.init();
6191
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6192
- (type, index) => ({
6193
- type,
6194
- url: `https://example.com/${type}.okpkg`,
6195
- size: 0x52a0 + index + 1,
6196
- fileHash: 'a'.repeat(64),
6197
- headerHash: index.toString(16).padStart(128, '0'),
6198
- })
6199
- );
6200
- const typedCall = createResourceFilesystemTypedCall(stable);
6201
- (method as any).device = stubDevice({
6202
- getCommands: () => ({ typedCall }),
6203
- });
6204
- const resourcesSpy = jest
6205
- .spyOn(DataManager, 'getProtocolV2Resources')
6206
- .mockReturnValue(stable as any);
6207
- (method as any).downloadProtocolV2Resource = jest.fn();
6208
-
6209
- await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6210
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6211
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6212
- expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6213
- resourcesSpy.mockRestore();
6214
6120
  });
6215
6121
 
6216
- test('downloads only changed resources from loader inventory', async () => {
6217
- const method = new FirmwareUpdateV4({
6218
- id: 1,
6219
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6220
- });
6221
- method.init();
6222
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6223
- (type, index) => ({
6224
- type,
6225
- url: `https://example.com/${type}.okpkg`,
6226
- size: 0x52a0 + index + 1,
6227
- fileHash: 'a'.repeat(64),
6228
- headerHash: index.toString(16).padStart(128, '0'),
6229
- })
6230
- );
6231
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6232
- (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6233
- const resourcesSpy = jest
6234
- .spyOn(DataManager, 'getProtocolV2Resources')
6235
- .mockReturnValue(stable as any);
6236
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6237
- Promise.resolve({
6238
- name: `${resource.type}.okpkg`,
6239
- binary: new ArrayBuffer(resource.size),
6240
- devicePath: `vol0:/${resource.type}.okpkg`,
6241
- })
6242
- );
6243
-
6244
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6245
-
6246
- expect(bundles).toHaveLength(1);
6247
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6248
- resourcesSpy.mockRestore();
6249
- });
6250
-
6251
- test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6252
- const method = new FirmwareUpdateV4({
6253
- id: 1,
6254
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6255
- });
6256
- method.init();
6257
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6258
- (type, index) => ({
6259
- type,
6260
- url: `https://example.com/${type}.okpkg`,
6261
- size: 0x52a0 + index + 1,
6262
- fileHash: 'a'.repeat(64),
6263
- headerHash: index.toString(16).padStart(128, '0'),
6264
- })
6265
- );
6266
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6267
- (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6268
- const resourcesSpy = jest
6269
- .spyOn(DataManager, 'getProtocolV2Resources')
6270
- .mockReturnValue(stable as any);
6271
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6272
- Promise.resolve({
6273
- name: `${resource.type}.okpkg`,
6274
- binary: new ArrayBuffer(resource.size),
6275
- devicePath: `vol0:/${resource.type}.okpkg`,
6276
- })
6277
- );
6278
-
6279
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6280
-
6281
- expect(bundles).toHaveLength(1);
6282
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6283
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6284
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6285
- resourcesSpy.mockRestore();
6286
- });
6287
-
6288
- const buildBootResourcesHeader = ({
6289
- type = 'CRAT',
6290
- payloadHash = 'ab'.repeat(64),
6291
- headerHash = 'cd'.repeat(64),
6292
- }: {
6293
- type?: string;
6294
- payloadHash?: string;
6295
- headerHash?: string;
6296
- } = {}) => {
6297
- const header = new Uint8Array(0x52a0);
6298
- const view = new DataView(header.buffer);
6299
- 'OKPP'.split('').forEach((char, index) => {
6300
- header[index] = char.charCodeAt(0);
6301
- });
6302
- type.split('').forEach((char, index) => {
6303
- header[0x08 + index] = char.charCodeAt(0);
6304
- });
6305
- view.setUint32(0x0c, header.byteLength, true);
6306
- const writeHex = (offset: number, hex: string) => {
6307
- for (let index = 0; index < hex.length / 2; index++) {
6308
- header[offset + index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
6309
- }
6310
- };
6311
- writeHex(0x200, payloadHash);
6312
- writeHex(0x240, headerHash);
6313
- return header;
6314
- };
6315
-
6316
- test('does not resolve boot resources unless the optional target is selected', async () => {
6317
- const method = new FirmwareUpdateV4({
6318
- id: 1,
6319
- payload: { method: 'firmwareUpdateV4', platform: 'web' },
6320
- });
6321
- method.init();
6322
- const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6323
- const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6324
-
6325
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6326
-
6327
- expect(configSpy).not.toHaveBeenCalled();
6328
- expect(downloadSpy).not.toHaveBeenCalled();
6329
- });
6330
-
6331
- test('downloads and maps the selected boot resources CRATE target', async () => {
6332
- const payloadHash = 'ab'.repeat(64);
6333
- const headerHash = 'cd'.repeat(64);
6334
- const bytes = buildBootResourcesHeader({ payloadHash, headerHash });
6335
- const binary = bytes.buffer as ArrayBuffer;
6336
- const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6337
- const resource = {
6338
- required: false as const,
6339
- target: 'CRATE' as const,
6340
- url: 'https://example.com/boot-resources.crate.okpkg',
6341
- size: bytes.byteLength,
6342
- fileHash,
6343
- payloadHash,
6344
- headerHash,
6345
- };
6122
+ test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
6346
6123
  const method = new FirmwareUpdateV4({
6347
6124
  id: 1,
6348
6125
  payload: {
6349
6126
  method: 'firmwareUpdateV4',
6350
- platform: 'web',
6351
- targetsToUpdate: ['boot_resources'],
6127
+ platform: 'native',
6128
+ chunkSize: 1800,
6352
6129
  },
6353
6130
  });
6354
6131
  method.init();
6355
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6356
- jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6357
-
6358
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual({
6359
- fileName: 'boot_resources.crate.okpkg',
6360
- binary,
6361
- targetId: 1,
6362
- kind: 'boot_resources',
6132
+ const readChunkLengths: number[] = [];
6133
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6134
+ if (requestType === 'FilesystemPathInfoQuery') {
6135
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
6136
+ }
6137
+ if (requestType === 'FilesystemFileRead') {
6138
+ readChunkLengths.push(request.chunk_len);
6139
+ return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
6140
+ }
6141
+ throw new Error(`Unexpected request: ${requestType}`);
6363
6142
  });
6364
- });
6365
-
6366
- test('rejects a non-CRATE manual boot resources package', async () => {
6367
- const method = new FirmwareUpdateV4({
6368
- id: 1,
6369
- payload: {
6370
- method: 'firmwareUpdateV4',
6371
- platform: 'web',
6372
- bootResourcesBinary: buildBootResourcesHeader({ type: 'RESC' }).buffer,
6373
- },
6143
+ (method as any).device = stubDevice({
6144
+ getCommands: () => ({ typedCall }),
6374
6145
  });
6375
- method.init();
6376
6146
 
6377
- await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
6378
- 'Invalid Pro2 boot resources CRATE header'
6379
- );
6147
+ await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
+ expect(readChunkLengths.length).toBeGreaterThan(1);
6149
+ expect(Math.max(...readChunkLengths)).toBe(900);
6380
6150
  });
6381
6151
  });
6382
6152
 
@@ -6491,257 +6261,338 @@ describe('Protocol V2 protected method execution', () => {
6491
6261
  payload: { method: 'deviceSettings', label: 'My Pro 2' },
6492
6262
  });
6493
6263
 
6494
- method.init();
6495
-
6496
- expect(method.protocolV2UiInteraction).toEqual({
6497
- request: 'button',
6498
- source: 'method-lifecycle',
6499
- reason: 'device-management',
6500
- completion: 'operation-completed',
6501
- deviceOnly: true,
6502
- operation: 'change-label',
6264
+ method.init();
6265
+
6266
+ expect(method.protocolV2UiInteraction).toEqual({
6267
+ request: 'button',
6268
+ source: 'method-lifecycle',
6269
+ reason: 'device-management',
6270
+ completion: 'operation-completed',
6271
+ deviceOnly: true,
6272
+ operation: 'change-label',
6273
+ });
6274
+ });
6275
+
6276
+ test.each([
6277
+ ['deviceInfoGet', DeviceInfoGet],
6278
+ ['deviceStatusGet', DeviceStatusGet],
6279
+ ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6280
+ const method = new MethodClass({
6281
+ id: 1,
6282
+ payload: { method: _name },
6283
+ } as any);
6284
+ method.init();
6285
+
6286
+ expect(method.unlockPolicy).toBe('none');
6287
+ });
6288
+
6289
+ test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6290
+ const calls: string[] = [];
6291
+ const method = {
6292
+ name: 'testBusinessMethod',
6293
+ unlockPolicy: 'retry-on-locked',
6294
+ protocolV2UiInteraction: { reason: 'settings-page' },
6295
+ run: jest
6296
+ .fn()
6297
+ .mockImplementationOnce(() => {
6298
+ calls.push('run-1');
6299
+ return Promise.reject(deviceLockedError());
6300
+ })
6301
+ .mockImplementationOnce(() => {
6302
+ calls.push('run-2');
6303
+ return Promise.resolve({ message: 'ok' });
6304
+ }),
6305
+ };
6306
+ const device = {
6307
+ isProtocolV2: () => true,
6308
+ unlockDevice: jest.fn(() => {
6309
+ calls.push('unlock');
6310
+ return Promise.resolve();
6311
+ }),
6312
+ };
6313
+ const uiCoordinator = {
6314
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6315
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6317
+ };
6318
+
6319
+ await expect(
6320
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6321
+ ).resolves.toEqual({ message: 'ok' });
6322
+ expect(calls).toEqual([
6323
+ 'method-prompt',
6324
+ 'run-1',
6325
+ 'unlock-prompt',
6326
+ 'unlock',
6327
+ 'method-prompt',
6328
+ 'run-2',
6329
+ ]);
6330
+ });
6331
+
6332
+ test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6333
+ const calls: string[] = [];
6334
+ const method = {
6335
+ name: 'evmSignMessage',
6336
+ payload: { passphraseState: 'hidden-state' },
6337
+ useDevicePassphraseState: true,
6338
+ unlockPolicy: 'retry-on-locked',
6339
+ run: jest
6340
+ .fn()
6341
+ .mockImplementationOnce(() => {
6342
+ calls.push('run-1');
6343
+ return Promise.reject(deviceLockedError());
6344
+ })
6345
+ .mockImplementationOnce(() => {
6346
+ calls.push('run-2');
6347
+ return Promise.resolve({ message: 'ok' });
6348
+ }),
6349
+ };
6350
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6351
+ if (requestType === 'ProtocolInfoRequest') {
6352
+ calls.push('negotiate-session');
6353
+ return Promise.resolve({ message: { version: 2 } });
6354
+ }
6355
+ if (requestType === 'DeviceSessionGet') {
6356
+ calls.push('resume-hidden-session');
6357
+ expect(request).toEqual({ session_id: 'hidden-session' });
6358
+ return Promise.resolve({
6359
+ message: {
6360
+ session_id: 'hidden-session',
6361
+ btc_test_address: 'hidden-state',
6362
+ },
6363
+ });
6364
+ }
6365
+ throw new Error(`Unexpected request: ${requestType}`);
6366
+ });
6367
+ const device = {
6368
+ features: { unlocked: true, passphraseProtection: true },
6369
+ passphraseState: 'hidden-state',
6370
+ commands: { typedCall },
6371
+ isProtocolV2: () => true,
6372
+ unlockDevice: jest.fn(() => {
6373
+ calls.push('unlock');
6374
+ return Promise.resolve();
6375
+ }),
6376
+ getCurrentPassphraseProtection: () => true,
6377
+ getInternalState: () => 'hidden-session',
6378
+ clearInternalState: jest.fn(),
6379
+ getCurrentDeviceId: () => 'wallet-device-id',
6380
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6381
+ };
6382
+
6383
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6384
+ message: 'ok',
6503
6385
  });
6386
+ expect(calls).toEqual([
6387
+ 'run-1',
6388
+ 'unlock',
6389
+ 'negotiate-session',
6390
+ 'resume-hidden-session',
6391
+ 'validate-hidden-session',
6392
+ 'run-2',
6393
+ ]);
6504
6394
  });
6505
6395
 
6506
- test.each([
6507
- ['deviceInfoGet', DeviceInfoGet],
6508
- ['deviceStatusGet', DeviceStatusGet],
6509
- ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6510
- const method = new MethodClass({
6511
- id: 1,
6512
- payload: { method: _name },
6513
- } as any);
6514
- method.init();
6396
+ test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6397
+ const initialError = deviceLockedError();
6398
+ const restoreError = new Error('Invalid wallet session');
6399
+ const method = {
6400
+ name: 'evmSignMessage',
6401
+ payload: { passphraseState: 'hidden-state' },
6402
+ useDevicePassphraseState: true,
6403
+ unlockPolicy: 'retry-on-locked',
6404
+ run: jest.fn().mockRejectedValueOnce(initialError),
6405
+ };
6406
+ const typedCall = jest.fn((requestType: string) => {
6407
+ if (requestType === 'ProtocolInfoRequest') {
6408
+ return Promise.resolve({ message: { version: 2 } });
6409
+ }
6410
+ if (requestType === 'DeviceSessionGet') {
6411
+ return Promise.reject(restoreError);
6412
+ }
6413
+ throw new Error(`Unexpected request: ${requestType}`);
6414
+ });
6415
+ const device = {
6416
+ features: { unlocked: true, passphraseProtection: true },
6417
+ passphraseState: 'hidden-state',
6418
+ commands: { typedCall },
6419
+ isProtocolV2: () => true,
6420
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6421
+ getCurrentPassphraseProtection: () => true,
6422
+ getInternalState: () => 'hidden-session',
6423
+ clearInternalState: jest.fn(),
6424
+ getCurrentDeviceId: () => 'wallet-device-id',
6425
+ updateInternalState: jest.fn(),
6426
+ };
6515
6427
 
6516
- expect(method.unlockPolicy).toBe('none');
6428
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6429
+ expect(method.run).toHaveBeenCalledTimes(1);
6430
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6431
+ expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6432
+ expect(device.updateInternalState).not.toHaveBeenCalled();
6517
6433
  });
6518
6434
 
6519
- test('checks fresh status for a wallet business method without a name allowlist', async () => {
6435
+ test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6520
6436
  const calls: string[] = [];
6521
- const features = { unlocked: true };
6522
6437
  const method = {
6523
- name: 'confluxSignMessageCIP23',
6524
- unlockPolicy: 'none',
6438
+ name: 'evmSignMessage',
6439
+ payload: { passphraseState: 'hidden-state' },
6525
6440
  useDevicePassphraseState: true,
6526
- protocolV2UiInteraction: { reason: 'address-confirmation' },
6441
+ unlockPolicy: 'retry-on-locked',
6527
6442
  run: jest.fn(() => {
6528
6443
  calls.push('run');
6529
- return Promise.resolve({ address: '0x1' });
6444
+ return Promise.resolve({ message: 'ok' });
6530
6445
  }),
6531
6446
  };
6447
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6448
+ if (requestType === 'ProtocolInfoRequest') {
6449
+ calls.push('negotiate-session');
6450
+ return Promise.resolve({ message: { version: 2 } });
6451
+ }
6452
+ if (requestType === 'DeviceSessionGet') {
6453
+ calls.push('resume-hidden-session');
6454
+ expect(request).toEqual({ session_id: 'hidden-session' });
6455
+ return Promise.resolve({
6456
+ message: {
6457
+ session_id: 'hidden-session',
6458
+ btc_test_address: 'hidden-state',
6459
+ },
6460
+ });
6461
+ }
6462
+ throw new Error(`Unexpected request: ${requestType}`);
6463
+ });
6464
+ const features = {
6465
+ unlocked: false,
6466
+ unlockedAttachPin: true,
6467
+ passphraseProtection: true,
6468
+ };
6532
6469
  const device = {
6533
6470
  features,
6534
- commands: {
6535
- typedCall: jest.fn(() => {
6536
- calls.push('status');
6537
- return Promise.resolve({ message: { unlocked: true } });
6538
- }),
6539
- },
6471
+ passphraseState: 'hidden-state',
6472
+ commands: { typedCall },
6540
6473
  isProtocolV2: () => true,
6541
- isBootloader: () => false,
6542
- isRomloader: () => false,
6543
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6544
- features.unlocked = status.unlocked === true;
6545
- return features;
6474
+ unlockDevice: jest.fn(() => {
6475
+ calls.push('unlock-main');
6476
+ features.unlocked = true;
6477
+ features.unlockedAttachPin = false;
6478
+ return Promise.resolve();
6546
6479
  }),
6547
- unlockDevice: jest.fn(),
6548
- };
6549
- const uiCoordinator = {
6550
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6551
- enterUnlockInteraction: jest.fn(),
6480
+ getCurrentPassphraseProtection: () => true,
6481
+ getInternalState: () => 'hidden-session',
6482
+ clearInternalState: jest.fn(),
6483
+ getCurrentDeviceId: () => 'wallet-device-id',
6484
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6552
6485
  };
6553
6486
 
6554
- await expect(
6555
- runMethodWithUnlockPolicy(method as any, device as any, {
6556
- uiCoordinator: uiCoordinator as any,
6557
- })
6558
- ).resolves.toEqual({ address: '0x1' });
6559
-
6560
- expect(calls).toEqual(['status', 'method-prompt', 'run']);
6561
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6562
- expect(device.unlockDevice).not.toHaveBeenCalled();
6563
- expect(method.run).toHaveBeenCalledTimes(1);
6487
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
+ message: 'ok',
6489
+ });
6490
+ expect(device.unlockDevice).toHaveBeenCalledWith();
6491
+ expect(calls).toEqual([
6492
+ 'unlock-main',
6493
+ 'negotiate-session',
6494
+ 'resume-hidden-session',
6495
+ 'validate-hidden-session',
6496
+ 'run',
6497
+ ]);
6564
6498
  });
6565
6499
 
6566
- test('validates the fresh device identity before starting unlock', async () => {
6567
- const calls: string[] = [];
6568
- const identityError = new Error('Unexpected device');
6500
+ test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6569
6501
  const method = {
6570
- name: 'evmSignMessage',
6571
- unlockPolicy: 'none',
6502
+ name: 'evmGetAddress',
6503
+ payload: { useEmptyPassphrase: true },
6572
6504
  useDevicePassphraseState: true,
6573
- run: jest.fn(),
6505
+ unlockPolicy: 'retry-on-locked',
6506
+ run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6574
6507
  };
6575
- const features = { unlocked: false };
6576
6508
  const device = {
6577
- features,
6578
- commands: {
6579
- typedCall: jest.fn(() => {
6580
- calls.push('status');
6581
- return Promise.resolve({ message: { unlocked: false } });
6582
- }),
6583
- },
6509
+ features: { unlocked: false },
6510
+ passphraseState: 'stale-hidden-state',
6584
6511
  isProtocolV2: () => true,
6585
- isBootloader: () => false,
6586
- isRomloader: () => false,
6587
- updateProtocolV2Status: jest.fn(() => features),
6588
- unlockDevice: jest.fn(),
6512
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6589
6513
  };
6590
6514
 
6591
- await expect(
6592
- runMethodWithUnlockPolicy(method as any, device as any, {
6593
- afterStatusBeforeUnlock: () => {
6594
- calls.push('identity');
6595
- throw identityError;
6596
- },
6597
- })
6598
- ).rejects.toBe(identityError);
6599
-
6600
- expect(calls).toEqual(['status', 'identity']);
6601
- expect(device.unlockDevice).not.toHaveBeenCalled();
6602
- expect(method.run).not.toHaveBeenCalled();
6515
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
+ address: 'standard-wallet-address',
6517
+ });
6518
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
+ expect(method.run).toHaveBeenCalledTimes(1);
6603
6520
  });
6604
6521
 
6605
- test('unlocks before business and never replays the callback', async () => {
6522
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6606
6523
  const calls: string[] = [];
6607
- const features = { unlocked: true };
6608
6524
  const method = {
6609
- name: 'evmSignMessage',
6525
+ name: 'deviceSettingsPageShow',
6610
6526
  unlockPolicy: 'unlock-before-run',
6611
- protocolV2UiInteraction: { reason: 'signing-confirmation' },
6527
+ protocolV2UiInteraction: { reason: 'settings-page' },
6612
6528
  run: jest.fn(() => {
6613
6529
  calls.push('run');
6614
- return Promise.resolve({ signature: 'signed' });
6530
+ return Promise.resolve({ message: 'ok' });
6615
6531
  }),
6616
6532
  };
6533
+ const features = { unlocked: true };
6617
6534
  const device = {
6618
6535
  features,
6619
6536
  commands: {
6620
6537
  typedCall: jest.fn(() => {
6621
- calls.push('status');
6538
+ calls.push('refresh-status');
6622
6539
  return Promise.resolve({ message: { unlocked: false } });
6623
6540
  }),
6624
6541
  },
6625
6542
  isProtocolV2: () => true,
6626
- isBootloader: () => false,
6627
- isRomloader: () => false,
6628
6543
  updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6629
- features.unlocked = status.unlocked === true;
6544
+ features.unlocked = status.unlocked ?? features.unlocked;
6630
6545
  return features;
6631
6546
  }),
6632
6547
  unlockDevice: jest.fn(() => {
6633
6548
  calls.push('unlock');
6634
6549
  features.unlocked = true;
6635
- return Promise.resolve(features);
6550
+ return Promise.resolve();
6636
6551
  }),
6637
6552
  };
6638
6553
  const uiCoordinator = {
6639
6554
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6640
- enterUnlockInteraction: jest.fn(() => {
6641
- calls.push('unlock-prompt');
6642
- return undefined;
6643
- }),
6555
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6556
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6644
6557
  };
6645
6558
 
6646
6559
  await expect(
6647
- runMethodWithUnlockPolicy(method as any, device as any, {
6648
- uiCoordinator: uiCoordinator as any,
6649
- prepare: () => {
6650
- calls.push('wallet-session');
6651
- return Promise.resolve();
6652
- },
6653
- })
6654
- ).resolves.toEqual({ signature: 'signed' });
6655
-
6656
- expect(calls).toEqual([
6657
- 'status',
6658
- 'unlock-prompt',
6659
- 'unlock',
6660
- 'wallet-session',
6661
- 'method-prompt',
6662
- 'run',
6663
- ]);
6664
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6560
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6561
+ ).resolves.toEqual({ message: 'ok' });
6562
+ expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6563
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6665
6564
  expect(method.run).toHaveBeenCalledTimes(1);
6565
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6666
6567
  });
6667
6568
 
6668
- test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6669
- const method = {
6670
- name: 'evmGetAddress',
6671
- unlockPolicy: 'unlock-before-run',
6672
- run: jest.fn(),
6673
- };
6674
- const device = {
6675
- features: { unlocked: undefined },
6676
- commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6677
- isProtocolV2: () => true,
6678
- isBootloader: () => false,
6679
- isRomloader: () => false,
6680
- updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6681
- unlockDevice: jest.fn(),
6682
- };
6683
-
6684
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6685
- errorCode: HardwareErrorCode.RuntimeError,
6686
- });
6687
- expect(device.unlockDevice).not.toHaveBeenCalled();
6688
- expect(method.run).not.toHaveBeenCalled();
6689
- });
6690
-
6691
- test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6692
- const error = deviceLockedError();
6569
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
+ const features = { unlocked: false };
6693
6571
  const method = {
6694
- name: 'evmSignMessage',
6572
+ name: 'deviceSettings',
6695
6573
  unlockPolicy: 'unlock-before-run',
6696
- run: jest.fn().mockRejectedValue(error),
6574
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6697
6575
  };
6698
- const features = { unlocked: true };
6699
6576
  const device = {
6700
6577
  features,
6701
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6578
+ commands: {
6579
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
+ },
6702
6581
  isProtocolV2: () => true,
6703
- isBootloader: () => false,
6704
- isRomloader: () => false,
6705
- updateProtocolV2Status: jest.fn(() => features),
6582
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
+ features.unlocked = status.unlocked ?? features.unlocked;
6584
+ return features;
6585
+ }),
6706
6586
  unlockDevice: jest.fn(),
6707
6587
  };
6708
6588
 
6709
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6589
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6590
+ message: 'ok',
6591
+ });
6710
6592
  expect(device.unlockDevice).not.toHaveBeenCalled();
6711
6593
  expect(method.run).toHaveBeenCalledTimes(1);
6712
6594
  });
6713
6595
 
6714
- test('reuses one lightweight preflight context across nested protected methods', async () => {
6715
- const features = { unlocked: true };
6716
- const context = createProtocolV2UnlockContext();
6717
- const firstMethod = {
6718
- name: 'allNetworkGetAddress',
6719
- unlockPolicy: 'unlock-before-run',
6720
- run: jest.fn().mockResolvedValue({ root: true }),
6721
- };
6722
- const nestedMethod = {
6723
- name: 'evmGetAddress',
6724
- unlockPolicy: 'unlock-before-run',
6725
- run: jest.fn().mockResolvedValue({ address: '0x1' }),
6726
- };
6727
- const device = {
6728
- features,
6729
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6730
- isProtocolV2: () => true,
6731
- isBootloader: () => false,
6732
- isRomloader: () => false,
6733
- updateProtocolV2Status: jest.fn(() => features),
6734
- unlockDevice: jest.fn(),
6735
- };
6736
-
6737
- await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
6738
- await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
6739
-
6740
- expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
6741
- expect(firstMethod.run).toHaveBeenCalledTimes(1);
6742
- expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6743
- });
6744
-
6745
6596
  test.each(['bootloader', 'romloader'] as const)(
6746
6597
  'skips DeviceStatus and unlock in Protocol V2 %s mode',
6747
6598
  async mode => {
@@ -6759,7 +6610,7 @@ describe('Protocol V2 protected method execution', () => {
6759
6610
  unlockDevice: jest.fn(),
6760
6611
  };
6761
6612
 
6762
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6613
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6763
6614
  message: 'updating',
6764
6615
  });
6765
6616
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6768,30 +6619,157 @@ describe('Protocol V2 protected method execution', () => {
6768
6619
  }
6769
6620
  );
6770
6621
 
6771
- test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6622
+ test('infers a signing interaction before running a Protocol V2 business method', async () => {
6623
+ const method = {
6624
+ name: 'evmSignMessage',
6625
+ params: { message: 'not-exposed-in-event' },
6626
+ payload: {},
6627
+ unlockPolicy: 'retry-on-locked',
6628
+ run: jest.fn().mockResolvedValue({ signature: 'test' }),
6629
+ };
6630
+ const device = {
6631
+ isProtocolV2: () => true,
6632
+ unlockDevice: jest.fn(),
6633
+ };
6634
+ const uiCoordinator = {
6635
+ enterMethodInteraction: jest.fn(),
6636
+ enterUnlockInteraction: jest.fn(),
6637
+ resumeMethodInteraction: jest.fn(),
6638
+ };
6639
+
6640
+ await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6641
+
6642
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6643
+ request: 'button',
6644
+ source: 'method-lifecycle',
6645
+ reason: 'signing-confirmation',
6646
+ completion: 'operation-completed',
6647
+ deviceOnly: true,
6648
+ operation: 'evmSignMessage',
6649
+ });
6650
+ });
6651
+
6652
+ test('does not unlock a Protocol V1 device or a method without the policy', async () => {
6772
6653
  for (const [isProtocolV2, unlockPolicy] of [
6773
- [false, 'unlock-before-run'],
6654
+ [false, 'retry-on-locked'],
6774
6655
  [true, 'none'],
6775
6656
  ] as const) {
6657
+ const error = deviceLockedError();
6776
6658
  const method = {
6777
6659
  unlockPolicy,
6778
- useDevicePassphraseState: false,
6779
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6660
+ run: jest.fn().mockRejectedValue(error),
6780
6661
  };
6781
6662
  const device = {
6782
- commands: { typedCall: jest.fn() },
6783
6663
  isProtocolV2: () => isProtocolV2,
6784
6664
  unlockDevice: jest.fn(),
6785
6665
  };
6786
6666
 
6787
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6788
- message: 'ok',
6789
- });
6790
- expect(device.commands.typedCall).not.toHaveBeenCalled();
6667
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6791
6668
  expect(device.unlockDevice).not.toHaveBeenCalled();
6792
6669
  expect(method.run).toHaveBeenCalledTimes(1);
6793
6670
  }
6794
6671
  });
6672
+
6673
+ test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6674
+ const method = {
6675
+ name: 'uploadPortfolio',
6676
+ unlockPolicy: 'none',
6677
+ protocolV2UiMode: 'none',
6678
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6679
+ };
6680
+ const device = {
6681
+ features: { unlocked: false },
6682
+ isProtocolV2: () => true,
6683
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6684
+ };
6685
+ const uiCoordinator = {
6686
+ enterMethodInteraction: jest.fn(),
6687
+ enterUnlockInteraction: jest.fn(),
6688
+ resumeMethodInteraction: jest.fn(),
6689
+ };
6690
+
6691
+ await expect(
6692
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6693
+ ).resolves.toEqual({ message: 'ok' });
6694
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6695
+ expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6696
+ expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6697
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6698
+ });
6699
+
6700
+ test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6701
+ const error = deviceLockedError();
6702
+ const method = {
6703
+ name: 'uploadPortfolio',
6704
+ unlockPolicy: 'none',
6705
+ run: jest.fn().mockRejectedValue(error),
6706
+ };
6707
+ const device = {
6708
+ features: { unlocked: true },
6709
+ isProtocolV2: () => true,
6710
+ unlockDevice: jest.fn(),
6711
+ };
6712
+
6713
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6714
+ expect(method.run).toHaveBeenCalledTimes(1);
6715
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6716
+ });
6717
+
6718
+ test('does not retry when unlock fails or when the retry is still locked', async () => {
6719
+ const initialError = deviceLockedError();
6720
+ const unlockError = new Error('PIN cancelled');
6721
+ const unlockFailMethod = {
6722
+ unlockPolicy: 'retry-on-locked',
6723
+ run: jest.fn().mockRejectedValue(initialError),
6724
+ };
6725
+ const unlockFailDevice = {
6726
+ isProtocolV2: () => true,
6727
+ unlockDevice: jest.fn().mockRejectedValue(unlockError),
6728
+ };
6729
+ const unlockFailCoordinator = {
6730
+ enterMethodInteraction: jest.fn(),
6731
+ enterUnlockInteraction: jest.fn(),
6732
+ resumeMethodInteraction: jest.fn(),
6733
+ };
6734
+
6735
+ await expect(
6736
+ runMethodWithUnlockRetry(
6737
+ unlockFailMethod as any,
6738
+ unlockFailDevice as any,
6739
+ unlockFailCoordinator as any
6740
+ )
6741
+ ).rejects.toBe(unlockError);
6742
+ expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6743
+ expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6744
+ expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6745
+
6746
+ const retryError = deviceLockedError();
6747
+ const retryFailMethod = {
6748
+ unlockPolicy: 'retry-on-locked',
6749
+ run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6750
+ };
6751
+ const retryFailDevice = {
6752
+ isProtocolV2: () => true,
6753
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6754
+ };
6755
+ const retryFailCoordinator = {
6756
+ enterMethodInteraction: jest.fn(),
6757
+ enterUnlockInteraction: jest.fn(),
6758
+ resumeMethodInteraction: jest.fn(),
6759
+ };
6760
+
6761
+ await expect(
6762
+ runMethodWithUnlockRetry(
6763
+ retryFailMethod as any,
6764
+ retryFailDevice as any,
6765
+ retryFailCoordinator as any
6766
+ )
6767
+ ).rejects.toBe(retryError);
6768
+ expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6769
+ expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6770
+ expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6771
+ expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6772
+ });
6795
6773
  });
6796
6774
 
6797
6775
  describe('Protocol V2 current low-level methods', () => {
@@ -6923,9 +6901,7 @@ describe('Protocol V2 current low-level methods', () => {
6923
6901
 
6924
6902
  await method.run();
6925
6903
 
6926
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
6927
- eventless_wallet_session: true,
6928
- });
6904
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
6929
6905
  });
6930
6906
 
6931
6907
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {