@onekeyfe/hd-core 1.2.2-alpha.8 → 1.2.2

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 (81) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +503 -10
  3. package/__tests__/DeviceCommands.test.ts +254 -1
  4. package/__tests__/core-error-output.test.ts +169 -1
  5. package/__tests__/device-lifecycle-events.test.ts +432 -15
  6. package/__tests__/logBlockEvent.test.ts +45 -122
  7. package/__tests__/open-wallet-session-error-response.test.ts +2 -2
  8. package/__tests__/open-wallet-session.test.ts +94 -363
  9. package/__tests__/protocol-v2.test.ts +132 -2
  10. package/__tests__/public-device-state-api.test.ts +2 -7
  11. package/__tests__/search-devices.test.ts +196 -8
  12. package/__tests__/sol-sign-offchain-message.test.ts +64 -0
  13. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  14. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  15. package/dist/api/GetFeatures.d.ts.map +1 -1
  16. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  17. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  18. package/dist/api/SearchDevices.d.ts +2 -15
  19. package/dist/api/SearchDevices.d.ts.map +1 -1
  20. package/dist/api/UploadPortfolio.d.ts +1 -0
  21. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  22. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts +2 -0
  23. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts.map +1 -1
  24. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +7 -1
  25. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  26. package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
  27. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  28. package/dist/api/solana/SolSignOffchainMessage.d.ts.map +1 -1
  29. package/dist/core/RequestQueue.d.ts +3 -0
  30. package/dist/core/RequestQueue.d.ts.map +1 -1
  31. package/dist/core/index.d.ts +3 -1
  32. package/dist/core/index.d.ts.map +1 -1
  33. package/dist/core/uiPromiseRegistry.d.ts +1 -1
  34. package/dist/data-manager/TransportManager.d.ts +2 -0
  35. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  36. package/dist/device/Device.d.ts +2 -0
  37. package/dist/device/Device.d.ts.map +1 -1
  38. package/dist/device/DeviceCommands.d.ts +5 -4
  39. package/dist/device/DeviceCommands.d.ts.map +1 -1
  40. package/dist/events/logBlockEvent.d.ts.map +1 -1
  41. package/dist/index.d.ts +35 -30
  42. package/dist/index.js +620 -325
  43. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  44. package/dist/types/api/getFeatures.d.ts.map +1 -1
  45. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  46. package/dist/types/api/openWalletSession.d.ts +1 -10
  47. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  48. package/dist/types/api/protocolV2.d.ts +3 -4
  49. package/dist/types/api/protocolV2.d.ts.map +1 -1
  50. package/dist/types/api/solSignOffchainMessage.d.ts +1 -0
  51. package/dist/types/api/solSignOffchainMessage.d.ts.map +1 -1
  52. package/dist/types/params.d.ts.map +1 -1
  53. package/dist/utils/patch.d.ts +1 -1
  54. package/dist/utils/patch.d.ts.map +1 -1
  55. package/package.json +4 -4
  56. package/src/api/FirmwareUpdateV4.ts +9 -4
  57. package/src/api/GetFeatures.ts +1 -0
  58. package/src/api/GetPassphraseState.ts +1 -0
  59. package/src/api/OpenWalletSession.ts +7 -77
  60. package/src/api/SearchDevices.ts +121 -27
  61. package/src/api/UploadPortfolio.ts +5 -4
  62. package/src/api/allnetwork/AllNetworkGetAddress.ts +79 -45
  63. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +95 -24
  64. package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
  65. package/src/api/device/DeviceVerify.ts +8 -0
  66. package/src/api/solana/SolSignOffchainMessage.ts +44 -4
  67. package/src/core/RequestQueue.ts +36 -1
  68. package/src/core/index.ts +178 -63
  69. package/src/data/messages/messages-protocol-v2.json +49 -33
  70. package/src/data/messages/messages.json +8 -5
  71. package/src/data-manager/TransportManager.ts +14 -3
  72. package/src/device/Device.ts +58 -27
  73. package/src/device/DeviceCommands.ts +29 -2
  74. package/src/events/logBlockEvent.ts +6 -75
  75. package/src/protocols/protocol-v2/walletSession.ts +18 -2
  76. package/src/types/api/getFeatures.ts +2 -1
  77. package/src/types/api/getPassphraseState.ts +2 -5
  78. package/src/types/api/openWalletSession.ts +7 -19
  79. package/src/types/api/protocolV2.ts +3 -4
  80. package/src/types/api/solSignOffchainMessage.ts +2 -0
  81. package/src/types/params.ts +7 -0
@@ -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,5 +1,11 @@
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';
7
+ import SearchDevices from '../src/api/SearchDevices';
8
+ import { DeviceList } from '../src/device/DeviceList';
3
9
 
