@onekeyfe/hd-core 1.2.2-alpha.11 → 1.2.2-alpha.110

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 (65) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +155 -2
  3. package/__tests__/DeviceCommands.test.ts +254 -1
  4. package/__tests__/core-error-output.test.ts +92 -1
  5. package/__tests__/device-lifecycle-events.test.ts +379 -15
  6. package/__tests__/open-wallet-session-error-response.test.ts +2 -2
  7. package/__tests__/open-wallet-session.test.ts +8 -411
  8. package/__tests__/protocol-v2.test.ts +86 -0
  9. package/__tests__/public-device-state-api.test.ts +2 -7
  10. package/__tests__/sol-sign-offchain-message.test.ts +64 -0
  11. package/dist/api/GetFeatures.d.ts.map +1 -1
  12. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  13. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  14. package/dist/api/UploadPortfolio.d.ts +1 -0
  15. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  16. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +3 -0
  17. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  18. package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
  19. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  20. package/dist/api/solana/SolSignOffchainMessage.d.ts.map +1 -1
  21. package/dist/core/RequestQueue.d.ts +1 -0
  22. package/dist/core/RequestQueue.d.ts.map +1 -1
  23. package/dist/core/index.d.ts +3 -1
  24. package/dist/core/index.d.ts.map +1 -1
  25. package/dist/core/uiPromiseRegistry.d.ts +1 -1
  26. package/dist/device/Device.d.ts +2 -0
  27. package/dist/device/Device.d.ts.map +1 -1
  28. package/dist/device/DeviceCommands.d.ts +5 -4
  29. package/dist/device/DeviceCommands.d.ts.map +1 -1
  30. package/dist/index.d.ts +35 -30
  31. package/dist/index.js +352 -188
  32. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  33. package/dist/types/api/getFeatures.d.ts.map +1 -1
  34. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  35. package/dist/types/api/openWalletSession.d.ts +1 -10
  36. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  37. package/dist/types/api/protocolV2.d.ts +3 -4
  38. package/dist/types/api/protocolV2.d.ts.map +1 -1
  39. package/dist/types/api/solSignOffchainMessage.d.ts +1 -0
  40. package/dist/types/api/solSignOffchainMessage.d.ts.map +1 -1
  41. package/dist/types/params.d.ts.map +1 -1
  42. package/dist/utils/patch.d.ts +1 -1
  43. package/dist/utils/patch.d.ts.map +1 -1
  44. package/package.json +4 -4
  45. package/src/api/GetFeatures.ts +1 -0
  46. package/src/api/GetPassphraseState.ts +1 -0
  47. package/src/api/OpenWalletSession.ts +7 -77
  48. package/src/api/UploadPortfolio.ts +5 -4
  49. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +56 -15
  50. package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
  51. package/src/api/device/DeviceVerify.ts +8 -0
  52. package/src/api/solana/SolSignOffchainMessage.ts +44 -4
  53. package/src/core/RequestQueue.ts +20 -0
  54. package/src/core/index.ts +116 -49
  55. package/src/data/messages/messages-protocol-v2.json +49 -33
  56. package/src/data/messages/messages.json +8 -5
  57. package/src/device/Device.ts +51 -27
  58. package/src/device/DeviceCommands.ts +29 -2
  59. package/src/protocols/protocol-v2/walletSession.ts +7 -0
  60. package/src/types/api/getFeatures.ts +2 -1
  61. package/src/types/api/getPassphraseState.ts +2 -5
  62. package/src/types/api/openWalletSession.ts +7 -19
  63. package/src/types/api/protocolV2.ts +3 -4
  64. package/src/types/api/solSignOffchainMessage.ts +2 -0
  65. package/src/types/params.ts +7 -0
package/README.md CHANGED
@@ -26,7 +26,7 @@ import Core from '@onekeyfe/hd-core';
26
26
 
27
27
  ## Docs
28
28
 
