@onekeyfe/hd-core 1.2.0-alpha.104 → 1.2.0-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.
Files changed (46) hide show
  1. package/__tests__/DeviceCommands.test.ts +100 -28
  2. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +0 -2
  3. package/__tests__/device-lifecycle-events.test.ts +113 -2
  4. package/__tests__/device-pool-state.test.ts +1 -5
  5. package/__tests__/deviceSettings.test.ts +0 -1
  6. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +145 -0
  7. package/__tests__/get-device-state.test.ts +0 -50
  8. package/__tests__/logBlockEvent.test.ts +13 -1
  9. package/__tests__/protocol-v2.test.ts +466 -126
  10. package/__tests__/refresh-device-state.test.ts +0 -1
  11. package/__tests__/search-devices.test.ts +0 -2
  12. package/dist/api/FirmwareUpdateV4.d.ts +7 -3
  13. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  14. package/dist/api/GetDeviceState.d.ts.map +1 -1
  15. package/dist/api/SearchDevices.d.ts.map +1 -1
  16. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/device/Device.d.ts +1 -3
  19. package/dist/device/Device.d.ts.map +1 -1
  20. package/dist/device/DeviceCommands.d.ts.map +1 -1
  21. package/dist/device/DevicePool.d.ts +1 -1
  22. package/dist/device/DevicePool.d.ts.map +1 -1
  23. package/dist/events/logBlockEvent.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -6
  25. package/dist/index.js +260 -196
  26. package/dist/protocols/protocol-v2/features.d.ts +1 -1
  27. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  28. package/dist/types/api/getDeviceState.d.ts +0 -1
  29. package/dist/types/api/getDeviceState.d.ts.map +1 -1
  30. package/package.json +4 -4
  31. package/src/api/FirmwareUpdateV4.ts +262 -172
  32. package/src/api/GetDeviceState.ts +0 -1
  33. package/src/api/SearchDevices.ts +0 -2
  34. package/src/api/firmware/protocolV2Release.ts +0 -1
  35. package/src/core/index.ts +6 -6
  36. package/src/device/Device.ts +27 -63
  37. package/src/device/DeviceCommands.ts +4 -14
  38. package/src/device/DevicePool.ts +3 -11
  39. package/src/events/logBlockEvent.ts +14 -1
  40. package/src/protocols/protocol-v2/features.ts +9 -2
  41. package/src/types/api/getDeviceState.ts +0 -2
  42. package/src/utils/deviceSettings.ts +1 -1
  43. package/__tests__/protocol-v2-legacy-recovery.test.ts +0 -29
  44. package/dist/core/protocolV2LegacyRecovery.d.ts +0 -5
  45. package/dist/core/protocolV2LegacyRecovery.d.ts.map +0 -1
  46. package/src/core/protocolV2LegacyRecovery.ts +0 -12
@@ -19,12 +19,12 @@ const createCommands = () => {
19
19
  };
20
20
 