4
10
  jest.mock('../src/data/config', () => ({
5
11
  getSDKVersion: jest.fn(() => '1.0.0-test'),
@@ -9,12 +15,174 @@ jest.mock('../src/data/config', () => ({
9
15
  jest.mock('../src/data-manager/TransportManager', () => ({
10
16
  __esModule: true,
11
17
  default: {
18
+ load: jest.fn(),
12
19
  configure: jest.fn().mockResolvedValue(undefined),
13
20
  getTransport: jest.fn(() => undefined),
14
21
  },
15
22
  }));
16
23
 
17
24
  describe('Core 错误输出边界', () => {
25
+ afterEach(() => {
26
+ jest.restoreAllMocks();
27
+ });
28
+
29
+ test.each([
30
+ ['webusb', false],
31
+ ['desktop-webusb', false],
32
+ ['desktop-webusb', true],
33
+ ] as const)(
34
+ '%s waits for discovery without masking initialization or cancellation (cancel=%s)',
35
+ async (env, shouldCancel) => {
36
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
37
+ const error = ERRORS.TypedError(HardwareErrorCode.DeviceInitializeFailed, 'probe failed');
38
+ let finishSearch!: () => void;
39
+ let searchStarted!: () => void;
40
+ const started = new Promise<void>(resolve => {
41
+ searchStarted = resolve;
42
+ });
43
+ const search = jest.spyOn(SearchDevices.prototype, 'run').mockImplementation(async () => {
44
+ searchStarted();
45
+ await new Promise<void>(resolve => {
46
+ finishSearch = resolve;
47
+ });
48
+ return [];
49
+ });
50
+ const initialize = jest
51
+ .spyOn(DeviceList.prototype, 'getDeviceLists')
52
+ .mockRejectedValue(error);
53
+ const core = initCore();
54
+ initConnector();
55
+ try {
56
+ const discovery = core.handleMessage({
57
+ id: 10,
58
+ type: IFRAME.CALL,
59
+ payload: { method: 'searchDevices' },
60
+ } as never);
61
+ await started;
62
+ const request = core.handleMessage({
63
+ id: 11,
64
+ type: IFRAME.CALL,
65
+ payload: {
66
+ method: 'getDeviceState',
67
+ connectId: 'serial-V2',
68
+ retryCount: 1,
69
+ pollIntervalTime: 1,
70
+ timeout: 1000,
71
+ },
72
+ } as never);
73
+ await new Promise(resolve => {
74
+ setTimeout(resolve, 0);
75
+ });
76
+ expect(initialize).not.toHaveBeenCalled();
77
+ if (shouldCancel) {
78
+ await core.handleMessage({
79
+ type: IFRAME.CANCEL,
80
+ payload: { connectId: 'serial-V2' },
81
+ } as never);
82
+ }
83
+ finishSearch();
84
+ await expect(discovery).resolves.toMatchObject({ success: true });
85
+ const expectedError = shouldCancel
86
+ ? ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled)
87
+ : error;
88
+ await expect(request).resolves.toMatchObject({
89
+ success: false,
90
+ payload: { code: expectedError.errorCode, error: expectedError.message },
91
+ });
92
+ await new Promise(resolve => {
93
+ setTimeout(resolve, 0);
94
+ });
95
+ expect(search).toHaveBeenCalledTimes(1);
96
+ expect(initialize).toHaveBeenCalledTimes(shouldCancel ? 0 : 2);
97
+ } finally {
98
+ finishSearch?.();
99
+ await core.dispose();
100
+ }
101
+ }
102
+ );
103
+
104
+ test.each([
105
+ HardwareErrorCode.BleDeviceNotBonded,
106
+ HardwareErrorCode.BleDeviceBondedCanceled,
107
+ HardwareErrorCode.BleDeviceDisconnected,
108
+ HardwareErrorCode.PollingTimeout,
109
+ ])(
110
+ 'preserves terminal BLE error %s in the public response without polling again',
111
+ async errorCode => {
112
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
113
+ const error = ERRORS.TypedError(errorCode);
114
+ const acquire = jest.fn().mockRejectedValue(error);
115
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue({
116
+ acquire,
117
+ release: jest.fn().mockResolvedValue(true),
118
+ stop: jest.fn().mockResolvedValue(undefined),
119
+ } as never);
120
+ const core = initCore();
121
+ initConnector();
122
+
123
+ try {
124
+ const response = await core.handleMessage({
125
+ id: 1,
126
+ event: IFRAME.CALL,
127
+ type: IFRAME.CALL,
128
+ payload: {
129
+ method: 'getDeviceState',
130
+ connectId: 'ble-pairing-test',
131
+ forceProtocolDetection: true,
132
+ retryCount: 1,
133
+ pollIntervalTime: 1,
134
+ timeout: 1000,
135
+ },
136
+ } as never);
137
+
138
+ expect(response).toMatchObject({
139
+ success: false,
140
+ payload: { code: errorCode, error: error.message },
141
+ });
142
+ expect(acquire).toHaveBeenCalledTimes(1);
143
+ } finally {
144
+ await core.dispose();
145
+ }
146
+ }
147
+ );
148
+
149
+ test('desktop BLE still polls after a transient disconnect instead of failing immediately', async () => {
150
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('desktop-web-ble' as never);
151
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
152
+ const acquire = jest.fn().mockRejectedValue(error);
153
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue({
154
+ acquire,
155
+ release: jest.fn().mockResolvedValue(true),
156
+ stop: jest.fn().mockResolvedValue(undefined),
157
+ } as never);
158
+ const core = initCore();
159
+ initConnector();
160
+
161
+ try {
162
+ const response = await core.handleMessage({
163
+ id: 1,
164
+ event: IFRAME.CALL,
165
+ type: IFRAME.CALL,
166
+ payload: {
167
+ method: 'getDeviceState',
168
+ connectId: 'desktop-ble-disconnect-test',
169
+ forceProtocolDetection: true,
170
+ retryCount: 1,
171
+ pollIntervalTime: 1,
172
+ timeout: 1000,
173
+ },
174
+ } as never);
175
+
176
+ expect(response).toMatchObject({
177
+ success: false,
178
+ payload: { code: HardwareErrorCode.DeviceNotFound },
179
+ });
180
+ expect(acquire.mock.calls.length).toBeGreaterThan(1);
181
+ } finally {
182
+ await core.dispose();
183
+ }
184
+ });
185
+
18
186
  test('连接失败只返回结构化错误,不直接写入 stdout', async () => {
19
187
  const stdout = jest.spyOn(console, 'log').mockImplementation(() => undefined);
20
188
  const core = initCore();