@onekeyfe/hd-core 1.2.8-alpha.13 → 1.2.8-alpha.130
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/__tests__/allNetworkGetAddress-privacy-networks.test.ts +78 -0
- package/__tests__/core-dispose.test.ts +114 -1
- package/__tests__/core-error-output.test.ts +21 -0
- package/__tests__/device-connector-protocol.test.ts +5 -2
- package/__tests__/device-lifecycle-events.test.ts +118 -3
- package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
- package/__tests__/logBlockEvent.test.ts +292 -0
- package/__tests__/logger.test.ts +18 -0
- package/__tests__/monero-get-address.test.ts +147 -0
- package/__tests__/monero-key-image-sync.test.ts +272 -0
- package/__tests__/monero-sign-transaction.test.ts +436 -0
- package/__tests__/protocol-v2-resources.test.ts +14 -0
- package/__tests__/protocol-v2.test.ts +845 -3
- package/__tests__/protocolV2FileWrite.test.ts +434 -7
- package/__tests__/search-devices.test.ts +36 -0
- package/__tests__/usb-identity-probe.test.ts +358 -0
- package/__tests__/zcash-protobuf.test.ts +141 -0
- package/__tests__/zcash-sign-pczt.test.ts +446 -0
- package/dist/api/BaseMethod.d.ts.map +1 -1
- package/dist/api/FileWrite.d.ts.map +1 -1
- package/dist/api/FirmwareUpdateV4.d.ts +5 -0
- package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
- package/dist/api/SearchDevices.d.ts +21 -1
- package/dist/api/SearchDevices.d.ts.map +1 -1
- package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
- package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
- package/dist/api/helpers/pathUtils.d.ts +1 -0
- package/dist/api/helpers/pathUtils.d.ts.map +1 -1
- package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
- package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
- package/dist/api/index.d.ts +6 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/monero/MoneroGetAddress.d.ts +11 -0
- package/dist/api/monero/MoneroGetAddress.d.ts.map +1 -0
- package/dist/api/monero/MoneroGetWatchKey.d.ts +11 -0
- package/dist/api/monero/MoneroGetWatchKey.d.ts.map +1 -0
- package/dist/api/monero/MoneroKeyImageSync.d.ts +20 -0
- package/dist/api/monero/MoneroKeyImageSync.d.ts.map +1 -0
- package/dist/api/monero/MoneroSignTransaction.d.ts +17 -0
- package/dist/api/monero/MoneroSignTransaction.d.ts.map +1 -0
- package/dist/api/monero/helpers.d.ts +5 -0
- package/dist/api/monero/helpers.d.ts.map +1 -0
- package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
- package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
- package/dist/api/zcash/ZcashGetAddress.d.ts +12 -0
- package/dist/api/zcash/ZcashGetAddress.d.ts.map +1 -0
- package/dist/api/zcash/ZcashSignPczt.d.ts +16 -0
- package/dist/api/zcash/ZcashSignPczt.d.ts.map +1 -0
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/device/Device.d.ts +1 -0
- package/dist/device/Device.d.ts.map +1 -1
- package/dist/device/DeviceCommands.d.ts +4 -4
- package/dist/events/logBlockEvent.d.ts +10 -0
- package/dist/events/logBlockEvent.d.ts.map +1 -1
- package/dist/events/ui-request.d.ts +2 -0
- package/dist/events/ui-request.d.ts.map +1 -1
- package/dist/index.d.ts +183 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5625 -4192
- package/dist/inject.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/resources.d.ts +1 -0
- package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
- package/dist/types/api/allNetworkGetAddress.d.ts +5 -1
- package/dist/types/api/allNetworkGetAddress.d.ts.map +1 -1
- package/dist/types/api/export.d.ts +6 -0
- package/dist/types/api/export.d.ts.map +1 -1
- package/dist/types/api/index.d.ts +12 -0
- package/dist/types/api/index.d.ts.map +1 -1
- package/dist/types/api/moneroGetAddress.d.ts +15 -0
- package/dist/types/api/moneroGetAddress.d.ts.map +1 -0
- package/dist/types/api/moneroGetWatchKey.d.ts +15 -0
- package/dist/types/api/moneroGetWatchKey.d.ts.map +1 -0
- package/dist/types/api/moneroKeyImageSync.d.ts +24 -0
- package/dist/types/api/moneroKeyImageSync.d.ts.map +1 -0
- package/dist/types/api/moneroSignTransaction.d.ts +32 -0
- package/dist/types/api/moneroSignTransaction.d.ts.map +1 -0
- package/dist/types/api/zcashGetAddress.d.ts +22 -0
- package/dist/types/api/zcashGetAddress.d.ts.map +1 -0
- package/dist/types/api/zcashSignPczt.d.ts +9 -0
- package/dist/types/api/zcashSignPczt.d.ts.map +1 -0
- package/dist/types/device.d.ts +10 -0
- package/dist/types/device.d.ts.map +1 -1
- package/dist/utils/logger.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/BaseMethod.ts +2 -0
- package/src/api/FileWrite.ts +7 -1
- package/src/api/FirmwareUpdateV4.ts +358 -74
- package/src/api/SearchDevices.ts +172 -7
- package/src/api/allnetwork/AllNetworkGetAddressBase.ts +38 -1
- package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
- package/src/api/helpers/pathUtils.ts +23 -0
- package/src/api/helpers/protocolV2FileWrite.ts +375 -111
- package/src/api/index.ts +7 -0
- package/src/api/monero/MoneroGetAddress.ts +71 -0
- package/src/api/monero/MoneroGetWatchKey.ts +69 -0
- package/src/api/monero/MoneroKeyImageSync.ts +164 -0
- package/src/api/monero/MoneroSignTransaction.ts +319 -0
- package/src/api/monero/helpers.ts +28 -0
- package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
- package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
- package/src/api/zcash/ZcashGetAddress.ts +119 -0
- package/src/api/zcash/ZcashSignPczt.ts +152 -0
- package/src/core/index.ts +49 -17
- package/src/data/messages/messages-protocol-v2.json +138 -1
- package/src/device/Device.ts +21 -3
- package/src/device/DeviceConnector.ts +4 -4
- package/src/events/logBlockEvent.ts +55 -3
- package/src/events/ui-request.ts +2 -0
- package/src/index.ts +1 -0
- package/src/inject.ts +13 -0
- package/src/protocols/protocol-v2/resources.ts +6 -1
- package/src/types/api/allNetworkGetAddress.ts +9 -1
- package/src/types/api/export.ts +15 -0
- package/src/types/api/index.ts +20 -0
- package/src/types/api/moneroGetAddress.ts +27 -0
- package/src/types/api/moneroGetWatchKey.ts +28 -0
- package/src/types/api/moneroKeyImageSync.ts +45 -0
- package/src/types/api/moneroSignTransaction.ts +54 -0
- package/src/types/api/zcashGetAddress.ts +35 -0
- package/src/types/api/zcashSignPczt.ts +18 -0
- package/src/types/device.ts +18 -0
- package/src/utils/logger.ts +4 -0
|
@@ -34,6 +34,7 @@ import DeviceSettings from '../src/api/device/DeviceSettings';
|
|
|
34
34
|
import DeviceUnlock from '../src/api/device/DeviceUnlock';
|
|
35
35
|
import DeviceWipe from '../src/api/device/DeviceWipe';
|
|
36
36
|
import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
|
|
37
|
+
import DeviceUploadNft from '../src/api/protocol-v2/DeviceUploadNft';
|
|
37
38
|
import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
|
|
38
39
|
import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
|
|
39
40
|
import FilesystemPermissionFix from '../src/api/protocol-v2/FilesystemPermissionFix';
|
|
@@ -44,6 +45,7 @@ import FirmwareUpdateV3 from '../src/api/FirmwareUpdateV3';
|
|
|
44
45
|
import FirmwareUpdateV4, {
|
|
45
46
|
assertProtocolV2FirmwareTargetsSupported,
|
|
46
47
|
assertProtocolV2ReconnectIdentity,
|
|
48
|
+
supportsProtocolV2BleFileWriteWindow,
|
|
47
49
|
} from '../src/api/FirmwareUpdateV4';
|
|
48
50
|
import GetPassphraseState from '../src/api/GetPassphraseState';
|
|
49
51
|
import GetOnekeyFeatures from '../src/api/GetOnekeyFeatures';
|
|
@@ -67,6 +69,7 @@ import StellarGetAddress from '../src/api/stellar/StellarGetAddress';
|
|
|
67
69
|
import BenfenSignMessage from '../src/api/benfen/BenfenSignMessage';
|
|
68
70
|
import { getBitcoinForkSupportedProtocols } from '../src/api/btc/helpers/versionLimit';
|
|
69
71
|
import { DataManager } from '../src/data-manager';
|
|
72
|
+
import { LoggerNames, getLogger } from '../src/utils/logger';
|
|
70
73
|
import { createCoreApi } from '../src/inject';
|
|
71
74
|
import { Device, preloadSessionCache } from '../src/device/Device';
|
|
72
75
|
import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
|
|
@@ -120,7 +123,13 @@ import {
|
|
|
120
123
|
|
|
121
124
|
import type { DeviceCommands } from '../src/device/DeviceCommands';
|
|
122
125
|
import type { Features } from '../src/types';
|
|
123
|
-
import type {
|
|
126
|
+
import type {
|
|
127
|
+
DeviceStatus,
|
|
128
|
+
FilesystemFileWrite,
|
|
129
|
+
MessageFromOneKey,
|
|
130
|
+
ProtocolInfo,
|
|
131
|
+
TransportCallOptions,
|
|
132
|
+
} from '@onekeyfe/hd-transport';
|
|
124
133
|
|
|
125
134
|
jest.mock('../src/data/config', () => ({
|
|
126
135
|
getSDKVersion: jest.fn(() => '1.0.0'),
|
|
@@ -653,6 +662,162 @@ describe('UploadPortfolio', () => {
|
|
|
653
662
|
});
|
|
654
663
|
});
|
|
655
664
|
|
|
665
|
+
describe('Pro2 / Neo application asset pipelines', () => {
|
|
666
|
+
afterEach(() => jest.restoreAllMocks());
|
|
667
|
+
|
|
668
|
+
test.each(
|
|
669
|
+
[EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
|
|
670
|
+
(['wallpaper', 'nft', 'portfolio'] as const).flatMap(asset =>
|
|
671
|
+
[
|
|
672
|
+
{ fail: false, recover: false },
|
|
673
|
+
{ fail: true, recover: false },
|
|
674
|
+
{ fail: true, recover: true },
|
|
675
|
+
].map(outcome => ({ deviceType, asset, ...outcome }))
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
)(
|
|
679
|
+
'$deviceType $asset applies only a fully confirmed upload (fail=$fail, recover=$recover)',
|
|
680
|
+
async ({ deviceType, asset, fail, recover }) => {
|
|
681
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
|
|
682
|
+
jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
|
|
683
|
+
const failure = new Error('BLE response timeout');
|
|
684
|
+
let pipelineAttempts = 0;
|
|
685
|
+
const applyMessage = {
|
|
686
|
+
wallpaper: 'DeviceSettingsSet',
|
|
687
|
+
nft: 'NftUpdate',
|
|
688
|
+
portfolio: 'PortfolioUpdate',
|
|
689
|
+
}[asset];
|
|
690
|
+
const typedCall = jest.fn(
|
|
691
|
+
async (
|
|
692
|
+
name: string,
|
|
693
|
+
_response: string,
|
|
694
|
+
first: FilesystemFileWrite,
|
|
695
|
+
options?: TransportCallOptions
|
|
696
|
+
) => {
|
|
697
|
+
if (name !== 'FilesystemFileWrite') return { type: 'Success', message: {} };
|
|
698
|
+
const pipeline = options?.fileWritePipeline;
|
|
699
|
+
if (!pipeline) {
|
|
700
|
+
if (fail && !recover) throw failure;
|
|
701
|
+
const { data } = first.file;
|
|
702
|
+
if (!(data instanceof Uint8Array)) throw new Error('Expected Uint8Array file data');
|
|
703
|
+
return {
|
|
704
|
+
type: 'FilesystemFile',
|
|
705
|
+
message: { ...first.file, processed_byte: first.file.offset + data.byteLength },
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
pipelineAttempts += 1;
|
|
709
|
+
let request: FilesystemFileWrite | undefined = first;
|
|
710
|
+
let response: MessageFromOneKey = { type: 'FilesystemFile', message: {} };
|
|
711
|
+
while (request) {
|
|
712
|
+
const { data } = request.file;
|
|
713
|
+
if (!(data instanceof Uint8Array)) throw new Error('Expected Uint8Array file data');
|
|
714
|
+
expect(data.byteLength).toBeLessThanOrEqual(1800);
|
|
715
|
+
expect(typedCall.mock.calls.some(call => call[0] === applyMessage)).toBe(false);
|
|
716
|
+
response = {
|
|
717
|
+
type: 'FilesystemFile',
|
|
718
|
+
message: { ...request.file, processed_byte: request.file.offset + data.byteLength },
|
|
719
|
+
};
|
|
720
|
+
pipeline.onResponse(response);
|
|
721
|
+
if (fail && (!recover || pipelineAttempts === 1)) throw failure;
|
|
722
|
+
request = (await pipeline.next()) as FilesystemFileWrite | undefined;
|
|
723
|
+
}
|
|
724
|
+
return response;
|
|
725
|
+
}
|
|
726
|
+
);
|
|
727
|
+
let method: DeviceUploadWallpaper | DeviceUploadNft | UploadPortfolio;
|
|
728
|
+
if (asset === 'wallpaper') {
|
|
729
|
+
method = new DeviceUploadWallpaper({
|
|
730
|
+
id: 1,
|
|
731
|
+
payload: { method: 'deviceUploadWallpaper', jpegBase64: createJpegBase64(604, 1024) },
|
|
732
|
+
});
|
|
733
|
+
} else if (asset === 'nft') {
|
|
734
|
+
method = new DeviceUploadNft({
|
|
735
|
+
id: 1,
|
|
736
|
+
payload: {
|
|
737
|
+
method: 'deviceUploadNft',
|
|
738
|
+
imageJpegBase64: createJpegBase64(540, 540),
|
|
739
|
+
thumbnailJpegBase64: createJpegBase64(263, 263),
|
|
740
|
+
title: 'NFT',
|
|
741
|
+
subtitle: '',
|
|
742
|
+
timestampMs: 1_760_000_000_000,
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
} else {
|
|
746
|
+
method = new UploadPortfolio({
|
|
747
|
+
id: 1,
|
|
748
|
+
payload: {
|
|
749
|
+
method: 'uploadPortfolio',
|
|
750
|
+
packageBase64: Buffer.from(new Uint8Array(3601)).toString('base64'),
|
|
751
|
+
uiMode: 'progress',
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const commands = { typedCall, device: undefined as Device | undefined };
|
|
756
|
+
const acquire = jest.fn().mockResolvedValue(undefined);
|
|
757
|
+
method.device = stubDevice({
|
|
758
|
+
commands,
|
|
759
|
+
originalDescriptor: { protocolType: 'V2' },
|
|
760
|
+
features: { deviceType, mode: 'normal' },
|
|
761
|
+
state: {
|
|
762
|
+
identity: { serialNo: 'test-asset-device', deviceType },
|
|
763
|
+
status: { mode: 'normal', initialized: false },
|
|
764
|
+
versions: { firmware: '1.0.3', bootloader: '1.0.0' },
|
|
765
|
+
},
|
|
766
|
+
acquire,
|
|
767
|
+
initialize: jest.fn().mockResolvedValue(undefined),
|
|
768
|
+
ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
|
|
769
|
+
version: 2,
|
|
770
|
+
supported_messages: [60412, 60805, 60809, 61400, 61500],
|
|
771
|
+
}),
|
|
772
|
+
refreshProtocolV2SettingsAfterMutation: jest.fn().mockResolvedValue({}),
|
|
773
|
+
}) as unknown as Device;
|
|
774
|
+
commands.device = method.device;
|
|
775
|
+
method.postMessage = jest.fn();
|
|
776
|
+
method.init();
|
|
777
|
+
|
|
778
|
+
if (fail && !recover) {
|
|
779
|
+
await expect(method.run()).rejects.toBe(failure);
|
|
780
|
+
expect(typedCall.mock.calls.some(call => call[0] === applyMessage)).toBe(false);
|
|
781
|
+
expect(method.postMessage).not.toHaveBeenCalledWith(
|
|
782
|
+
expect.objectContaining({ type: UI_REQUEST.REQUEST_BUTTON })
|
|
783
|
+
);
|
|
784
|
+
} else {
|
|
785
|
+
await method.run();
|
|
786
|
+
expect(typedCall.mock.calls[typedCall.mock.calls.length - 1][0]).toBe(applyMessage);
|
|
787
|
+
expect(typedCall.mock.calls.filter(call => call[0] === applyMessage)).toHaveLength(1);
|
|
788
|
+
expect(method.postMessage).toHaveBeenCalledWith(
|
|
789
|
+
expect.objectContaining({
|
|
790
|
+
type: UI_REQUEST.DEVICE_PROGRESS,
|
|
791
|
+
payload: expect.objectContaining({ progress: 100 }),
|
|
792
|
+
})
|
|
793
|
+
);
|
|
794
|
+
if (asset === 'wallpaper') {
|
|
795
|
+
expect(method.postMessage).toHaveBeenLastCalledWith(
|
|
796
|
+
expect.objectContaining({
|
|
797
|
+
type: UI_REQUEST.REQUEST_BUTTON,
|
|
798
|
+
payload: expect.objectContaining({
|
|
799
|
+
deviceOnly: true,
|
|
800
|
+
completion: 'operation-completed',
|
|
801
|
+
operation: 'deviceUploadWallpaper',
|
|
802
|
+
}),
|
|
803
|
+
})
|
|
804
|
+
);
|
|
805
|
+
const postMessageMock = method.postMessage as jest.Mock;
|
|
806
|
+
const promptOrder =
|
|
807
|
+
postMessageMock.mock.invocationCallOrder[postMessageMock.mock.calls.length - 1];
|
|
808
|
+
const applyOrder = typedCall.mock.invocationCallOrder[typedCall.mock.calls.length - 1];
|
|
809
|
+
expect(promptOrder).toBeLessThan(applyOrder);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const recoveryAttempts = recover ? 1 : 3;
|
|
813
|
+
expect(typedCall.mock.calls.filter(call => call[3]?.fileWritePipeline)).toHaveLength(
|
|
814
|
+
fail ? recoveryAttempts + 1 : 1
|
|
815
|
+
);
|
|
816
|
+
expect(acquire).toHaveBeenCalledTimes(fail ? recoveryAttempts : 0);
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
656
821
|
const descriptor = {
|
|
657
822
|
id: 'ble-id',
|
|
658
823
|
path: 'usb-path',
|
|
@@ -4991,6 +5156,76 @@ describe('Protocol V2 firmware update targets', () => {
|
|
|
4991
5156
|
expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
|
|
4992
5157
|
});
|
|
4993
5158
|
|
|
5159
|
+
test.each([undefined, 1500])(
|
|
5160
|
+
'uses short BLE readiness attempts and reconnects after a lost response (override %s)',
|
|
5161
|
+
async protocolV2DeviceInfoTimeoutMs => {
|
|
5162
|
+
const method = new FirmwareUpdateV4({
|
|
5163
|
+
id: 1,
|
|
5164
|
+
payload: { method: 'firmwareUpdateV4', protocolV2DeviceInfoTimeoutMs },
|
|
5165
|
+
});
|
|
5166
|
+
const typedCall = jest
|
|
5167
|
+
.fn()
|
|
5168
|
+
.mockRejectedValueOnce(new Error('BLE response timeout'))
|
|
5169
|
+
.mockResolvedValue({ type: 'DeviceInfo', message: protocolV2BootloaderDeviceInfo });
|
|
5170
|
+
method.device = stubDevice({
|
|
5171
|
+
originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
|
|
5172
|
+
getCommands: () => ({ typedCall }),
|
|
5173
|
+
probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({ mode: 'bootloader' }),
|
|
5174
|
+
}) as unknown as Device;
|
|
5175
|
+
jest.spyOn(method, 'isBleReconnect').mockReturnValue(true);
|
|
5176
|
+
const update = method as unknown as {
|
|
5177
|
+
reconnectProtocolV2Device: jest.Mock;
|
|
5178
|
+
waitForProtocolV2BootloaderMode: (timeout: number, interval: number) => Promise<unknown>;
|
|
5179
|
+
protocolV2ExpectedSerialNumber: string;
|
|
5180
|
+
getProtocolV2DeviceInfoTimeout: () => number;
|
|
5181
|
+
};
|
|
5182
|
+
update.protocolV2ExpectedSerialNumber = 'PR9999999999';
|
|
5183
|
+
update.reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
|
|
5184
|
+
|
|
5185
|
+
await update.waitForProtocolV2BootloaderMode(60_000, 0);
|
|
5186
|
+
|
|
5187
|
+
expect(typedCall).toHaveBeenCalledTimes(2);
|
|
5188
|
+
expect(typedCall).toHaveBeenNthCalledWith(
|
|
5189
|
+
1,
|
|
5190
|
+
'DeviceInfoGet',
|
|
5191
|
+
'DeviceInfo',
|
|
5192
|
+
expect.anything(),
|
|
5193
|
+
{ timeoutMs: protocolV2DeviceInfoTimeoutMs ?? 5000 }
|
|
5194
|
+
);
|
|
5195
|
+
expect(update.reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
|
|
5196
|
+
expect(update.reconnectProtocolV2Device).toHaveBeenCalledWith({ skipProtocolProbe: true });
|
|
5197
|
+
expect(update.getProtocolV2DeviceInfoTimeout()).toBe(protocolV2DeviceInfoTimeoutMs ?? 30_000);
|
|
5198
|
+
}
|
|
5199
|
+
);
|
|
5200
|
+
|
|
5201
|
+
test('disconnects the pre-reboot BLE connection before waiting for bootloader readiness', async () => {
|
|
5202
|
+
const method = new FirmwareUpdateV4({ id: 1, payload: { method: 'firmwareUpdateV4' } });
|
|
5203
|
+
const disconnect = jest.fn().mockResolvedValue(undefined);
|
|
5204
|
+
method.device = stubDevice({
|
|
5205
|
+
originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
|
|
5206
|
+
deviceConnector: { disconnect },
|
|
5207
|
+
}) as unknown as Device;
|
|
5208
|
+
jest.spyOn(method, 'isBleReconnect').mockReturnValue(true);
|
|
5209
|
+
method.postTipMessage = jest.fn();
|
|
5210
|
+
const update = method as unknown as {
|
|
5211
|
+
protocolV2Reboot: jest.Mock;
|
|
5212
|
+
waitForProtocolV2BootloaderMode: jest.Mock;
|
|
5213
|
+
rebootProtocolV2ToBootloader: () => Promise<boolean>;
|
|
5214
|
+
};
|
|
5215
|
+
update.protocolV2Reboot = jest.fn().mockResolvedValue(undefined);
|
|
5216
|
+
update.waitForProtocolV2BootloaderMode = jest.fn().mockResolvedValue({ mode: 'bootloader' });
|
|
5217
|
+
|
|
5218
|
+
await expect(update.rebootProtocolV2ToBootloader()).resolves.toBe(true);
|
|
5219
|
+
|
|
5220
|
+
expect(disconnect).toHaveBeenCalledWith('ble-id');
|
|
5221
|
+
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
5222
|
+
update.protocolV2Reboot.mock.invocationCallOrder[0]
|
|
5223
|
+
);
|
|
5224
|
+
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
|
|
5225
|
+
update.waitForProtocolV2BootloaderMode.mock.invocationCallOrder[0]
|
|
5226
|
+
);
|
|
5227
|
+
});
|
|
5228
|
+
|
|
4994
5229
|
test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
|
|
4995
5230
|
const method = new FirmwareUpdateV4({
|
|
4996
5231
|
id: 1,
|
|
@@ -8883,6 +9118,587 @@ describe('Protocol V2 firmware update targets', () => {
|
|
|
8883
9118
|
expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
|
|
8884
9119
|
});
|
|
8885
9120
|
|
|
9121
|
+
describe('RESC header check link recovery', () => {
|
|
9122
|
+
const currentHeader = {
|
|
9123
|
+
version: [1, 0, 0],
|
|
9124
|
+
payloadHash: '11'.repeat(64),
|
|
9125
|
+
headerHash: '22'.repeat(64),
|
|
9126
|
+
};
|
|
9127
|
+
const linkLost = () =>
|
|
9128
|
+
ERRORS.TypedError(
|
|
9129
|
+
HardwareErrorCode.RuntimeError,
|
|
9130
|
+
'Device protocol has not been detected for AA:BB'
|
|
9131
|
+
);
|
|
9132
|
+
const createResource = (name: string) => ({
|
|
9133
|
+
name,
|
|
9134
|
+
source: { size: 3, readAt: jest.fn(), close: jest.fn() },
|
|
9135
|
+
devicePath: `vol0:/bundles/${name}`,
|
|
9136
|
+
...currentHeader,
|
|
9137
|
+
});
|
|
9138
|
+
const createMethod = () => {
|
|
9139
|
+
const method = new FirmwareUpdateV4({
|
|
9140
|
+
id: 1,
|
|
9141
|
+
payload: {
|
|
9142
|
+
method: 'firmwareUpdateV4',
|
|
9143
|
+
},
|
|
9144
|
+
});
|
|
9145
|
+
method.postTipMessage = jest.fn();
|
|
9146
|
+
method.postProgressMessage = jest.fn();
|
|
9147
|
+
(method as any).protocolV2SourceUpdateProcess = jest.fn(
|
|
9148
|
+
({ processedSize }: { processedSize: number }) => Promise.resolve(processedSize + 3)
|
|
9149
|
+
);
|
|
9150
|
+
(method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
|
|
9151
|
+
(method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
|
|
9152
|
+
return method;
|
|
9153
|
+
};
|
|
9154
|
+
|
|
9155
|
+
test('reconnects and asks again when the link drops during a header check', async () => {
|
|
9156
|
+
const method = createMethod();
|
|
9157
|
+
(method as any).readProtocolV2DeviceFileHeader = jest
|
|
9158
|
+
.fn()
|
|
9159
|
+
.mockRejectedValueOnce(linkLost())
|
|
9160
|
+
.mockResolvedValue(currentHeader);
|
|
9161
|
+
|
|
9162
|
+
await (method as any).executeProtocolV2TransferPhase({
|
|
9163
|
+
installSources: [],
|
|
9164
|
+
resourceSources: [createResource('images.okpkg'), createResource('fonts.okpkg')],
|
|
9165
|
+
});
|
|
9166
|
+
|
|
9167
|
+
expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
|
|
9168
|
+
expect((method as any).readProtocolV2DeviceFileHeader).toHaveBeenCalledTimes(3);
|
|
9169
|
+
expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
|
|
9170
|
+
});
|
|
9171
|
+
|
|
9172
|
+
test.each([
|
|
9173
|
+
['BLE disconnect', () => ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected)],
|
|
9174
|
+
['BLE timeout', () => ERRORS.TypedError(HardwareErrorCode.BleTimeoutError)],
|
|
9175
|
+
[
|
|
9176
|
+
'invalidated Protocol V2 link',
|
|
9177
|
+
() =>
|
|
9178
|
+
Object.assign(new Error('Protocol V2 link generation changed'), {
|
|
9179
|
+
name: 'ProtocolV2LinkError',
|
|
9180
|
+
code: 'generation',
|
|
9181
|
+
}),
|
|
9182
|
+
],
|
|
9183
|
+
[
|
|
9184
|
+
'released React Native BLE transport',
|
|
9185
|
+
() => new Error('React Native BLE transport released'),
|
|
9186
|
+
],
|
|
9187
|
+
['released Electron BLE transport', () => new Error('Electron BLE transport released')],
|
|
9188
|
+
['cleaned Electron BLE state', () => new Error('Electron BLE device state cleaned')],
|
|
9189
|
+
[
|
|
9190
|
+
'Electron BLE device that is no longer connected',
|
|
9191
|
+
() => ERRORS.TypedError(HardwareErrorCode.TransportNotFound, 'Device AA:BB not connected'),
|
|
9192
|
+
],
|
|
9193
|
+
])('rethrows a %s from the header check for recovery', async (_label, createError) => {
|
|
9194
|
+
const method = createMethod();
|
|
9195
|
+
const error = createError();
|
|
9196
|
+
(method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(error);
|
|
9197
|
+
|
|
9198
|
+
await expect(
|
|
9199
|
+
(method as any).isProtocolV2ResourceBundleUpToDate(createResource('images.okpkg'))
|
|
9200
|
+
).rejects.toBe(error);
|
|
9201
|
+
});
|
|
9202
|
+
|
|
9203
|
+
test('treats a device-side header read failure as stale without reconnecting', async () => {
|
|
9204
|
+
const method = createMethod();
|
|
9205
|
+
(method as any).readProtocolV2DeviceFileHeader = jest
|
|
9206
|
+
.fn()
|
|
9207
|
+
.mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'));
|
|
9208
|
+
|
|
9209
|
+
await (method as any).executeProtocolV2TransferPhase({
|
|
9210
|
+
installSources: [],
|
|
9211
|
+
resourceSources: [createResource('images.okpkg')],
|
|
9212
|
+
});
|
|
9213
|
+
|
|
9214
|
+
expect((method as any).recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
|
|
9215
|
+
expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
|
|
9216
|
+
});
|
|
9217
|
+
|
|
9218
|
+
test('limits header-check reconnects per transfer phase', async () => {
|
|
9219
|
+
const method = createMethod();
|
|
9220
|
+
(method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
|
|
9221
|
+
|
|
9222
|
+
await (method as any).executeProtocolV2TransferPhase({
|
|
9223
|
+
installSources: [],
|
|
9224
|
+
resourceSources: ['a.okpkg', 'b.okpkg', 'c.okpkg'].map(createResource),
|
|
9225
|
+
});
|
|
9226
|
+
|
|
9227
|
+
expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
|
|
9228
|
+
expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(3);
|
|
9229
|
+
});
|
|
9230
|
+
|
|
9231
|
+
test.each([
|
|
9232
|
+
['no device to reconnect to', () => ERRORS.TypedError(HardwareErrorCode.DeviceNotFound)],
|
|
9233
|
+
[
|
|
9234
|
+
'an identity mismatch',
|
|
9235
|
+
() =>
|
|
9236
|
+
ERRORS.TypedError(
|
|
9237
|
+
HardwareErrorCode.DeviceNotFound,
|
|
9238
|
+
'Protocol V2 reconnect physical identity mismatch: expected A, received B'
|
|
9239
|
+
),
|
|
9240
|
+
],
|
|
9241
|
+
[
|
|
9242
|
+
'an unavailable identity',
|
|
9243
|
+
() =>
|
|
9244
|
+
ERRORS.TypedError(
|
|
9245
|
+
HardwareErrorCode.DeviceNotFound,
|
|
9246
|
+
'Protocol V2 reconnect physical identity unavailable: expected path A'
|
|
9247
|
+
),
|
|
9248
|
+
],
|
|
9249
|
+
[
|
|
9250
|
+
'a failed identity query',
|
|
9251
|
+
() => ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'),
|
|
9252
|
+
],
|
|
9253
|
+
])(
|
|
9254
|
+
'aborts instead of resending when the header-check reconnect fails with %s',
|
|
9255
|
+
async (_label, createError) => {
|
|
9256
|
+
const method = createMethod();
|
|
9257
|
+
const error = createError();
|
|
9258
|
+
(method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
|
|
9259
|
+
(method as any).recoverProtocolV2FileTransfer = jest.fn().mockRejectedValue(error);
|
|
9260
|
+
|
|
9261
|
+
await expect(
|
|
9262
|
+
(method as any).executeProtocolV2TransferPhase({
|
|
9263
|
+
installSources: [],
|
|
9264
|
+
resourceSources: [createResource('images.okpkg'), createResource('fonts.okpkg')],
|
|
9265
|
+
})
|
|
9266
|
+
).rejects.toBe(error);
|
|
9267
|
+
expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
|
|
9268
|
+
expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
|
|
9269
|
+
}
|
|
9270
|
+
);
|
|
9271
|
+
|
|
9272
|
+
test('writes nothing to a reconnected device whose identity query fails', async () => {
|
|
9273
|
+
const method = createMethod();
|
|
9274
|
+
// Run the real recovery: the session is replaced before the identity check.
|
|
9275
|
+
delete (method as any).recoverProtocolV2FileTransfer;
|
|
9276
|
+
const initialize = jest.fn();
|
|
9277
|
+
(method as any).device = stubDevice({ initialize });
|
|
9278
|
+
(method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
|
|
9279
|
+
(method as any).verifyProtocolV2ReconnectIdentity = jest
|
|
9280
|
+
.fn()
|
|
9281
|
+
.mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'));
|
|
9282
|
+
(method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
|
|
9283
|
+
|
|
9284
|
+
await expect(
|
|
9285
|
+
(method as any).executeProtocolV2TransferPhase({
|
|
9286
|
+
installSources: [],
|
|
9287
|
+
resourceSources: [createResource('images.okpkg')],
|
|
9288
|
+
})
|
|
9289
|
+
).rejects.toMatchObject({ message: expect.stringContaining('Failure: busy') });
|
|
9290
|
+
expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
|
|
9291
|
+
expect(initialize).not.toHaveBeenCalled();
|
|
9292
|
+
expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
|
|
9293
|
+
});
|
|
9294
|
+
|
|
9295
|
+
test('reports a cancel that tears the header-check reconnect down as the cancel', async () => {
|
|
9296
|
+
const method = createMethod();
|
|
9297
|
+
delete (method as any).recoverProtocolV2FileTransfer;
|
|
9298
|
+
const abortController = new AbortController();
|
|
9299
|
+
method.abortSignal = abortController.signal;
|
|
9300
|
+
(method as any).reconnectProtocolV2Device = jest.fn(() => {
|
|
9301
|
+
abortController.abort();
|
|
9302
|
+
return Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
|
|
9303
|
+
});
|
|
9304
|
+
(method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
|
|
9305
|
+
|
|
9306
|
+
await expect(
|
|
9307
|
+
(method as any).executeProtocolV2TransferPhase({
|
|
9308
|
+
installSources: [],
|
|
9309
|
+
resourceSources: [createResource('images.okpkg')],
|
|
9310
|
+
})
|
|
9311
|
+
).rejects.toMatchObject({ errorCode: HardwareErrorCode.CallQueueActionCancelled });
|
|
9312
|
+
expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
|
|
9313
|
+
});
|
|
9314
|
+
});
|
|
9315
|
+
|
|
9316
|
+
test.each([
|
|
9317
|
+
['1.0.3', '1.0.3', true],
|
|
9318
|
+
['1.0.10', '1.0.3', true],
|
|
9319
|
+
['1.0.2', '1.0.3', false],
|
|
9320
|
+
['0.0.0.1', '1.0.3', false],
|
|
9321
|
+
['1.0.3', '', false],
|
|
9322
|
+
] as const)(
|
|
9323
|
+
'gates the BLE file write window on bootloader %s with minimum %s',
|
|
9324
|
+
(bootloaderVersion, minBootloaderVersion, expected) => {
|
|
9325
|
+
expect(supportsProtocolV2BleFileWriteWindow(bootloaderVersion, minBootloaderVersion)).toBe(
|
|
9326
|
+
expected
|
|
9327
|
+
);
|
|
9328
|
+
}
|
|
9329
|
+
);
|
|
9330
|
+
|
|
9331
|
+
test('keeps released 1.0.2 bootloaders serial by default', () => {
|
|
9332
|
+
expect(supportsProtocolV2BleFileWriteWindow('1.0.3')).toBe(true);
|
|
9333
|
+
expect(supportsProtocolV2BleFileWriteWindow('1.0.2')).toBe(false);
|
|
9334
|
+
});
|
|
9335
|
+
|
|
9336
|
+
test.each([
|
|
9337
|
+
['react-native', 'bootloader', true],
|
|
9338
|
+
['desktop-web-ble', 'bootloader', true],
|
|
9339
|
+
['lowlevel', 'bootloader', false],
|
|
9340
|
+
['webusb', 'bootloader', false],
|
|
9341
|
+
['react-native', 'normal', false],
|
|
9342
|
+
] as const)(
|
|
9343
|
+
'enables the BLE file write window for %s in %s mode only with a supported bootloader',
|
|
9344
|
+
(env, mode, expected) => {
|
|
9345
|
+
const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
|
|
9346
|
+
const method = new FirmwareUpdateV4({
|
|
9347
|
+
id: 1,
|
|
9348
|
+
payload: {
|
|
9349
|
+
method: 'firmwareUpdateV4',
|
|
9350
|
+
},
|
|
9351
|
+
});
|
|
9352
|
+
(method as any).device = stubDevice({
|
|
9353
|
+
features: { mode, bootloaderMode: mode === 'bootloader', bootloaderVersion: '1.0.3' },
|
|
9354
|
+
});
|
|
9355
|
+
|
|
9356
|
+
try {
|
|
9357
|
+
expect((method as any).shouldUseProtocolV2BleFileWriteWindow('1.0.3')).toBe(expected);
|
|
9358
|
+
expect((method as any).shouldUseProtocolV2BleFileWriteWindow('1.0.4')).toBe(false);
|
|
9359
|
+
} finally {
|
|
9360
|
+
getSettingsSpy.mockRestore();
|
|
9361
|
+
}
|
|
9362
|
+
}
|
|
9363
|
+
);
|
|
9364
|
+
|
|
9365
|
+
test.each([
|
|
9366
|
+
['1.0.2', false],
|
|
9367
|
+
['1.0.3', true],
|
|
9368
|
+
// The parsed bootloader version drops prerelease tags.
|
|
9369
|
+
['1.0.3-rc.1', true],
|
|
9370
|
+
] as const)(
|
|
9371
|
+
'gates the default minimum on a running %s bootloader in React Native',
|
|
9372
|
+
(bootloaderVersion, expected) => {
|
|
9373
|
+
const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
|
|
9374
|
+
const method = new FirmwareUpdateV4({
|
|
9375
|
+
id: 1,
|
|
9376
|
+
payload: {
|
|
9377
|
+
method: 'firmwareUpdateV4',
|
|
9378
|
+
},
|
|
9379
|
+
});
|
|
9380
|
+
(method as any).device = stubDevice({
|
|
9381
|
+
features: { mode: 'bootloader', bootloaderMode: true, bootloaderVersion },
|
|
9382
|
+
});
|
|
9383
|
+
|
|
9384
|
+
try {
|
|
9385
|
+
expect((method as any).shouldUseProtocolV2BleFileWriteWindow()).toBe(expected);
|
|
9386
|
+
} finally {
|
|
9387
|
+
getSettingsSpy.mockRestore();
|
|
9388
|
+
}
|
|
9389
|
+
}
|
|
9390
|
+
);
|
|
9391
|
+
|
|
9392
|
+
describe('windowed BLE file write', () => {
|
|
9393
|
+
const confirm = (request: { file: { offset: number; data: ArrayBuffer } }) => ({
|
|
9394
|
+
type: 'FilesystemFile',
|
|
9395
|
+
message: { processed_byte: request.file.offset + request.file.data.byteLength },
|
|
9396
|
+
});
|
|
9397
|
+
const streamAll = (requests: any[], onConfirmed?: () => void) =>
|
|
9398
|
+
jest.fn(async (_name: string, _resType: string, first: any, options: any) => {
|
|
9399
|
+
// A serial write sends one chunk per call.
|
|
9400
|
+
if (!options.fileWritePipeline) {
|
|
9401
|
+
requests.push(first);
|
|
9402
|
+
return confirm(first);
|
|
9403
|
+
}
|
|
9404
|
+
const { next, onResponse } = options.fileWritePipeline;
|
|
9405
|
+
let request = first;
|
|
9406
|
+
let last;
|
|
9407
|
+
while (request) {
|
|
9408
|
+
requests.push(request);
|
|
9409
|
+
last = confirm(request);
|
|
9410
|
+
onConfirmed?.();
|
|
9411
|
+
onResponse(last);
|
|
9412
|
+
request = await next();
|
|
9413
|
+
}
|
|
9414
|
+
return last;
|
|
9415
|
+
});
|
|
9416
|
+
const createMethod = (typedCall: jest.Mock) => {
|
|
9417
|
+
const method = new FirmwareUpdateV4({
|
|
9418
|
+
id: 1,
|
|
9419
|
+
payload: {
|
|
9420
|
+
method: 'firmwareUpdateV4',
|
|
9421
|
+
},
|
|
9422
|
+
});
|
|
9423
|
+
(method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
|
|
9424
|
+
(method as any).shouldUseProtocolV2BleFileWriteWindow = jest.fn().mockReturnValue(true);
|
|
9425
|
+
(method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
|
|
9426
|
+
(method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
|
|
9427
|
+
method.postProgressMessage = jest.fn();
|
|
9428
|
+
return method;
|
|
9429
|
+
};
|
|
9430
|
+
const transfer = async (method: FirmwareUpdateV4, filePath = 'vol0:/application_p1.bin') => {
|
|
9431
|
+
const source = await openFirmwareByteSource({ binary: new Uint8Array(2500).buffer });
|
|
9432
|
+
try {
|
|
9433
|
+
const processedSize: number = await (method as any).protocolV2SourceUpdateProcess({
|
|
9434
|
+
source,
|
|
9435
|
+
filePath,
|
|
9436
|
+
processedSize: 500,
|
|
9437
|
+
totalSize: 3000,
|
|
9438
|
+
});
|
|
9439
|
+
return processedSize;
|
|
9440
|
+
} finally {
|
|
9441
|
+
await source?.close();
|
|
9442
|
+
}
|
|
9443
|
+
};
|
|
9444
|
+
|
|
9445
|
+
test('streams the file through one FileWrite call and reports confirmed chunks', async () => {
|
|
9446
|
+
const requests: any[] = [];
|
|
9447
|
+
const typedCall = streamAll(requests);
|
|
9448
|
+
const method = createMethod(typedCall);
|
|
9449
|
+
|
|
9450
|
+
await expect(transfer(method)).resolves.toBe(3000);
|
|
9451
|
+
|
|
9452
|
+
expect(typedCall).toHaveBeenCalledTimes(1);
|
|
9453
|
+
expect(typedCall).toHaveBeenCalledWith(
|
|
9454
|
+
'FilesystemFileWrite',
|
|
9455
|
+
'FilesystemFile',
|
|
9456
|
+
expect.anything(),
|
|
9457
|
+
expect.objectContaining({
|
|
9458
|
+
writeWithResponse: false,
|
|
9459
|
+
fileWritePipeline: expect.objectContaining({
|
|
9460
|
+
next: expect.any(Function),
|
|
9461
|
+
onResponse: expect.any(Function),
|
|
9462
|
+
}),
|
|
9463
|
+
})
|
|
9464
|
+
);
|
|
9465
|
+
expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
|
|
9466
|
+
expect(requests.map(request => request.file.data.byteLength)).toEqual([1000, 1000, 500]);
|
|
9467
|
+
expect(requests.map(request => request.overwrite)).toEqual([true, false, false]);
|
|
9468
|
+
expect(requests.map(request => request.ui_percentage)).toEqual([50, 84, 99]);
|
|
9469
|
+
requests.forEach(request =>
|
|
9470
|
+
expect(request).toMatchObject({
|
|
9471
|
+
append: false,
|
|
9472
|
+
file: { total_size: 2500, path: 'vol0:/application_p1.bin' },
|
|
9473
|
+
})
|
|
9474
|
+
);
|
|
9475
|
+
const transferredBytes = (method.postProgressMessage as jest.Mock).mock.calls.map(
|
|
9476
|
+
([, , metrics]) => metrics.transferredBytes
|
|
9477
|
+
);
|
|
9478
|
+
expect(transferredBytes).toEqual([1500, 2500, 3000]);
|
|
9479
|
+
});
|
|
9480
|
+
|
|
9481
|
+
test('caps windowed staging writes at the generic BLE chunk', async () => {
|
|
9482
|
+
const requests: any[] = [];
|
|
9483
|
+
const method = createMethod(streamAll(requests));
|
|
9484
|
+
delete (method as any).getProtocolV2FirmwareChunkSize;
|
|
9485
|
+
(method as any).params = { platform: 'native', chunkSize: 4096 };
|
|
9486
|
+
|
|
9487
|
+
await expect(transfer(method)).resolves.toBe(3000);
|
|
9488
|
+
|
|
9489
|
+
expect(requests.map(request => request.file.offset)).toEqual([0, 1800]);
|
|
9490
|
+
expect(requests.map(request => request.file.data.byteLength)).toEqual([1800, 700]);
|
|
9491
|
+
});
|
|
9492
|
+
|
|
9493
|
+
test.each(['react-native', 'desktop-web-ble'] as const)(
|
|
9494
|
+
'bounds each windowed response wait in %s',
|
|
9495
|
+
async env => {
|
|
9496
|
+
const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
|
|
9497
|
+
const typedCall = streamAll([]);
|
|
9498
|
+
|
|
9499
|
+
try {
|
|
9500
|
+
await transfer(createMethod(typedCall));
|
|
9501
|
+
} finally {
|
|
9502
|
+
getSettingsSpy.mockRestore();
|
|
9503
|
+
}
|
|
9504
|
+
|
|
9505
|
+
expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 10_000 }));
|
|
9506
|
+
}
|
|
9507
|
+
);
|
|
9508
|
+
|
|
9509
|
+
const failWindowThenWriteSerial = (requests: any[]) => {
|
|
9510
|
+
const writeSerial = streamAll(requests);
|
|
9511
|
+
return jest.fn((name: string, resType: string, first: any, options: any) => {
|
|
9512
|
+
if (options.fileWritePipeline) {
|
|
9513
|
+
return Promise.reject(new Error('Protocol V2 file write response does not match'));
|
|
9514
|
+
}
|
|
9515
|
+
return writeSerial(name, resType, first, options);
|
|
9516
|
+
});
|
|
9517
|
+
};
|
|
9518
|
+
|
|
9519
|
+
test('falls back to serial whole-file overwrite after a failed windowed exchange', async () => {
|
|
9520
|
+
const requests: any[] = [];
|
|
9521
|
+
const typedCall = failWindowThenWriteSerial(requests);
|
|
9522
|
+
const method = createMethod(typedCall);
|
|
9523
|
+
|
|
9524
|
+
await expect(transfer(method)).resolves.toBe(3000);
|
|
9525
|
+
|
|
9526
|
+
expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
|
|
9527
|
+
// One failed pipeline, then serial chunks overwriting the file from offset 0.
|
|
9528
|
+
expect(typedCall).toHaveBeenCalledTimes(4);
|
|
9529
|
+
expect(typedCall.mock.calls[0][3]).toHaveProperty('fileWritePipeline');
|
|
9530
|
+
typedCall.mock.calls
|
|
9531
|
+
.slice(1)
|
|
9532
|
+
.forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
|
|
9533
|
+
expect(typedCall.mock.calls[1][2]).toEqual(
|
|
9534
|
+
expect.objectContaining({ overwrite: true, file: expect.objectContaining({ offset: 0 }) })
|
|
9535
|
+
);
|
|
9536
|
+
expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
|
|
9537
|
+
const chunkSize = (method as any).getProtocolV2FirmwareChunkSize as jest.Mock;
|
|
9538
|
+
expect(chunkSize).toHaveBeenNthCalledWith(1, 'write', 'vol0:/application_p1.bin', true);
|
|
9539
|
+
expect(chunkSize).toHaveBeenNthCalledWith(2, 'write', 'vol0:/application_p1.bin', false);
|
|
9540
|
+
});
|
|
9541
|
+
|
|
9542
|
+
test('keeps subsequent files serial after a failed windowed exchange', async () => {
|
|
9543
|
+
const typedCall = failWindowThenWriteSerial([]);
|
|
9544
|
+
const method = createMethod(typedCall);
|
|
9545
|
+
await transfer(method);
|
|
9546
|
+
typedCall.mockClear();
|
|
9547
|
+
|
|
9548
|
+
await expect(transfer(method, 'vol0:/resource.bin')).resolves.toBe(3000);
|
|
9549
|
+
|
|
9550
|
+
expect(typedCall).toHaveBeenCalledTimes(3);
|
|
9551
|
+
typedCall.mock.calls.forEach(call => {
|
|
9552
|
+
expect(call[3]).not.toHaveProperty('fileWritePipeline');
|
|
9553
|
+
expect(call[2].file.path).toBe('vol0:/resource.bin');
|
|
9554
|
+
});
|
|
9555
|
+
expect((method as any).shouldUseProtocolV2BleFileWriteWindow).toHaveBeenCalledTimes(1);
|
|
9556
|
+
});
|
|
9557
|
+
|
|
9558
|
+
test('bounds failed file transfers to three attempts after serial fallback', async () => {
|
|
9559
|
+
const typedCall = jest.fn().mockRejectedValue(new Error('BLE response timeout'));
|
|
9560
|
+
const method = createMethod(typedCall);
|
|
9561
|
+
await expect(transfer(method)).rejects.toMatchObject({
|
|
9562
|
+
errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
|
|
9563
|
+
});
|
|
9564
|
+
expect(typedCall).toHaveBeenCalledTimes(3);
|
|
9565
|
+
expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
|
|
9566
|
+
expect(typedCall.mock.calls[0][3]).toHaveProperty('fileWritePipeline');
|
|
9567
|
+
typedCall.mock.calls
|
|
9568
|
+
.slice(1)
|
|
9569
|
+
.forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
|
|
9570
|
+
});
|
|
9571
|
+
|
|
9572
|
+
test('logs confirmed progress every ten seconds and average speed through Core', async () => {
|
|
9573
|
+
let now = 1000;
|
|
9574
|
+
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
|
|
9575
|
+
const metrics = jest.spyOn(getLogger(LoggerNames.Core), 'log');
|
|
9576
|
+
const method = createMethod(
|
|
9577
|
+
streamAll([], () => {
|
|
9578
|
+
now += 5000;
|
|
9579
|
+
})
|
|
9580
|
+
);
|
|
9581
|
+
try {
|
|
9582
|
+
await expect(transfer(method)).resolves.toBe(3000);
|
|
9583
|
+
expect(metrics).toHaveBeenCalledTimes(3);
|
|
9584
|
+
expect(metrics).toHaveBeenNthCalledWith(
|
|
9585
|
+
2,
|
|
9586
|
+
'[FirmwareUpdateV4] FileWrite metrics',
|
|
9587
|
+
expect.objectContaining({
|
|
9588
|
+
status: 'progress',
|
|
9589
|
+
fileWriteWindow: 2,
|
|
9590
|
+
chunkSize: 1000,
|
|
9591
|
+
transferredBytes: 2000,
|
|
9592
|
+
totalBytes: 2500,
|
|
9593
|
+
elapsedMs: 10_000,
|
|
9594
|
+
rateBytesPerSecond: 200,
|
|
9595
|
+
attempt: 1,
|
|
9596
|
+
retryCount: 0,
|
|
9597
|
+
})
|
|
9598
|
+
);
|
|
9599
|
+
expect(metrics).toHaveBeenLastCalledWith(
|
|
9600
|
+
'[FirmwareUpdateV4] FileWrite metrics',
|
|
9601
|
+
expect.objectContaining({
|
|
9602
|
+
status: 'completed',
|
|
9603
|
+
transferredBytes: 2500,
|
|
9604
|
+
elapsedMs: 15_000,
|
|
9605
|
+
rateBytesPerSecond: 167,
|
|
9606
|
+
})
|
|
9607
|
+
);
|
|
9608
|
+
} finally {
|
|
9609
|
+
clock.mockRestore();
|
|
9610
|
+
metrics.mockRestore();
|
|
9611
|
+
}
|
|
9612
|
+
});
|
|
9613
|
+
|
|
9614
|
+
test('stops sending on cancel and raises it after the transport call returns', async () => {
|
|
9615
|
+
const abortController = new AbortController();
|
|
9616
|
+
const requests: any[] = [];
|
|
9617
|
+
const succeed = streamAll(requests);
|
|
9618
|
+
const typedCall = jest.fn((name: string, resType: string, first: any, options: any) =>
|
|
9619
|
+
succeed(name, resType, first, {
|
|
9620
|
+
...options,
|
|
9621
|
+
fileWritePipeline: {
|
|
9622
|
+
...options.fileWritePipeline,
|
|
9623
|
+
onResponse: (response: any) => {
|
|
9624
|
+
options.fileWritePipeline.onResponse(response);
|
|
9625
|
+
abortController.abort();
|
|
9626
|
+
},
|
|
9627
|
+
},
|
|
9628
|
+
})
|
|
9629
|
+
);
|
|
9630
|
+
const method = createMethod(typedCall);
|
|
9631
|
+
method.abortSignal = abortController.signal;
|
|
9632
|
+
|
|
9633
|
+
await expect(transfer(method)).rejects.toMatchObject({
|
|
9634
|
+
errorCode: HardwareErrorCode.CallQueueActionCancelled,
|
|
9635
|
+
});
|
|
9636
|
+
expect(requests.map(request => request.file.offset)).toEqual([0]);
|
|
9637
|
+
expect((method as any).recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
|
|
9638
|
+
});
|
|
9639
|
+
|
|
9640
|
+
test('raises a source read error after the windowed call and retries the file windowed', async () => {
|
|
9641
|
+
const requests: any[] = [];
|
|
9642
|
+
const typedCall = streamAll(requests);
|
|
9643
|
+
const method = createMethod(typedCall);
|
|
9644
|
+
const bytes = new Uint8Array(2500);
|
|
9645
|
+
let failed = false;
|
|
9646
|
+
const source = {
|
|
9647
|
+
size: bytes.length,
|
|
9648
|
+
readAt: jest.fn((offset: number, length: number) => {
|
|
9649
|
+
if (offset === 1000 && !failed) {
|
|
9650
|
+
failed = true;
|
|
9651
|
+
return Promise.reject(new Error('artifact read failed'));
|
|
9652
|
+
}
|
|
9653
|
+
return Promise.resolve(bytes.slice(offset, offset + length).buffer);
|
|
9654
|
+
}),
|
|
9655
|
+
close: jest.fn(),
|
|
9656
|
+
};
|
|
9657
|
+
|
|
9658
|
+
await expect(
|
|
9659
|
+
(method as any).protocolV2SourceUpdateProcess({
|
|
9660
|
+
source,
|
|
9661
|
+
filePath: 'vol0:/application_p1.bin',
|
|
9662
|
+
processedSize: 0,
|
|
9663
|
+
totalSize: 2500,
|
|
9664
|
+
})
|
|
9665
|
+
).resolves.toBe(2500);
|
|
9666
|
+
|
|
9667
|
+
// A host-side read error says nothing about the bootloader, so the retry keeps the window.
|
|
9668
|
+
expect(typedCall).toHaveBeenCalledTimes(2);
|
|
9669
|
+
typedCall.mock.calls.forEach(call => expect(call[3]).toHaveProperty('fileWritePipeline'));
|
|
9670
|
+
expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
|
|
9671
|
+
expect(requests.map(request => request.file.offset)).toEqual([0, 0, 1000, 2000]);
|
|
9672
|
+
});
|
|
9673
|
+
|
|
9674
|
+
test('rejects a windowed write that does not confirm the whole file', async () => {
|
|
9675
|
+
const typedCall = jest.fn((_name: string, _resType: string, first: any) =>
|
|
9676
|
+
Promise.resolve(confirm(first))
|
|
9677
|
+
);
|
|
9678
|
+
const method = createMethod(typedCall);
|
|
9679
|
+
const source = await openFirmwareByteSource({ binary: new Uint8Array(2500).buffer });
|
|
9680
|
+
|
|
9681
|
+
try {
|
|
9682
|
+
await expect(
|
|
9683
|
+
(method as any).writeProtocolV2SourceWindowed({
|
|
9684
|
+
source,
|
|
9685
|
+
filePath: 'vol0:/application_p1.bin',
|
|
9686
|
+
chunkSize: 1000,
|
|
9687
|
+
processedSize: 500,
|
|
9688
|
+
totalSize: 3000,
|
|
9689
|
+
onChunkConfirmed: jest.fn(),
|
|
9690
|
+
})
|
|
9691
|
+
).rejects.toMatchObject({
|
|
9692
|
+
errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
|
|
9693
|
+
message: expect.stringContaining('invalid processed_byte 1000 for file size 2500'),
|
|
9694
|
+
});
|
|
9695
|
+
} finally {
|
|
9696
|
+
await source?.close();
|
|
9697
|
+
}
|
|
9698
|
+
expect(typedCall).toHaveBeenCalledTimes(1);
|
|
9699
|
+
});
|
|
9700
|
+
});
|
|
9701
|
+
|
|
8886
9702
|
test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
|
|
8887
9703
|
const method = new FirmwareUpdateV4({
|
|
8888
9704
|
id: 1,
|
|
@@ -9612,6 +10428,27 @@ describe('Protocol V2 firmware update targets', () => {
|
|
|
9612
10428
|
expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
|
|
9613
10429
|
});
|
|
9614
10430
|
|
|
10431
|
+
test.each([
|
|
10432
|
+
[false, 'vol0:/application_p1.bin', 1960],
|
|
10433
|
+
[true, 'vol0:/application_p1.bin', 1800],
|
|
10434
|
+
[true, 'vol0:/bundles/images/animation.okpkg', 1800],
|
|
10435
|
+
])(
|
|
10436
|
+
'sizes BLE write chunks for fileWriteWindow=%s and %s',
|
|
10437
|
+
(fileWriteWindow, filePath, expected) => {
|
|
10438
|
+
const method = new FirmwareUpdateV4({
|
|
10439
|
+
id: 1,
|
|
10440
|
+
payload: {
|
|
10441
|
+
method: 'firmwareUpdateV4',
|
|
10442
|
+
},
|
|
10443
|
+
});
|
|
10444
|
+
(method as any).params = { platform: 'native', chunkSize: 4096 };
|
|
10445
|
+
|
|
10446
|
+
expect(
|
|
10447
|
+
(method as any).getProtocolV2FirmwareChunkSize('write', filePath, fileWriteWindow)
|
|
10448
|
+
).toBe(expected);
|
|
10449
|
+
}
|
|
10450
|
+
);
|
|
10451
|
+
|
|
9615
10452
|
test('keeps confirmation active after the Request write until terminal Success arrives', async () => {
|
|
9616
10453
|
const method = new FirmwareUpdateV4({
|
|
9617
10454
|
id: 1,
|
|
@@ -10150,8 +10987,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
|
|
|
10150
10987
|
});
|
|
10151
10988
|
|
|
10152
10989
|
await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
|
|
10153
|
-
expect(readChunkLengths
|
|
10154
|
-
expect(Math.max(...readChunkLengths)).toBe(900);
|
|
10990
|
+
expect(readChunkLengths).toEqual([640]);
|
|
10155
10991
|
});
|
|
10156
10992
|
|
|
10157
10993
|
test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
|
|
@@ -10207,6 +11043,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
|
|
|
10207
11043
|
path: 'vol0:/bundles/images/images.okpkg',
|
|
10208
11044
|
});
|
|
10209
11045
|
expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
|
|
11046
|
+
expect(typedCall).toHaveBeenCalledTimes(2);
|
|
11047
|
+
expect(typedCall).toHaveBeenCalledWith('FilesystemFileRead', 'FilesystemFile', {
|
|
11048
|
+
file: { path: 'vol0:/bundles/images/images.okpkg', offset: 0, total_size: 0 },
|
|
11049
|
+
chunk_len: 640,
|
|
11050
|
+
ui_percentage: undefined,
|
|
11051
|
+
});
|
|
10210
11052
|
});
|
|
10211
11053
|
|
|
10212
11054
|
test('updates a resource when the installed file size differs despite matching headers', async () => {
|