21
21
  describe('DeviceCommands failure mapping', () => {
22
- it('logs passphrase call request and response without exposing the passphrase', async () => {
22
+ it('logs passphrase request and canonical response without exposing the passphrase', async () => {
23
23
  const commands = createCommands();
24
- const requestLog = getLogger(LoggerNames.DeviceCommands);
25
- const responseLog = getLogger(LoggerNames.Core);
26
- requestLog.messages.length = 0;
27
- responseLog.messages.length = 0;
24
+ const log = getLogger(LoggerNames.DeviceCommands);
25
+ const coreLog = getLogger(LoggerNames.Core);
26
+ log.messages.length = 0;
27
+ coreLog.messages.length = 0;
28
28
  commands.mainId = 'main-id';
29
29
  commands.transport = {
30
30
  call: jest.fn().mockResolvedValue({
@@ -36,13 +36,13 @@ describe('DeviceCommands failure mapping', () => {
36
36
  } as any;
37
37
 
38
38
  await expect(
39
- commands.call('DeviceSessionAskPassphrase', {
39
+ commands._commonCall('DeviceSessionAskPassphrase', {
40
40
  passphrase: 'hidden-wallet-secret',
41
41
  on_device: false,
42
42
  })
43
43
  ).resolves.toMatchObject({ type: 'Success' });
44
44
 
45
- expect(requestLog.messages.at(-1)?.message).toEqual([
45
+ expect(log.messages.at(-2)?.message).toEqual([
46
46
  '[DeviceCommands] [call] Sending',
47
47
  'DeviceSessionAskPassphrase',
48
48
  {
@@ -50,21 +50,27 @@ describe('DeviceCommands failure mapping', () => {
50
50
  on_device: false,
51
51
  },
52
52
  ]);
53
- expect(responseLog.messages.at(-1)?.message).toEqual([
54
- '[DeviceCommands] [call] Received',
55
- 'Success',
53
+ expect(log.messages.at(-1)?.message).toEqual([
54
+ '_filterCommonTypes: ',
56
55
  {
57
- message: 'Passphrase accepted',
56
+ request: 'DeviceSessionAskPassphrase',
57
+ response: {
58
+ type: 'Success',
59
+ message: {
60
+ message: 'Passphrase accepted',
61
+ },
62
+ },
58
63
  },
59
64
  ]);
60
- expect(JSON.stringify([...requestLog.messages, ...responseLog.messages])).not.toContain(
61
- 'hidden-wallet-secret'
62
- );
65
+ expect(
66
+ coreLog.messages.some(entry => entry.message[0] === '[DeviceCommands] [call] Received')
67
+ ).toBe(false);
68
+ expect(JSON.stringify(log.messages)).not.toContain('hidden-wallet-secret');
63
69
  });
64
70
 
65
- it('logs DeviceStatus response fields without exposing the device ID', async () => {
71
+ it('logs canonical DeviceStatus response fields without exposing the device ID', async () => {
66
72
  const commands = createCommands();
67
- const log = getLogger(LoggerNames.Core);
73
+ const log = getLogger(LoggerNames.DeviceCommands);
68
74
  log.messages.length = 0;
69
75
  commands.mainId = 'main-id';
70
76
  commands.transport = {
@@ -82,22 +88,27 @@ describe('DeviceCommands failure mapping', () => {
82
88
  }),
83
89
  } as any;
84
90
 
85
- await expect(commands.call('DeviceStatusGet', {})).resolves.toMatchObject({
91
+ await expect(commands._commonCall('DeviceStatusGet', {})).resolves.toMatchObject({
86
92
  type: 'DeviceStatus',
87
93
  });
88
94
 
89
95
  const receivedLog = log.messages.at(-1)?.message;
90
96
  expect(receivedLog).toEqual([
91
- '[DeviceCommands] [call] Received',
92
- 'DeviceStatus',
97
+ '_filterCommonTypes: ',
93
98
  {
94
- device_id: '[REDACTED]',
95
- unlocked: true,
96
- init_states: true,
97
- backup_required: false,
98
- passphrase_enabled: true,
99
- attach_to_pin_enabled: false,
100
- unlocked_by_attach_to_pin: false,
99
+ request: 'DeviceStatusGet',
100
+ response: {
101
+ type: 'DeviceStatus',
102
+ message: {
103
+ device_id: '[REDACTED]',
104
+ unlocked: true,
105
+ init_states: true,
106
+ backup_required: false,
107
+ passphrase_enabled: true,
108
+ attach_to_pin_enabled: false,
109
+ unlocked_by_attach_to_pin: false,
110
+ },
111
+ },
101
112
  },
102
113
  ]);
103
114
  expect(JSON.stringify(receivedLog)).not.toContain('sensitive-device-id');
@@ -125,6 +136,59 @@ describe('DeviceCommands failure mapping', () => {
125
136
  expect(JSON.stringify(log.messages)).not.toContain('secret-wallet-address');
126
137
  });
127
138
 
139
+ it('logs the sanitized DeviceInfo response payload', async () => {
140
+ const commands = createCommands();
141
+ const log = getLogger(LoggerNames.DeviceCommands);
142
+ log.messages.length = 0;
143
+
144
+ await expect(
145
+ commands._filterCommonTypes(
146
+ {
147
+ type: 'DeviceInfo',
148
+ message: {
149
+ protocol_version: 2,
150
+ hw: {
151
+ Device_type: 7,
152
+ hardware_version: '1.0.0',
153
+ },
154
+ fw: {
155
+ application: {
156
+ version: '5.0.0',
157
+ build_id: '20260811',
158
+ hash: 'firmware-hash',
159
+ },
160
+ },
161
+ },
162
+ } as any,
163
+ 'DeviceInfoGet'
164
+ )
165
+ ).resolves.toMatchObject({ type: 'DeviceInfo' });
166
+
167
+ expect(log.messages.at(-1)?.message).toEqual([
168
+ '_filterCommonTypes: ',
169
+ {
170
+ request: 'DeviceInfoGet',
171
+ response: {
172
+ type: 'DeviceInfo',
173
+ message: {
174
+ protocol_version: 2,
175
+ hw: {
176
+ Device_type: 7,
177
+ hardware_version: '1.0.0',
178
+ },
179
+ fw: {
180
+ application: {
181
+ version: '5.0.0',
182
+ build_id: '20260811',
183
+ hash: 'firmware-hash',
184
+ },
185
+ },
186
+ },
187
+ },
188
+ },
189
+ ]);
190
+ });
191
+
128
192
  it('logs the sanitized DeviceFirmwareUpdateStatus response payload', async () => {
129
193
  const commands = createCommands();
130
194
  const log = getLogger(LoggerNames.DeviceCommands);
@@ -561,17 +625,22 @@ describe('DeviceCommands failure mapping', () => {
561
625
  });
562
626
 
563
627
  describe('DeviceCommands Protocol V2 interactive response compatibility', () => {
564
- it('still auto-acks ButtonRequest when no hardware UI listener is registered', async () => {
628
+ it('auto-acks Pro2 ButtonRequest and registers Cancel instead of Initialize', async () => {
565
629
  const commands = createCommands();
566
630
  const emit = jest.fn();
631
+ const cancelDevice = jest.spyOn(commands, 'cancelDevice').mockResolvedValue(undefined);
632
+ const cancelDeviceOnOneKeyDevice = jest
633
+ .spyOn(commands, 'cancelDeviceOnOneKeyDevice')
634
+ .mockResolvedValue(undefined);
567
635
  const commonCall = jest.fn().mockResolvedValue({
568
636
  type: 'Success',
569
637
  message: {},
570
638
  });
639
+ const setCancelableAction = jest.fn();
571
640
  commands.device = {
572
641
  ...commands.device,
573
642
  getCurrentDeviceType: jest.fn(() => 'pro2'),
574
- setCancelableAction: jest.fn(),
643
+ setCancelableAction,
575
644
  emit,
576
645
  listenerCount: jest.fn(() => 0),
577
646
  } as any;
@@ -593,6 +662,9 @@ describe('DeviceCommands Protocol V2 interactive response compatibility', () =>
593
662
  expect.objectContaining({ code: 'ButtonRequest_PinEntry' })
594
663
  );
595
664
  expect(commonCall).toHaveBeenCalledWith('ButtonAck', {}, undefined);
665
+ await setCancelableAction.mock.calls[0][0]();
666
+ expect(cancelDevice).toHaveBeenCalledTimes(1);
667
+ expect(cancelDeviceOnOneKeyDevice).not.toHaveBeenCalled();
596
668
  });
597
669
 
598
670
  it('rejects PassphraseRequest when the Pro2 UI listener is not registered', async () => {
@@ -346,7 +346,6 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
346
346
  expect(typedCall).not.toHaveBeenCalled();
347
347
  expect(getDeviceState).toHaveBeenCalledWith({
348
348
  refreshSections: ['identity', 'versions'],
349
- allowLegacyProtocolV2ProtocolInfo: true,
350
349
  });
351
350
  expect(method.getSupportedProtocols()).toEqual(['V1', 'V2']);
352
351
  });
@@ -726,7 +725,6 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
726
725
  });
727
726
  expect(getDeviceState).toHaveBeenCalledWith({
728
727
  refreshSections: ['identity', 'versions', 'verification'],
729
- allowLegacyProtocolV2ProtocolInfo: true,
730
728
  });
731
729
  });
732
730
 
@@ -1,12 +1,14 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
1
+ import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { DeviceType, TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import { initConnector, initCore } from '../src/core';
5
5
  import { DataManager } from '../src/data-manager';
6
6
  import TransportManager from '../src/data-manager/TransportManager';
7
7
  import { Device } from '../src/device/Device';
8
+ import { cancelDeviceInPrompt, cancelDeviceWithInitialize } from '../src/device/DeviceCommands';
8
9
  import { DevicePool } from '../src/device/DevicePool';
9
10
  import { CORE_EVENT, DEVICE, IFRAME } from '../src/events';
11
+ import { PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE } from '../src/protocols/protocol-v2';
10
12
 
11
13
  import type Core from '../src/core';
12
14
  import type { CoreMessage } from '../src/events';
@@ -271,6 +273,115 @@ describe('public device lifecycle events', () => {
271
273
  expect(device.isUsedHere()).toBe(false);
272
274
  });
273
275
 
276
+ test.each([
277
+ ['V2', 'Cancel', 'webusb'],
278
+ ['V2', 'Cancel', 'desktop-web-ble'],
279
+ ['V2', 'Cancel', 'react-native'],
280
+ ['V1', 'Initialize', 'webusb'],
281
+ ['V1', 'Cancel', 'desktop-web-ble'],
282
+ ] as const)(
283
+ 'sends Protocol %s %s without treating the acquired %s session as disconnected',
284
+ async (protocol, messageType, env) => {
285
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
286
+ const device = createInitializedDevice(protocol);
287
+ const post = jest.fn().mockResolvedValue(undefined);
288
+ const call = jest.fn().mockResolvedValue(undefined);
289
+ const cancel = jest.fn().mockResolvedValue(undefined);
290
+ device.originalDescriptor.session = device.mainId;
291
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
292
+ (device as any).commands = { transport: { post, call }, cancel };
293
+ device.setCancelableAction(() =>
294
+ messageType === 'Cancel'
295
+ ? cancelDeviceInPrompt(device, false)
296
+ : cancelDeviceWithInitialize(device)
297
+ );
298
+
299
+ await device.interruptionFromUser();
300
+
301
+ if (messageType === 'Cancel') {
302
+ expect(post).toHaveBeenCalledWith(device.mainId, 'Cancel', {});
303
+ expect(call).not.toHaveBeenCalled();
304
+ } else {
305
+ expect(call).toHaveBeenCalledWith(device.mainId, 'Initialize', {});
306
+ expect(post).not.toHaveBeenCalled();
307
+ }
308
+ expect(cancel).toHaveBeenCalledTimes(1);
309
+ expect(device.hasDeviceAcquire()).toBe(true);
310
+ }
311
+ );
312
+
313
+ test.each([
314
+ [EDeviceType.Pro2, 'webusb', false, DeviceType.PRO2],
315
+ [EDeviceType.Neo, 'webusb', false, DeviceType.NEO],
316
+ [EDeviceType.Pro2, 'desktop-web-ble', true, DeviceType.PRO2],
317
+ [EDeviceType.Neo, 'desktop-web-ble', true, DeviceType.NEO],
318
+ [EDeviceType.Pro2, 'react-native', true, DeviceType.PRO2],
319
+ [EDeviceType.Neo, 'react-native', true, DeviceType.NEO],
320
+ [EDeviceType.Pro2, 'lowlevel', false, DeviceType.PRO2],
321
+ ] as const)(
322
+ 'refreshes %s runtime state after acquiring a fresh %s session',
323
+ async (deviceType, env, isBle, protocolV2DeviceType) => {
324
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
325
+ const device = createInitializedDevice('V2');
326
+ const staleProtocolInfo = {
327
+ version: 1,
328
+ build_fingerprint: 'bootloader__1.0.0__abcdef0__PROD__RELEASE',
329
+ supported_messages: [],
330
+ };
331
+ const freshProtocolInfo = {
332
+ version: 1,
333
+ build_fingerprint: 'application__5.0.0__abcdef0__PROD__RELEASE',
334
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
335
+ };
336
+ device.updateState(
337
+ {
338
+ identity: { deviceType },
339
+ status: { mode: 'bootloader' },
340
+ raw: { protocolV2ProtocolInfo: staleProtocolInfo },
341
+ },
342
+ 'initialize'
343
+ );
344
+ const acquire = jest
345
+ .fn()
346
+ .mockResolvedValue(isBle ? { uuid: 'fresh-session', protocolType: 'V2' } : 'fresh-session');
347
+ device.deviceConnector = { acquire } as never;
348
+
349
+ await device.acquire('V2', { throwOnRunPromiseError: true });
350
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
351
+ if (requestType === 'DeviceInfoGet') {
352
+ return {
353
+ message: {
354
+ protocol_version: 2,
355
+ hw: { Device_type: protocolV2DeviceType, serial_no: 'SERIAL-001' },
356
+ fw: { application: { version: '5.0.0' } },
357
+ },
358
+ };
359
+ }
360
+ if (requestType === 'ProtocolInfoRequest') {
361
+ return { message: freshProtocolInfo };
362
+ }
363
+ if (requestType === 'DeviceStatusGet') {
364
+ return {
365
+ message: { init_states: true, unlocked: true, device_id: 'wallet-device-id' },
366
+ };
367
+ }
368
+ throw new Error(`Unexpected request: ${requestType}`);
369
+ });
370
+ device.commands.typedCall = typedCall as never;
371
+
372
+ await device.initialize();
373
+
374
+ expect(typedCall.mock.calls.map(callArgs => callArgs[0])).toEqual([
375
+ 'DeviceInfoGet',
376
+ 'ProtocolInfoRequest',
377
+ 'DeviceStatusGet',
378
+ ]);
379
+ expect(device.state?.status.mode).toBe('normal');
380
+ expect(device.state?.identity.deviceType).toBe(deviceType);
381
+ expect(device.hasDeviceAcquire()).toBe(true);
382
+ }
383
+ );
384
+
274
385
  test('exposes the current transport usage state in public device snapshots', () => {
275
386
  const getSettings = jest
276
387
  .spyOn(DataManager, 'getSettings')
@@ -38,7 +38,7 @@ describe('DevicePool state lifecycle', () => {
38
38
  expect(getDeviceState).toHaveBeenNthCalledWith(2, { refreshSections: ['settings'] });
39
39
  });
40
40
 
41
- test('keeps legacy ProtocolInfo compatibility scoped to a discovery refresh', async () => {
41
+ test('refreshes Protocol V2 discovery state without legacy-specific options', async () => {
42
42
  const getDeviceState = jest.fn().mockResolvedValue({ status: { mode: 'bootloader' } });
43
43
  const run = jest.fn(async (callback: () => Promise<void>) => callback());
44
44
  const device = {
@@ -49,21 +49,17 @@ describe('DevicePool state lifecycle', () => {
49
49
 
50
50
  await (DevicePool as any)._refreshRuntimeState(device, {
51
51
  refreshRuntimeState: true,
52
- allowLegacyProtocolV2ProtocolInfo: true,
53
52
  });
54
53
 
55
54
  expect(run).toHaveBeenCalledWith(expect.any(Function), {
56
55
  connectProtocol: undefined,
57
56
  forceProtocolDetection: undefined,
58
- allowLegacyProtocolV2ProtocolInfo: true,
59
57
  });
60
58
  expect(getDeviceState).toHaveBeenNthCalledWith(1, {
61
59
  refreshSections: ['status'],
62
- allowLegacyProtocolV2ProtocolInfo: true,
63
60
  });
64
61
  expect(getDeviceState).toHaveBeenNthCalledWith(2, {
65
62
  refreshSections: ['settings'],
66
- allowLegacyProtocolV2ProtocolInfo: true,
67
63
  });
68
64
  });
69
65
 
@@ -67,7 +67,6 @@ describe('Pro 2 device settings options', () => {
67
67
  { label: '2 minutes', valueMs: 120_000 },
68
68
  { label: '5 minutes', valueMs: 300_000 },
69
69
  { label: '10 minutes', valueMs: 600_000 },
70
- { label: '30 minutes', valueMs: 1_800_000 },
71
70
  { label: 'Never', valueMs: PROTOCOL_V2_NEVER_TIMEOUT_MS },
72
71
  ]);
73
72
  });
@@ -0,0 +1,145 @@
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ import FirmwareUpdateV4 from '../../src/api/FirmwareUpdateV4';
4
+ import { DataManager } from '../../src/data-manager';
5
+
6
+ import type { Device } from '../../src/device/Device';
7
+
8
+ jest.mock('../../src/data/config', () => ({
9
+ DEFAULT_DOMAIN: 'https://example.com/',
10
+ getSDKVersion: () => '0.0.0-test',
11
+ }));
12
+
13
+ describe('FirmwareUpdateV4 install polling', () => {
14
+ let getSettingsSpy: jest.SpyInstance;
15
+
16
+ beforeEach(() => {
17
+ getSettingsSpy = jest
18
+ .spyOn(DataManager, 'getSettings')
19
+ .mockReturnValue('react-native' as never);
20
+ });
21
+
22
+ afterEach(() => {
23
+ jest.restoreAllMocks();
24
+ });
25
+
26
+ test('polls status instead of replaying install after React Native BLE releases', async () => {
27
+ const method = new FirmwareUpdateV4({
28
+ id: 1,
29
+ payload: {
30
+ method: 'firmwareUpdateV4',
31
+ connectId: 'pro2-ble',
32
+ },
33
+ });
34
+ const targets = [{ target_id: 4, path: 'vol0:/application_p1.bin' }];
35
+ const typedCall = jest
36
+ .fn()
37
+ .mockRejectedValueOnce(new Error('React Native BLE transport released'))
38
+ .mockResolvedValueOnce({
39
+ message: {
40
+ records: [
41
+ {
42
+ target_id: 4,
43
+ status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED',
44
+ path: 'vol0:/application_p1.bin',
45
+ },
46
+ ],
47
+ },
48
+ });
49
+
50
+ method.device = {
51
+ getCommands: () => ({ typedCall }),
52
+ } as unknown as Device;
53
+ method.postTipMessage = jest.fn();
54
+ method.postProgressMessage = jest.fn();
55
+
56
+ const firmwareUpdate = method as unknown as {
57
+ protocolV2StartFirmwareUpdate: (params: { targets: typeof targets }) => Promise<unknown>;
58
+ waitForProtocolV2FirmwareUpdateComplete: (value: typeof targets) => Promise<void>;
59
+ reconnectProtocolV2Device: () => Promise<void>;
60
+ verifyProtocolV2ReconnectIdentity: () => Promise<Record<string, never>>;
61
+ };
62
+ firmwareUpdate.reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
63
+ firmwareUpdate.verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue({});
64
+
65
+ await expect(
66
+ firmwareUpdate.protocolV2StartFirmwareUpdate({ targets })
67
+ ).resolves.toBeUndefined();
68
+ await expect(
69
+ firmwareUpdate.waitForProtocolV2FirmwareUpdateComplete(targets)
70
+ ).resolves.toBeUndefined();
71
+
72
+ expect(typedCall).toHaveBeenCalledTimes(2);
73
+ expect(typedCall.mock.calls[0]?.[0]).toBe('DeviceFirmwareUpdateRequest');
74
+ expect(typedCall.mock.calls[1]?.[0]).toBe('DeviceFirmwareUpdateStatusGet');
75
+ });
76
+
77
+ test('does not treat an explicit workflow cancellation as an install reboot', async () => {
78
+ const method = new FirmwareUpdateV4({
79
+ id: 1,
80
+ payload: {
81
+ method: 'firmwareUpdateV4',
82
+ connectId: 'pro2-ble',
83
+ },
84
+ });
85
+ const targets = [{ target_id: 4, path: 'vol0:/application_p1.bin' }];
86
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
87
+ const abortController = new AbortController();
88
+ abortController.abort();
89
+
90
+ method.abortSignal = abortController.signal;
91
+ method.device = {
92
+ getCommands: () => ({ typedCall }),
93
+ } as unknown as Device;
94
+
95
+ await expect(
96
+ (
97
+ method as unknown as {
98
+ protocolV2StartFirmwareUpdate: (params: { targets: typeof targets }) => Promise<unknown>;
99
+ }
100
+ ).protocolV2StartFirmwareUpdate({ targets })
101
+ ).rejects.toMatchObject({
102
+ errorCode: HardwareErrorCode.CallQueueActionCancelled,
103
+ });
104
+
105
+ expect(typedCall).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ test.each([
109
+ ['webusb', 'React Native BLE transport released'],
110
+ ['react-native', 'Connection error has occured: Device disconnected'],
111
+ ])(
112
+ 'requires both a BLE environment and an explicit transport release signal: %s / %s',
113
+ async (env, message) => {
114
+ getSettingsSpy.mockReturnValue(env);
115
+ const method = new FirmwareUpdateV4({
116
+ id: 1,
117
+ payload: {
118
+ method: 'firmwareUpdateV4',
119
+ connectId: 'pro2-device',
120
+ },
121
+ });
122
+ const targets = [{ target_id: 4, path: 'vol0:/application_p1.bin' }];
123
+ const typedCall = jest.fn().mockRejectedValue(new Error(message));
124
+
125
+ method.device = {
126
+ getCommands: () => ({ typedCall }),
127
+ } as unknown as Device;
128
+ method.postTipMessage = jest.fn();
129
+ method.postProgressMessage = jest.fn();
130
+
131
+ await expect(
132
+ (
133
+ method as unknown as {
134
+ protocolV2StartFirmwareUpdate: (params: {
135
+ targets: typeof targets;
136
+ }) => Promise<unknown>;
137
+ }
138
+ ).protocolV2StartFirmwareUpdate({ targets })
139
+ ).rejects.toThrow(message);
140
+
141
+ expect(method.postTipMessage).not.toHaveBeenCalled();
142
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
143
+ }
144
+ );
145
+ });
@@ -252,56 +252,6 @@ describe('getDeviceState', () => {
252
252
  }
253
253
  );
254
254
 
255
- test.each([
256
- [EDeviceType.Pro2, DeviceType.PRO2],
257
- [EDeviceType.Neo, DeviceType.NEO],
258
- ] as const)('invalidates cached loader state when %s is cancelled', async (deviceType, type) => {
259
- const typedCall = jest.fn().mockImplementation((requestType: string) => {
260
- if (requestType === 'DeviceInfoGet') {
261
- return {
262
- message: {
263
- protocol_version: 2,
264
- hw: { Device_type: type, serial_no: 'SERIAL-1' },
265
- fw: { application: { version: '5.0.0' } },
266
- },
267
- };
268
- }
269
- if (requestType === 'ProtocolInfoRequest') {
270
- return { message: protocolV2ApplicationInfo };
271
- }
272
- if (requestType === 'DeviceStatusGet') {
273
- return {
274
- message: { init_states: true, unlocked: true, device_id: 'wallet-1' },
275
- };
276
- }
277
- throw new Error(`Unexpected request: ${requestType}`);
278
- });
279
- const cancel = jest.fn().mockResolvedValue(undefined);
280
- const device = createV2Device(typedCall);
281
- (device as any).commands = { cancel, typedCall };
282
- device.updateState(
283
- {
284
- protocol: 'V2',
285
- identity: { deviceType },
286
- status: { mode: 'bootloader' },
287
- raw: { protocolV2ProtocolInfo: getProtocolV2LoaderInfo('bootloader') },
288
- },
289
- 'initialize'
290
- );
291
-
292
- await device.interruptionFromUser();
293
- await device.initialize();
294
-
295
- expect(cancel).toHaveBeenCalledTimes(1);
296
- expect(device.state?.status.mode).toBe('normal');
297
- expect(device.state?.identity.deviceId).toBe('wallet-1');
298
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
299
- 'DeviceInfoGet',
300
- 'ProtocolInfoRequest',
301
- 'DeviceStatusGet',
302
- ]);
303
- });
304
-
305
255
  test.each(['bootloader', 'romloader'] as const)(
306
256
  'keeps live %s mode after refreshing cached loader state',
307
257
  async mode => {
@@ -104,7 +104,19 @@ describe('getLogBlockLabel', () => {
104
104
  });
105
105
  });
106
106
 
107
- it.each(['deviceUploadNft', 'deviceUploadWallpaper', 'uploadPortfolio', 'fileWrite', 'fileRead'])(
107
+ it.each(['deviceUploadNft', 'deviceUploadWallpaper', 'uploadPortfolio'])(
108
+ 'skips large Base64 resource payload logging for %s',
109
+ method => {
110
+ const request = { method, path: 'resource.bin', data: 'A'.repeat(1024 * 1024) };
111
+ expect(getLogBlockLabel(request)).toBe(method);
112
+ expect(getSafeLogPayload(request, method)).toEqual({
113
+ method,
114
+ payload: '[REDACTED]',
115
+ });
116
+ }
117
+ );
118
+
119
+ it.each(['fileWrite', 'fileRead'])(
108
120
  'keeps metadata and replaces binary payloads with their size for %s',
109
121
  method => {
110
122
  const request = { method, path: 'resource.bin', data: new Uint8Array(1024) };