@onekeyfe/hd-core 1.2.2-alpha.104 → 1.2.2-alpha.106
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__/core-error-output.test.ts +92 -1
- package/__tests__/device-lifecycle-events.test.ts +313 -2
- package/__tests__/open-wallet-session-error-response.test.ts +2 -2
- package/__tests__/open-wallet-session.test.ts +8 -411
- package/__tests__/public-device-state-api.test.ts +2 -7
- 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/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/core/RequestQueue.d.ts +1 -0
- package/dist/core/RequestQueue.d.ts.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/device/Device.d.ts +2 -0
- package/dist/device/Device.d.ts.map +1 -1
- package/dist/index.d.ts +17 -19
- package/dist/index.js +229 -138
- 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/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/allnetwork/AllNetworkGetAddressBase.ts +56 -15
- package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
- package/src/core/RequestQueue.ts +20 -0
- package/src/core/index.ts +95 -42
- package/src/data/messages/messages-protocol-v2.json +21 -0
- package/src/device/Device.ts +51 -27
- 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/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,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();
|
|
@@ -3,6 +3,7 @@ import { DeviceType, TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
cancel,
|
|
6
|
+
getPostCallPendingPromise,
|
|
6
7
|
initConnector,
|
|
7
8
|
initCore,
|
|
8
9
|
isDeviceIdentityMismatchError,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
resolveBleConnectProtocol,
|
|
14
15
|
} from '../src/core';
|
|
15
16
|
import { DataManager } from '../src/data-manager';
|
|
17
|
+
import GetDeviceState from '../src/api/GetDeviceState';
|
|
16
18
|
import TransportManager from '../src/data-manager/TransportManager';
|
|
17
19
|
import { Device } from '../src/device/Device';
|
|
18
20
|
import { cancelDeviceInPrompt, cancelDeviceWithInitialize } from '../src/device/DeviceCommands';
|
|
@@ -74,6 +76,104 @@ describe('public device lifecycle events', () => {
|
|
|
74
76
|
jest.restoreAllMocks();
|
|
75
77
|
});
|
|
76
78
|
|
|
79
|
+
test.each(['V1', 'V2'] as const)(
|
|
80
|
+
'preserves the %s acknowledged result and waits for release before the next acquire',
|
|
81
|
+
async protocol => {
|
|
82
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
83
|
+
jest.spyOn(DataManager, 'checkAndReloadData').mockResolvedValue(undefined);
|
|
84
|
+
jest.spyOn(TransportManager, 'configure').mockResolvedValue(undefined);
|
|
85
|
+
const device = createInitializedDevice(protocol);
|
|
86
|
+
jest.spyOn(Device, 'fromDescriptor').mockReturnValue(device);
|
|
87
|
+
const acquire = jest.spyOn(device, 'acquire').mockResolvedValue(undefined);
|
|
88
|
+
jest.spyOn(device, 'initialize').mockResolvedValue(undefined);
|
|
89
|
+
jest.spyOn(device, 'hasUnexpectedMode').mockReturnValue(undefined);
|
|
90
|
+
jest.spyOn(device, 'getCurrentMethodVersionRange').mockReturnValue({} as never);
|
|
91
|
+
device.commands = { disposed: false, dispose: jest.fn(), checkDisposed: jest.fn() } as never;
|
|
92
|
+
const methodRun = jest.spyOn(GetDeviceState.prototype, 'run').mockResolvedValue({} as never);
|
|
93
|
+
const releaseGate = createDeferred<void>();
|
|
94
|
+
const release = jest.spyOn(device, 'release').mockReturnValue(releaseGate.promise);
|
|
95
|
+
core = initCore();
|
|
96
|
+
const call = (id: number) =>
|
|
97
|
+
core!.handleMessage({
|
|
98
|
+
id,
|
|
99
|
+
event: IFRAME.CALL,
|
|
100
|
+
type: IFRAME.CALL,
|
|
101
|
+
payload: {
|
|
102
|
+
method: 'getDeviceState',
|
|
103
|
+
connectId: 'release-device',
|
|
104
|
+
connectProtocol: protocol,
|
|
105
|
+
},
|
|
106
|
+
} as CoreMessage);
|
|
107
|
+
await expect(call(301)).resolves.toMatchObject({ success: true });
|
|
108
|
+
const nextCall = call(302);
|
|
109
|
+
await new Promise(resolve => {
|
|
110
|
+
setImmediate(resolve);
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
expect(methodRun).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(acquire).toHaveBeenCalledTimes(1);
|
|
116
|
+
} finally {
|
|
117
|
+
releaseGate.resolve();
|
|
118
|
+
}
|
|
119
|
+
await expect(nextCall).resolves.toMatchObject({ success: true });
|
|
120
|
+
expect(acquire).toHaveBeenCalledTimes(2);
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
test.each([
|
|
125
|
+
['webusb', EDeviceType.Pro2],
|
|
126
|
+
['desktop-webusb', EDeviceType.Pro2],
|
|
127
|
+
['node-usb', EDeviceType.Neo],
|
|
128
|
+
] as const)('waits one second after %s signing on %s', async (env, deviceType) => {
|
|
129
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
|
|
130
|
+
const cooldown = createDeferred<void>();
|
|
131
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => {
|
|
132
|
+
cooldown.promise.then(() => callback());
|
|
133
|
+
return 0 as never;
|
|
134
|
+
});
|
|
135
|
+
const cleanup = createDeferred<void>();
|
|
136
|
+
const pending = getPostCallPendingPromise(
|
|
137
|
+
{ name: 'evmSignTransaction' } as never,
|
|
138
|
+
{
|
|
139
|
+
getCurrentDeviceType: () => deviceType,
|
|
140
|
+
waitForRunCleanup: () => cleanup.promise,
|
|
141
|
+
} as never
|
|
142
|
+
);
|
|
143
|
+
let settled = false;
|
|
144
|
+
pending.then(() => {
|
|
145
|
+
settled = true;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
cleanup.resolve();
|
|
149
|
+
await Promise.resolve();
|
|
150
|
+
expect(settled).toBe(false);
|
|
151
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
|
|
152
|
+
cooldown.resolve();
|
|
153
|
+
await pending;
|
|
154
|
+
expect(settled).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test.each([
|
|
158
|
+
['react-native', EDeviceType.Pro2, 'evmSignTransaction'],
|
|
159
|
+
['webusb', EDeviceType.Classic, 'evmSignTransaction'],
|
|
160
|
+
['webusb', EDeviceType.Pro2, 'evmGetAddress'],
|
|
161
|
+
] as const)('does not add a signing cooldown for %s, %s, %s', async (env, deviceType, name) => {
|
|
162
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
|
|
163
|
+
const cleanup = createDeferred<void>();
|
|
164
|
+
const pending = getPostCallPendingPromise(
|
|
165
|
+
{ name } as never,
|
|
166
|
+
{
|
|
167
|
+
getCurrentDeviceType: () => deviceType,
|
|
168
|
+
waitForRunCleanup: () => cleanup.promise,
|
|
169
|
+
} as never
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(pending).toBe(cleanup.promise);
|
|
173
|
+
cleanup.resolve();
|
|
174
|
+
await expect(pending).resolves.toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
77
177
|
test('prefers Protocol V2 only when the method contract is explicitly V2-only', () => {
|
|
78
178
|
const createMethod = (protocols: readonly ('V1' | 'V2')[], connectProtocol?: 'V1' | 'V2') =>
|
|
79
179
|
({
|
|
@@ -105,7 +205,7 @@ describe('public device lifecycle events', () => {
|
|
|
105
205
|
expect(DevicePool.emitter.listenerCount(DEVICE.DISCONNECT)).toBe(1);
|
|
106
206
|
});
|
|
107
207
|
|
|
108
|
-
test('isolates pending cancellation cleanup by connect id', () => {
|
|
208
|
+
test('isolates pending cancellation cleanup by connect id and waits for every cleanup', async () => {
|
|
109
209
|
core = initCore();
|
|
110
210
|
const context = (core as any).getCoreContext();
|
|
111
211
|
const firstCleanup = createDeferred<void>();
|
|
@@ -117,9 +217,20 @@ describe('public device lifecycle events', () => {
|
|
|
117
217
|
expect(context.getPrePendingCallPromise('device-b')).toBeUndefined();
|
|
118
218
|
|
|
119
219
|
context.setPrePendingCallPromise('device-a', replacementCleanup.promise);
|
|
220
|
+
const combined = context.getPrePendingCallPromise('device-a');
|
|
120
221
|
context.removePrePendingCallPromise('device-a', firstCleanup.promise);
|
|
121
222
|
|
|
122
|
-
expect(context.getPrePendingCallPromise('device-a')).toBe(
|
|
223
|
+
expect(context.getPrePendingCallPromise('device-a')).toBe(combined);
|
|
224
|
+
let drained = false;
|
|
225
|
+
combined.then(() => {
|
|
226
|
+
drained = true;
|
|
227
|
+
});
|
|
228
|
+
replacementCleanup.resolve();
|
|
229
|
+
await Promise.resolve();
|
|
230
|
+
expect(drained).toBe(false);
|
|
231
|
+
firstCleanup.resolve();
|
|
232
|
+
await combined;
|
|
233
|
+
expect(drained).toBe(true);
|
|
123
234
|
});
|
|
124
235
|
|
|
125
236
|
test('cancels only requests associated with the requested connect id', () => {
|
|
@@ -158,6 +269,154 @@ describe('public device lifecycle events', () => {
|
|
|
158
269
|
expect(deviceB.interruptionFromUser).not.toHaveBeenCalled();
|
|
159
270
|
});
|
|
160
271
|
|
|
272
|
+
test.each(['callback', 'cleanup'] as const)(
|
|
273
|
+
'cancels a request waiting for %s before acquire',
|
|
274
|
+
async phase => {
|
|
275
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
276
|
+
const acquire = jest.spyOn(Device.prototype, 'acquire').mockResolvedValue(undefined);
|
|
277
|
+
core = initCore();
|
|
278
|
+
const context = (core as any).getCoreContext();
|
|
279
|
+
const gate = createDeferred<void>();
|
|
280
|
+
const connectId = `queued-${phase}`;
|
|
281
|
+
if (phase === 'callback') context.registerCallbackTask(connectId, gate);
|
|
282
|
+
else context.setPrePendingCallPromise(connectId, gate.promise);
|
|
283
|
+
const result = core.handleMessage({
|
|
284
|
+
id: 201,
|
|
285
|
+
event: IFRAME.CALL,
|
|
286
|
+
type: IFRAME.CALL,
|
|
287
|
+
payload: { method: 'getDeviceState', connectId, connectProtocol: 'V2' },
|
|
288
|
+
} as CoreMessage);
|
|
289
|
+
await new Promise(resolve => {
|
|
290
|
+
setImmediate(resolve);
|
|
291
|
+
});
|
|
292
|
+
expect(context.requestQueue.getRequestTasksIdByConnectId(connectId)).toHaveLength(1);
|
|
293
|
+
cancel(context, connectId);
|
|
294
|
+
await expect(result).resolves.toMatchObject({
|
|
295
|
+
success: false,
|
|
296
|
+
payload: { code: HardwareErrorCode.CallQueueActionCancelled },
|
|
297
|
+
});
|
|
298
|
+
gate.resolve();
|
|
299
|
+
await new Promise(resolve => {
|
|
300
|
+
setImmediate(resolve);
|
|
301
|
+
});
|
|
302
|
+
expect(acquire).not.toHaveBeenCalled();
|
|
303
|
+
expect(context.requestQueue.getRequestTasksId()).toHaveLength(0);
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
test.each([
|
|
308
|
+
['V1', 'callback'],
|
|
309
|
+
['V1', 'cleanup'],
|
|
310
|
+
['V2', 'callback'],
|
|
311
|
+
['V2', 'cleanup'],
|
|
312
|
+
] as const)(
|
|
313
|
+
'does not run a cancelled %s request waiting for %s after acquire',
|
|
314
|
+
async (protocol, phase) => {
|
|
315
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
316
|
+
jest.spyOn(DataManager, 'checkAndReloadData').mockResolvedValue(undefined);
|
|
317
|
+
jest.spyOn(TransportManager, 'configure').mockResolvedValue(undefined);
|
|
318
|
+
const device = createInitializedDevice(protocol);
|
|
319
|
+
jest.spyOn(Device, 'fromDescriptor').mockReturnValue(device);
|
|
320
|
+
const acquire = jest.spyOn(device, 'acquire').mockResolvedValue(undefined);
|
|
321
|
+
jest.spyOn(device, 'hasUnexpectedMode').mockReturnValue(undefined);
|
|
322
|
+
jest.spyOn(device, 'getCurrentMethodVersionRange').mockReturnValue({} as never);
|
|
323
|
+
device.commands = { disposed: false, dispose: jest.fn(), checkDisposed: jest.fn() } as never;
|
|
324
|
+
const deviceRun = jest.spyOn(device, 'run');
|
|
325
|
+
core = initCore();
|
|
326
|
+
const context = (core as any).getCoreContext();
|
|
327
|
+
const gate = createDeferred<void>();
|
|
328
|
+
const connectId = `post-acquire-${protocol}-${phase}`;
|
|
329
|
+
jest.spyOn(device, 'initialize').mockImplementation(() => {
|
|
330
|
+
if (phase === 'callback') context.registerCallbackTask(connectId, gate);
|
|
331
|
+
else context.setPrePendingCallPromise(connectId, gate.promise);
|
|
332
|
+
return Promise.resolve();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = core.handleMessage({
|
|
336
|
+
id: 203,
|
|
337
|
+
event: IFRAME.CALL,
|
|
338
|
+
type: IFRAME.CALL,
|
|
339
|
+
payload: { method: 'getDeviceState', connectId, connectProtocol: protocol },
|
|
340
|
+
} as CoreMessage);
|
|
341
|
+
await new Promise(resolve => {
|
|
342
|
+
setImmediate(resolve);
|
|
343
|
+
});
|
|
344
|
+
expect(acquire).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(deviceRun).not.toHaveBeenCalled();
|
|
346
|
+
|
|
347
|
+
cancel(context, connectId);
|
|
348
|
+
await expect(result).resolves.toMatchObject({
|
|
349
|
+
success: false,
|
|
350
|
+
payload: { code: HardwareErrorCode.CallQueueActionCancelled },
|
|
351
|
+
});
|
|
352
|
+
gate.resolve();
|
|
353
|
+
await new Promise(resolve => {
|
|
354
|
+
setImmediate(resolve);
|
|
355
|
+
});
|
|
356
|
+
expect(deviceRun).not.toHaveBeenCalled();
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
test('keeps the cleanup barrier when its deadline expires', async () => {
|
|
361
|
+
const realSetTimeout = setTimeout;
|
|
362
|
+
jest
|
|
363
|
+
.spyOn(global, 'setTimeout')
|
|
364
|
+
.mockImplementation((callback, delay, ...args) =>
|
|
365
|
+
realSetTimeout(callback, delay === 15_000 ? 0 : delay, ...args)
|
|
366
|
+
);
|
|
367
|
+
{
|
|
368
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
369
|
+
const acquire = jest.spyOn(Device.prototype, 'acquire').mockResolvedValue(undefined);
|
|
370
|
+
core = initCore();
|
|
371
|
+
const context = (core as any).getCoreContext();
|
|
372
|
+
const gate = createDeferred<void>();
|
|
373
|
+
context.setPrePendingCallPromise('draining-device', gate.promise);
|
|
374
|
+
const result = core.handleMessage({
|
|
375
|
+
id: 202,
|
|
376
|
+
event: IFRAME.CALL,
|
|
377
|
+
type: IFRAME.CALL,
|
|
378
|
+
payload: { method: 'getDeviceState', connectId: 'draining-device', connectProtocol: 'V2' },
|
|
379
|
+
} as CoreMessage);
|
|
380
|
+
await expect(result).resolves.toMatchObject({
|
|
381
|
+
success: false,
|
|
382
|
+
payload: { code: HardwareErrorCode.DeviceBusy },
|
|
383
|
+
});
|
|
384
|
+
expect(acquire).not.toHaveBeenCalled();
|
|
385
|
+
expect(context.getPrePendingCallPromise('draining-device')).toBe(gate.promise);
|
|
386
|
+
gate.resolve();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test.each(['V1', 'V2'] as const)(
|
|
391
|
+
'keeps repeated and late %s cancellation scoped to the old commands',
|
|
392
|
+
async protocol => {
|
|
393
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
394
|
+
const device = createInitializedDevice(protocol);
|
|
395
|
+
const gate = createDeferred<void>();
|
|
396
|
+
const oldCancel = jest.fn().mockResolvedValue(undefined);
|
|
397
|
+
device.commands = { cancel: oldCancel } as never;
|
|
398
|
+
device.setCancelableAction(() => gate.promise);
|
|
399
|
+
const first = device.interruptionFromUser();
|
|
400
|
+
expect(device.interruptionFromUser()).toBe(first);
|
|
401
|
+
device.beginConnectionAttempt();
|
|
402
|
+
const newCancel = jest.fn();
|
|
403
|
+
const newRun = createDeferred<void>();
|
|
404
|
+
const rejected = jest.spyOn(newRun, 'reject');
|
|
405
|
+
device.commands = { cancel: newCancel } as never;
|
|
406
|
+
device.runPromise = newRun;
|
|
407
|
+
const newAction = jest.fn().mockResolvedValue(undefined);
|
|
408
|
+
device.setCancelableAction(newAction);
|
|
409
|
+
gate.resolve();
|
|
410
|
+
await first;
|
|
411
|
+
expect(oldCancel).toHaveBeenCalledTimes(1);
|
|
412
|
+
expect(newCancel).not.toHaveBeenCalled();
|
|
413
|
+
expect(rejected).not.toHaveBeenCalled();
|
|
414
|
+
newRun.promise.catch(() => undefined);
|
|
415
|
+
await device.interruptionFromUser();
|
|
416
|
+
expect(newAction).toHaveBeenCalledTimes(1);
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
|
|
161
420
|
test('aborts every request before cancel-all rejects WebUSB tasks', () => {
|
|
162
421
|
jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
|
|
163
422
|
core = initCore();
|
|
@@ -389,6 +648,38 @@ describe('public device lifecycle events', () => {
|
|
|
389
648
|
expect(isRetryableBleConnectionError(method, error)).toBe(false);
|
|
390
649
|
});
|
|
391
650
|
|
|
651
|
+
test.each(['V1', 'V2'] as const)(
|
|
652
|
+
'stops the outer BLE poll after a manager reset for Protocol %s',
|
|
653
|
+
async connectProtocol => {
|
|
654
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
655
|
+
jest.spyOn(TransportManager, 'configure').mockResolvedValue(undefined);
|
|
656
|
+
const acquire = jest
|
|
657
|
+
.spyOn(Device.prototype, 'acquire')
|
|
658
|
+
.mockRejectedValue(
|
|
659
|
+
ERRORS.TypedError(HardwareErrorCode.PollingTimeout, 'BLE setup wedged repeatedly')
|
|
660
|
+
);
|
|
661
|
+
core = initCore();
|
|
662
|
+
const response = await core.handleMessage({
|
|
663
|
+
id: `manager-reset-${connectProtocol}`,
|
|
664
|
+
event: IFRAME.CALL,
|
|
665
|
+
type: IFRAME.CALL,
|
|
666
|
+
payload: {
|
|
667
|
+
method: 'getDeviceState',
|
|
668
|
+
connectId: `manager-reset-device-${connectProtocol}`,
|
|
669
|
+
connectProtocol,
|
|
670
|
+
retryCount: 1,
|
|
671
|
+
pollIntervalTime: 1,
|
|
672
|
+
},
|
|
673
|
+
} as CoreMessage);
|
|
674
|
+
|
|
675
|
+
expect(response).toMatchObject({
|
|
676
|
+
success: false,
|
|
677
|
+
payload: { code: HardwareErrorCode.PollingTimeout },
|
|
678
|
+
});
|
|
679
|
+
expect(acquire).toHaveBeenCalledTimes(1);
|
|
680
|
+
}
|
|
681
|
+
);
|
|
682
|
+
|
|
392
683
|
test.each([
|
|
393
684
|
[HardwareErrorCode.BleDeviceBondError, true],
|
|
394
685
|
[HardwareErrorCode.BlePeerRemovedPairingInformation, true],
|
|
@@ -561,6 +852,26 @@ describe('public device lifecycle events', () => {
|
|
|
561
852
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
562
853
|
});
|
|
563
854
|
|
|
855
|
+
test.each(['V1', 'V2'] as const)(
|
|
856
|
+
'disconnects and resolves when Protocol %s cancellation cleanup fails',
|
|
857
|
+
async protocol => {
|
|
858
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
859
|
+
const device = createInitializedDevice(protocol);
|
|
860
|
+
const cleanupError = new Error('cancel cleanup failed');
|
|
861
|
+
const cancel = jest.fn().mockRejectedValue(cleanupError);
|
|
862
|
+
const disconnect = jest.fn().mockResolvedValue(undefined);
|
|
863
|
+
device.deviceConnector = { disconnect } as never;
|
|
864
|
+
device.commands = { cancel } as never;
|
|
865
|
+
(device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
|
|
866
|
+
|
|
867
|
+
await expect(device.interruptionFromUser()).resolves.toBeUndefined();
|
|
868
|
+
|
|
869
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
870
|
+
expect(disconnect).toHaveBeenCalledWith(device.mainId);
|
|
871
|
+
expect(device.hasDeviceAcquire()).toBe(false);
|
|
872
|
+
}
|
|
873
|
+
);
|
|
874
|
+
|
|
564
875
|
test('does not finish acquire after the user already cancelled', async () => {
|
|
565
876
|
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
|
|
566
877
|
const device = createInitializedDevice('V2');
|
|
@@ -17,7 +17,7 @@ jest.mock('../src/data-manager/TransportManager', () => ({
|
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
19
|
describe('openWalletSession error response', () => {
|
|
20
|
-
test('returns a structured invalid-parameter response for
|
|
20
|
+
test('returns a structured invalid-parameter response for the removed resume-hidden mode', async () => {
|
|
21
21
|
const core = initCore();
|
|
22
22
|
|
|
23
23
|
const response = await core.handleMessage({
|
|
@@ -34,7 +34,7 @@ describe('openWalletSession error response', () => {
|
|
|
34
34
|
expect(response).toMatchObject({
|
|
35
35
|
success: false,
|
|
36
36
|
payload: {
|
|
37
|
-
error: '
|
|
37
|
+
error: 'Parameter [mode] must be one of standard or select-hidden.',
|
|
38
38
|
code: HardwareErrorCode.CallMethodInvalidParameter,
|
|
39
39
|
},
|
|
40
40
|
});
|