29
- Documentation is available [hardware-js-sdk](https://developer.onekey.so/connect-to-hardware/hardware-sdk/start)
29
+ Documentation is available [Hardware SDK Getting Started](https://developer.onekey.so/en/hardware-sdk/getting-started)
30
30
 
31
31
  ## Examples
32
32
  // TODO: add example url
@@ -1,5 +1,8 @@
1
+ import { EDeviceType } from '@onekeyfe/hd-shared';
2
+
1
3
  import AllNetworkGetAddressBase from '../src/api/allnetwork/AllNetworkGetAddressBase';
2
4
  import AllNetworkGetAddress from '../src/api/allnetwork/AllNetworkGetAddress';
5
+ import AllNetworkGetAddressByLoop from '../src/api/allnetwork/AllNetworkGetAddressByLoop';
3
6
  import { findMethod } from '../src/api/utils';
4
7
  import { getActiveRequestsByDeviceInstance } from '../src/utils/tracing';
5
8
 
@@ -18,11 +21,161 @@ class TestAllNetworkMethod extends AllNetworkGetAddressBase {
18
21
  }
19
22
  }
20
23
 
24
+ describe('Pro2 all-network loading lifecycle', () => {
25
+ function setup(callback = false) {
26
+ const Method = callback ? AllNetworkGetAddressByLoop : AllNetworkGetAddress;
27
+ const method = new Method({
28
+ id: 1,
29
+ payload: {
30
+ method: callback ? 'allNetworkGetAddressByLoop' : 'allNetworkGetAddress',
31
+ connectId: 'test-device',
32
+ callbackId: 'item',
33
+ callbackIdFinish: 'finish',
34
+ bundle: [
35
+ { network: 'evm', path: "m/44'/60'/0'/0/0" },
36
+ { network: 'sol', path: "m/44'/501'/0'" },
37
+ ],
38
+ },
39
+ });
40
+ const calls: string[] = [];
41
+ const typedCall = jest.fn((type: string, _response: string, message: { action?: number }) => {
42
+ calls.push(type === 'DeviceAnimationControl' ? `animation:${message.action}` : type);
43
+ return Promise.resolve({ message: { root_fingerprint: 0 } });
44
+ });
45
+ method.device = {
46
+ commands: { typedCall, disposed: false },
47
+ isProtocolV2: jest.fn(() => true),
48
+ getCurrentDeviceType: jest.fn(() => EDeviceType.Pro2),
49
+ ensureProtocolV2RuntimeContext: jest.fn(() =>
50
+ Promise.resolve({ supported_messages: [60461] })
51
+ ),
52
+ } as any;
53
+ method.postMessage = jest.fn();
54
+ method.context = {
55
+ registerCallbackTask: jest.fn(),
56
+ cancelCallbackTasks: jest.fn(() => method.device.pendingCallbackPromise?.resolve()),
57
+ } as any;
58
+ const callMethod = jest.spyOn(method, 'callMethod').mockImplementation(name => {
59
+ calls.push(name);
60
+ return Promise.resolve([{ success: true, payload: {} }] as any);
61
+ });
62
+ return { method, calls, typedCall, callMethod };
63
+ }
64
+
65
+ test('keeps one session across fingerprint and all chain requests', async () => {
66
+ const { method, calls } = setup();
67
+ await method.run();
68
+ expect(calls).toEqual([
69
+ 'animation:1',
70
+ 'GetPublicKey',
71
+ 'evmGetAddress',
72
+ 'solGetAddress',
73
+ 'animation:2',
74
+ ]);
75
+ });
76
+
77
+ test.each(['V1', 'unsupported', 'Neo'])('does not change %s device behavior', async variant => {
78
+ const { method, calls } = setup();
79
+ if (variant === 'V1') jest.spyOn(method.device, 'isProtocolV2').mockReturnValue(false);
80
+ if (variant === 'Neo')
81
+ jest.spyOn(method.device, 'getCurrentDeviceType').mockReturnValue(EDeviceType.Neo);
82
+ if (variant === 'unsupported') {
83
+ jest
84
+ .spyOn(method.device, 'ensureProtocolV2RuntimeContext')
85
+ .mockResolvedValue({ supported_messages: [] } as any);
86
+ }
87
+ await method.run();
88
+ expect(calls).toEqual(['GetPublicKey', 'evmGetAddress', 'solGetAddress']);
89
+ });
90
+
91
+ test('does not reconnect for cleanup after chain failure', async () => {
92
+ const { method, calls, callMethod } = setup();
93
+ const error = new Error('chain failed');
94
+ callMethod.mockRejectedValue(error);
95
+ await expect(method.run()).rejects.toBe(error);
96
+ expect(calls).toEqual(['animation:1', 'GetPublicKey']);
97
+ });
98
+
99
+ test('does not replace a successful result when Stop fails', async () => {
100
+ const { method, calls, typedCall } = setup();
101
+ typedCall.mockResolvedValueOnce({ message: { root_fingerprint: 0 } });
102
+ typedCall.mockResolvedValueOnce({ message: { root_fingerprint: 0 } });
103
+ typedCall.mockImplementationOnce(() => {
104
+ calls.push('stop-failed');
105
+ return Promise.reject(new Error('cleanup failed'));
106
+ });
107
+ await expect(method.run()).resolves.toHaveLength(2);
108
+ expect(calls).toEqual(['evmGetAddress', 'solGetAddress', 'stop-failed']);
109
+ });
110
+
111
+ test('does not continue wallet commands when starting the session fails', async () => {
112
+ const { method, typedCall, callMethod } = setup();
113
+ const error = new Error('link disconnected');
114
+ typedCall.mockRejectedValueOnce(error);
115
+ await expect(method.run()).rejects.toBe(error);
116
+ expect(typedCall).toHaveBeenCalledTimes(1);
117
+ expect(callMethod).not.toHaveBeenCalled();
118
+ });
119
+
120
+ test.each(['success', 'failure', 'cancel'])(
121
+ 'keeps callback loading until background %s',
122
+ async outcome => {
123
+ const { method, calls, callMethod } = setup(true);
124
+ let finishChain: () => void = () => {};
125
+ const chainPending = new Promise<void>(resolve => {
126
+ finishChain = resolve;
127
+ });
128
+ callMethod.mockImplementationOnce(async () => {
129
+ calls.push('chain-pending');
130
+ await chainPending;
131
+ if (outcome === 'failure') throw new Error('chain failed');
132
+ if (outcome === 'cancel') method.abortController?.abort();
133
+ return [{ success: true, payload: {} }] as any;
134
+ });
135
+ await expect(method.run()).resolves.toEqual([]);
136
+ expect(calls).toEqual(['animation:1', 'GetPublicKey', 'chain-pending']);
137
+ finishChain();
138
+ await method.device.pendingCallbackPromise?.promise;
139
+ expect(calls.filter(call => call === 'animation:2')).toHaveLength(
140
+ outcome === 'success' ? 1 : 0
141
+ );
142
+ expect(method.postMessage).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ payload: expect.objectContaining({ callbackId: 'finish' }),
145
+ })
146
+ );
147
+ }
148
+ );
149
+
150
+ test('does not send further commands when the fingerprint response is invalid', async () => {
151
+ const { method, typedCall, callMethod } = setup();
152
+ typedCall.mockResolvedValueOnce({ message: { root_fingerprint: 0 } });
153
+ typedCall.mockResolvedValueOnce({ message: {} } as any);
154
+ await expect(method.run()).rejects.toThrow();
155
+ expect(callMethod).not.toHaveBeenCalled();
156
+ expect(typedCall).toHaveBeenCalledTimes(2);
157
+ });
158
+
159
+ test.each(['disposed', 'replaced'])(
160
+ 'does not send cleanup through a %s connection',
161
+ async state => {
162
+ const { method, calls, callMethod } = setup();
163
+ callMethod.mockImplementation(() => {
164
+ if (state === 'disposed') method.device.commands.disposed = true;
165
+ else method.device.commands = { typedCall: jest.fn() } as any;
166
+ return Promise.resolve([{ success: true, payload: {} }] as any);
167
+ });
168
+ await method.run();
169
+ expect(calls).toEqual(['animation:1', 'GetPublicKey']);
170
+ }
171
+ );
172
+ });
173
+
21
174
  describe('AllNetworkGetAddressBase tracing', () => {
22
175
  test('resumes a Protocol V2 hidden wallet before running a nested chain method', async () => {
23
176
  const calls: string[] = [];
24
177
  const checkPassphraseStateSafety = jest.fn().mockImplementation(() => {
25
- calls.push('resume-hidden-session');
178
+ calls.push('restore-wallet-session');
26
179
  return Promise.resolve(true);
27
180
  });
28
181
  const innerMethod = {
@@ -98,7 +251,7 @@ describe('AllNetworkGetAddressBase tracing', () => {
98
251
  undefined,
99
252
  undefined
100
253
  );
101
- expect(calls).toEqual(['resume-hidden-session', 'run-chain-method']);
254
+ expect(calls).toEqual(['restore-wallet-session', 'run-chain-method']);
102
255
  expect(typedCall).not.toHaveBeenCalled();
103
256
  });
104
257
 
@@ -1,5 +1,9 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { DeviceSessionErrorCode } from '@onekeyfe/hd-transport';
2
3
 
4
+ import { DataManager } from '../src/data-manager';
5
+ import TransportManager from '../src/data-manager/TransportManager';
6
+ import { Device } from '../src/device/Device';
3
7
  import { DeviceCommands } from '../src/device/DeviceCommands';
4
8
  import { DEVICE } from '../src/events';
5
9
  import { LoggerNames, getLogger } from '../src/utils';
@@ -807,6 +811,255 @@ describe('DeviceCommands Protocol V2 interactive response compatibility', () =>
807
811
  });
808
812
  });
809
813
 
814
+ describe('DeviceCommands Protocol V2 session busy retry', () => {
815
+ const busy = {
816
+ type: 'Failure',
817
+ message: {
818
+ code: 'Failure_ProcessError',
819
+ subcode: DeviceSessionErrorCode.DeviceSessionError_Busy,
820
+ message: 'Another flow in progress',
821
+ },
822
+ } as const;
823
+ const success = { type: 'Success', message: {} } as const;
824
+ const flushCalls = () =>
825
+ new Promise<void>(resolve => {
826
+ setImmediate(resolve);
827
+ });
828
+
829
+ beforeEach(() => {
830
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
831
+ });
832
+
833
+ afterEach(() => {
834
+ jest.useRealTimers();
835
+ jest.restoreAllMocks();
836
+ });
837
+
838
+ const setup = () => {
839
+ const commands = createCommands();
840
+ commands.device.wasInterruptedByUser = jest.fn(() => false);
841
+ commands.mainId = 'main-id';
842
+ const call = jest.fn();
843
+ commands.transport = { call } as DeviceCommands['transport'];
844
+ return { commands, call };
845
+ };
846
+
847
+ it.each(['DeviceSessionGet', 'DeviceSessionAskPin', 'DeviceSessionAskPassphrase'] as const)(
848
+ 'retries %s busy responses at 100 ms intervals without matching their message',
849
+ async request => {
850
+ const { commands, call } = setup();
851
+ const response =
852
+ request === 'DeviceSessionGet' ? { type: 'DeviceSession' as const, message: {} } : success;
853
+ call
854
+ .mockResolvedValueOnce(busy)
855
+ .mockResolvedValueOnce({
856
+ type: 'Failure',
857
+ message: { ...busy.message, message: 'Passphrase entry busy' },
858
+ })
859
+ .mockResolvedValueOnce({
860
+ type: 'Failure',
861
+ message: { ...busy.message, message: 'Passphrase confirm busy' },
862
+ })
863
+ .mockResolvedValue(response);
864
+ const payload = request === 'DeviceSessionAskPassphrase' ? { on_device: true } : {};
865
+ const result = commands.typedCall(request, response.type, payload).catch(error => error);
866
+ await flushCalls();
867
+
868
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
869
+ jest.advanceTimersByTime(99);
870
+ await flushCalls();
871
+ expect(call).toHaveBeenCalledTimes(attempt);
872
+ jest.advanceTimersByTime(1);
873
+ await flushCalls();
874
+ expect(call).toHaveBeenCalledTimes(attempt + 1);
875
+ }
876
+
877
+ await expect(result).resolves.toEqual(response);
878
+ expect(call.mock.calls).toEqual(
879
+ Array.from({ length: 4 }, () => [
880
+ commands.mainId,
881
+ request,
882
+ payload,
883
+ { expectedTypes: [response.type] },
884
+ ])
885
+ );
886
+ expect(jest.getTimerCount()).toBe(0);
887
+ }
888
+ );
889
+
890
+ it('preserves the final busy error after three retries', async () => {
891
+ const { commands, call } = setup();
892
+ call.mockResolvedValue(busy);
893
+ const result = commands.typedCall('DeviceSessionGet', 'DeviceSession').catch(error => error);
894
+ await flushCalls();
895
+ for (let retry = 0; retry < 3; retry += 1) {
896
+ jest.advanceTimersByTime(100);
897
+ await flushCalls();
898
+ }
899
+
900
+ expect(call).toHaveBeenCalledTimes(4);
901
+ await expect(result).resolves.toMatchObject({
902
+ errorCode: HardwareErrorCode.DeviceBusy,
903
+ params: {
904
+ failureCode: 'Failure_ProcessError',
905
+ subcode: DeviceSessionErrorCode.DeviceSessionError_Busy,
906
+ firmwareMessage: 'Another flow in progress',
907
+ },
908
+ });
909
+ expect(jest.getTimerCount()).toBe(0);
910
+ });
911
+
912
+ it('does not delay a successful session request', async () => {
913
+ const { commands, call } = setup();
914
+ call.mockResolvedValue(success);
915
+ await expect(commands.typedCall('DeviceSessionAskPin', 'Success')).resolves.toEqual(success);
916
+ expect(call).toHaveBeenCalledTimes(1);
917
+ expect(jest.getTimerCount()).toBe(0);
918
+ });
919
+
920
+ it.each([
921
+ ['DeviceSessionAskPin', 'Failure_ProcessError', 0],
922
+ [
923
+ 'DeviceSessionGet',
924
+ 'Failure_ProcessError',
925
+ DeviceSessionErrorCode.DeviceSessionError_InvalidSession,
926
+ ],
927
+ [
928
+ 'DeviceSessionAskPassphrase',
929
+ 'Failure_ProcessError',
930
+ DeviceSessionErrorCode.DeviceSessionError_UserCancelled,
931
+ ],
932
+ ['DeviceSessionGet', 'Failure_DataError', DeviceSessionErrorCode.DeviceSessionError_Busy],
933
+ ['DeviceSettingsSet', 'Failure_ProcessError', 5],
934
+ ['EthereumSignTx', 'Failure_ProcessError', 5],
935
+ ] as const)('does not retry %s with %s subcode %s', async (request, code, subcode) => {
936
+ const { commands, call } = setup();
937
+ call.mockResolvedValue({ type: 'Failure', message: { code, subcode, message: 'Busy' } });
938
+ await expect(commands.typedCall(request, 'Success')).rejects.toBeInstanceOf(Error);
939
+ expect(call).toHaveBeenCalledTimes(1);
940
+ expect(jest.getTimerCount()).toBe(0);
941
+ });
942
+
943
+ it('does not retry Protocol V1 busy responses', async () => {
944
+ const { commands, call } = setup();
945
+ commands.device.isProtocolV2 = () => false;
946
+ call.mockResolvedValue(busy);
947
+ await expect(commands.typedCall('DeviceSessionGet', 'DeviceSession')).rejects.toMatchObject({
948
+ errorCode: HardwareErrorCode.DeviceBusy,
949
+ });
950
+ expect(call).toHaveBeenCalledTimes(1);
951
+ expect(jest.getTimerCount()).toBe(0);
952
+ });
953
+
954
+ it('stops retrying when the next attempt fails at the transport layer', async () => {
955
+ const { commands, call } = setup();
956
+ const error = ERRORS.TypedError(HardwareErrorCode.BridgeDeviceDisconnected);
957
+ call.mockResolvedValueOnce(busy).mockRejectedValue(error);
958
+ const result = commands.typedCall('DeviceSessionGet', 'DeviceSession').catch(err => err);
959
+ await flushCalls();
960
+ jest.advanceTimersByTime(100);
961
+ await flushCalls();
962
+ await expect(result).resolves.toBe(error);
963
+ expect(call).toHaveBeenCalledTimes(2);
964
+ expect(jest.getTimerCount()).toBe(0);
965
+ });
966
+
967
+ it('stops a superseded retry when a React Native run reuses the commands', async () => {
968
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
969
+ const call = jest.fn().mockResolvedValueOnce(busy).mockResolvedValueOnce(busy);
970
+ call.mockResolvedValue(success);
971
+ const cancel = jest.fn();
972
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue({
973
+ call,
974
+ cancel,
975
+ } as DeviceCommands['transport']);
976
+ const device = Device.fromDescriptor({
977
+ id: 'ble-device',
978
+ path: 'ble-device',
979
+ name: 'OneKey Test',
980
+ debug: false,
981
+ commType: 'ble',
982
+ protocolType: 'V2',
983
+ });
984
+ const commands = new DeviceCommands(device, 'ble-device');
985
+ device.commands = commands;
986
+ const release = jest.spyOn(device, 'release');
987
+ const continued = jest.fn();
988
+ let staleCallResult: Promise<unknown> | undefined;
989
+ const staleRun = device
990
+ .run(
991
+ async () => {
992
+ const operation = commands.typedCall('DeviceSessionAskPassphrase', 'Success', {
993
+ on_device: true,
994
+ });
995
+ staleCallResult = operation.catch(error => error);
996
+ await operation;
997
+ continued();
998
+ },
999
+ { keepSession: true }
1000
+ )
1001
+ .catch(error => error);
1002
+ await flushCalls();
1003
+
1004
+ const nextRun = device.run(
1005
+ async () => {
1006
+ await commands.typedCall('DeviceSessionAskPin', 'Success');
1007
+ },
1008
+ { keepSession: true }
1009
+ );
1010
+ await flushCalls();
1011
+ expect(device.commands).toBe(commands);
1012
+ expect(commands.disposed).toBe(false);
1013
+ expect(cancel).toHaveBeenCalledTimes(1);
1014
+ expect(call).toHaveBeenCalledTimes(2);
1015
+
1016
+ jest.advanceTimersByTime(100);
1017
+ await flushCalls();
1018
+
1019
+ await expect(staleRun).resolves.toMatchObject({
1020
+ errorCode: HardwareErrorCode.DeviceInterruptedFromOutside,
1021
+ });
1022
+ await expect(staleCallResult).resolves.toMatchObject({
1023
+ errorCode: HardwareErrorCode.RuntimeError,
1024
+ });
1025
+ await expect(nextRun).resolves.toBeUndefined();
1026
+ expect(call.mock.calls.map(([, request]) => request)).toEqual([
1027
+ 'DeviceSessionAskPassphrase',
1028
+ 'DeviceSessionAskPin',
1029
+ 'DeviceSessionAskPin',
1030
+ ]);
1031
+ expect(continued).not.toHaveBeenCalled();
1032
+ expect(release).not.toHaveBeenCalled();
1033
+ expect(jest.getTimerCount()).toBe(0);
1034
+ });
1035
+
1036
+ it.each(['cancelled', 'disposed'] as const)(
1037
+ 'does not resend when %s during backoff',
1038
+ async state => {
1039
+ const { commands, call } = setup();
1040
+ call.mockResolvedValueOnce(busy).mockResolvedValue(success);
1041
+ const result = commands.typedCall('DeviceSessionAskPin', 'Success').catch(error => error);
1042
+ await flushCalls();
1043
+ if (state === 'cancelled') {
1044
+ commands.device.wasInterruptedByUser = () => true;
1045
+ } else {
1046
+ commands.disposed = true;
1047
+ }
1048
+ jest.advanceTimersByTime(100);
1049
+ await flushCalls();
1050
+
1051
+ await expect(result).resolves.toMatchObject({
1052
+ errorCode:
1053
+ state === 'cancelled'
1054
+ ? HardwareErrorCode.DeviceInterruptedFromUser
1055
+ : HardwareErrorCode.RuntimeError,
1056
+ });
1057
+ expect(call).toHaveBeenCalledTimes(1);
1058
+ expect(jest.getTimerCount()).toBe(0);
1059
+ }
1060
+ );
1061
+ });
1062
+
810
1063
  describe('DeviceCommands cancellation', () => {
811
1064
  it('stops waiting after the bounded cancellation timeout', async () => {
812
1065
  jest.useFakeTimers({ doNotFake: ['performance'] });
@@ -1,4 +1,8 @@
1
- import { initCore } from '../src/core';
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ import { initConnector, initCore } from '../src/core';
4
+ import { DataManager } from '../src/data-manager';
5
+ import TransportManager from '../src/data-manager/TransportManager';
2
6
  import { IFRAME } from '../src/events';
3
7
 
4
8
  jest.mock('../src/data/config', () => ({
@@ -9,12 +13,99 @@ jest.mock('../src/data/config', () => ({
9
13
  jest.mock('../src/data-manager/TransportManager', () => ({
10
14
  __esModule: true,
11
15
  default: {
16
+ load: jest.fn(),
12
17
  configure: jest.fn().mockResolvedValue(undefined),
13
18
  getTransport: jest.fn(() => undefined),
14
19
  },
15
20
  }));
16
21
 
17
22
  describe('Core 错误输出边界', () => {
23
+ afterEach(() => {
24
+ jest.restoreAllMocks();
25
+ });
26
+
27
+ test.each([
28
+ HardwareErrorCode.BleDeviceNotBonded,
29
+ HardwareErrorCode.BleDeviceBondedCanceled,
30
+ HardwareErrorCode.BleDeviceDisconnected,
31
+ HardwareErrorCode.PollingTimeout,
32
+ ])(
33
+ 'preserves terminal BLE error %s in the public response without polling again',
34
+ async errorCode => {
35
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
36
+ const error = ERRORS.TypedError(errorCode);
37
+ const acquire = jest.fn().mockRejectedValue(error);
38
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue({
39
+ acquire,
40
+ release: jest.fn().mockResolvedValue(true),
41
+ stop: jest.fn().mockResolvedValue(undefined),
42
+ } as never);
43
+ const core = initCore();
44
+ initConnector();
45
+
46
+ try {
47
+ const response = await core.handleMessage({
48
+ id: 1,
49
+ event: IFRAME.CALL,
50
+ type: IFRAME.CALL,
51
+ payload: {
52
+ method: 'getDeviceState',
53
+ connectId: 'ble-pairing-test',
54
+ forceProtocolDetection: true,
55
+ retryCount: 1,
56
+ pollIntervalTime: 1,
57
+ timeout: 1000,
58
+ },
59
+ } as never);
60
+
61
+ expect(response).toMatchObject({
62
+ success: false,
63
+ payload: { code: errorCode, error: error.message },
64
+ });
65
+ expect(acquire).toHaveBeenCalledTimes(1);
66
+ } finally {
67
+ await core.dispose();
68
+ }
69
+ }
70
+ );
71
+
72
+ test('desktop BLE still polls after a transient disconnect instead of failing immediately', async () => {
73
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('desktop-web-ble' as never);
74
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
75
+ const acquire = jest.fn().mockRejectedValue(error);
76
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue({
77
+ acquire,
78
+ release: jest.fn().mockResolvedValue(true),
79
+ stop: jest.fn().mockResolvedValue(undefined),
80
+ } as never);
81
+ const core = initCore();
82
+ initConnector();
83
+
84
+ try {
85
+ const response = await core.handleMessage({
86
+ id: 1,
87
+ event: IFRAME.CALL,
88
+ type: IFRAME.CALL,
89
+ payload: {
90
+ method: 'getDeviceState',
91
+ connectId: 'desktop-ble-disconnect-test',
92
+ forceProtocolDetection: true,
93
+ retryCount: 1,
94
+ pollIntervalTime: 1,
95
+ timeout: 1000,
96
+ },
97
+ } as never);
98
+
99
+ expect(response).toMatchObject({
100
+ success: false,
101
+ payload: { code: HardwareErrorCode.DeviceNotFound },
102
+ });
103
+ expect(acquire.mock.calls.length).toBeGreaterThan(1);
104
+ } finally {
105
+ await core.dispose();
106
+ }
107
+ });
108
+
18
109
  test('连接失败只返回结构化错误,不直接写入 stdout', async () => {
19
110
  const stdout = jest.spyOn(console, 'log').mockImplementation(() => undefined);
20
111
  const core = initCore();