@onekeyfe/hd-core 1.2.2-alpha.11 → 1.2.2-alpha.111
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.
- package/README.md +1 -1
- package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +155 -2
- package/__tests__/DeviceCommands.test.ts +254 -1
- package/__tests__/core-error-output.test.ts +92 -1
- package/__tests__/device-lifecycle-events.test.ts +379 -15
- package/__tests__/open-wallet-session-error-response.test.ts +2 -2
- package/__tests__/open-wallet-session.test.ts +8 -411
- package/__tests__/protocol-v2.test.ts +86 -0
- package/__tests__/public-device-state-api.test.ts +2 -7
- package/__tests__/sol-sign-offchain-message.test.ts +64 -0
- package/dist/api/GetFeatures.d.ts.map +1 -1
- package/dist/api/GetPassphraseState.d.ts.map +1 -1
- package/dist/api/OpenWalletSession.d.ts.map +1 -1
- package/dist/api/UploadPortfolio.d.ts +1 -0
- package/dist/api/UploadPortfolio.d.ts.map +1 -1
- package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +3 -0
- package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
- package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
- package/dist/api/device/DeviceVerify.d.ts.map +1 -1
- package/dist/api/solana/SolSignOffchainMessage.d.ts.map +1 -1
- package/dist/core/RequestQueue.d.ts +1 -0
- package/dist/core/RequestQueue.d.ts.map +1 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/uiPromiseRegistry.d.ts +1 -1
- package/dist/device/Device.d.ts +2 -0
- package/dist/device/Device.d.ts.map +1 -1
- package/dist/device/DeviceCommands.d.ts +5 -4
- package/dist/device/DeviceCommands.d.ts.map +1 -1
- package/dist/index.d.ts +35 -30
- package/dist/index.js +352 -188
- package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
- package/dist/types/api/getFeatures.d.ts.map +1 -1
- package/dist/types/api/getPassphraseState.d.ts.map +1 -1
- package/dist/types/api/openWalletSession.d.ts +1 -10
- package/dist/types/api/openWalletSession.d.ts.map +1 -1
- package/dist/types/api/protocolV2.d.ts +3 -4
- package/dist/types/api/protocolV2.d.ts.map +1 -1
- package/dist/types/api/solSignOffchainMessage.d.ts +1 -0
- package/dist/types/api/solSignOffchainMessage.d.ts.map +1 -1
- package/dist/types/params.d.ts.map +1 -1
- package/dist/utils/patch.d.ts +1 -1
- package/dist/utils/patch.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/api/GetFeatures.ts +1 -0
- package/src/api/GetPassphraseState.ts +1 -0
- package/src/api/OpenWalletSession.ts +7 -77
- package/src/api/UploadPortfolio.ts +5 -4
- package/src/api/allnetwork/AllNetworkGetAddressBase.ts +56 -15
- package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
- package/src/api/device/DeviceVerify.ts +8 -0
- package/src/api/solana/SolSignOffchainMessage.ts +44 -4
- package/src/core/RequestQueue.ts +20 -0
- package/src/core/index.ts +116 -49
- package/src/data/messages/messages-protocol-v2.json +49 -33
- package/src/data/messages/messages.json +8 -5
- package/src/device/Device.ts +51 -27
- package/src/device/DeviceCommands.ts +29 -2
- package/src/protocols/protocol-v2/walletSession.ts +7 -0
- package/src/types/api/getFeatures.ts +2 -1
- package/src/types/api/getPassphraseState.ts +2 -5
- package/src/types/api/openWalletSession.ts +7 -19
- package/src/types/api/protocolV2.ts +3 -4
- package/src/types/api/solSignOffchainMessage.ts +2 -0
- 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 [
|
|
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('
|
|
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(['
|
|
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 {
|
|
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();
|