@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
235 lines (205 loc) • 7.29 kB
text/typescript
import { firstValueFrom, from, Observable, of, timer } from "rxjs";
import { delay } from "rxjs/operators";
import Transport from "@ledgerhq/hw-transport";
import { CantOpenDevice, DisconnectedDevice, LockedDeviceError } from "@ledgerhq/errors";
import { DeviceInfo } from "@ledgerhq/types-live";
import getDeviceInfo from "./getDeviceInfo";
import { getDeviceRunningMode } from "./getDeviceRunningMode";
import { aDeviceInfoBuilder } from "../mock/fixtures/aDeviceInfo";
jest.useFakeTimers();
// Only mocks withDevice
jest.mock("./deviceAccess", () => {
const originalModule = jest.requireActual("./deviceAccess");
return {
...originalModule, // import and retain the original functionalities
withDevice: jest.fn().mockReturnValue(job => {
return from(job(new Transport()));
}),
};
});
// Needs to mock the timer from rxjs used in retryWhileErrors
jest.mock("rxjs", () => {
const originalModule = jest.requireActual("rxjs");
return {
...originalModule,
timer: jest.fn(),
};
});
const mockedTimer = jest.mocked(timer);
jest.mock("./getDeviceInfo");
const mockedGetDeviceInfo = jest.mocked(getDeviceInfo);
const A_DEVICE_ID = "";
describe("getDeviceRunningMode", () => {
beforeEach(() => {
// @ts-expect-error the mocked function reflect an incorrect signature
mockedTimer.mockReturnValue(of(1));
});
afterEach(() => {
mockedTimer.mockClear();
mockedGetDeviceInfo.mockClear();
});
describe("When the device is in bootloader mode", () => {
it("pushes an event bootloaderMode", done => {
const aDeviceInfo = aDeviceInfoBuilder({ isBootloader: true });
mockedGetDeviceInfo.mockResolvedValue(aDeviceInfo);
getDeviceRunningMode({ deviceId: A_DEVICE_ID }).subscribe({
next: event => {
try {
expect(event.type).toBe("bootloaderMode");
done();
} catch (expectError) {
done(expectError);
}
},
error: error => {
// It should not reach here
done(error);
},
});
jest.advanceTimersByTime(1);
});
describe("but for now it is restarting and/or in a unknown state", () => {
it("should wait and retry until the device is in bootloader", done => {
const aDeviceInfo = aDeviceInfoBuilder({ isBootloader: true });
const nbAcceptedErrors = 3;
let count = 0;
// Could not simply mockedRejectValueOnce several times followed by
// a mockedResolveValueOnce. Needed to transform getDeviceInfo
// into an Observable.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockedGetDeviceInfo.mockImplementation(() => {
return new Observable<DeviceInfo>(o => {
if (count < nbAcceptedErrors) {
count++;
o.error(new DisconnectedDevice());
} else {
o.next(aDeviceInfo);
}
});
});
getDeviceRunningMode({
deviceId: A_DEVICE_ID,
}).subscribe({
next: event => {
try {
expect(mockedTimer).toHaveBeenCalledTimes(nbAcceptedErrors);
expect(event.type).toBe("bootloaderMode");
done();
} catch (expectError) {
done(expectError);
}
},
error: error => {
// It should not reach here
done(error);
},
});
// No need to handle the timer with a specific value as rxjs timer has been mocked
// because we could not advance the timer every time the retryWhileErrors is called
jest.advanceTimersByTime(1);
});
});
});
describe("When the device is NOT in bootloader mode and unlocked", () => {
it("pushes an event mainMode", done => {
const aDeviceInfo = aDeviceInfoBuilder({ isBootloader: false });
mockedGetDeviceInfo.mockResolvedValue(aDeviceInfo);
getDeviceRunningMode({ deviceId: A_DEVICE_ID }).subscribe({
next: event => {
try {
expect(event.type).toBe("mainMode");
done();
} catch (expectError) {
done(expectError);
}
},
error: error => {
// It should not reach here
done(error);
},
});
jest.advanceTimersByTime(1);
});
});
describe("When the device is locked (not in bootloader)", () => {
describe("And is not responsive", () => {
it("waits for a given time and pushes an event lockedDevice", done => {
const unresponsiveTimeoutMs = 5000;
// The deviceInfo will not be returned before the timeout
// leading to an "unresponsive device"
const aDeviceInfo = aDeviceInfoBuilder({ isBootloader: false });
mockedGetDeviceInfo.mockResolvedValue(
firstValueFrom(of(aDeviceInfo).pipe(delay(unresponsiveTimeoutMs + 1000))),
);
getDeviceRunningMode({
deviceId: A_DEVICE_ID,
unresponsiveTimeoutMs,
}).subscribe({
next: event => {
try {
expect(event.type).toBe("lockedDevice");
done();
} catch (expectError) {
done(expectError);
}
},
error: error => {
// It should not reach here
done(error);
},
});
jest.advanceTimersByTime(unresponsiveTimeoutMs + 1);
});
});
describe("And the device responds with a locked device error", () => {
it("pushes an event lockedDevice", done => {
mockedGetDeviceInfo.mockRejectedValue(new LockedDeviceError());
getDeviceRunningMode({
deviceId: A_DEVICE_ID,
}).subscribe({
next: event => {
try {
expect(event.type).toBe("lockedDevice");
done();
} catch (expectError) {
done(expectError);
}
},
error: error => {
// It should not reach here
done(error);
},
});
jest.advanceTimersByTime(1);
});
});
describe("And the transport lib throws CantOpenDevice errors", () => {
it("pushes an event disconnectedOrlockedDevice after a given number of retry", done => {
const cantOpenDeviceRetryLimit = 3;
mockedGetDeviceInfo.mockRejectedValue(new CantOpenDevice());
getDeviceRunningMode({
deviceId: A_DEVICE_ID,
cantOpenDeviceRetryLimit,
}).subscribe({
next: event => {
try {
expect(mockedTimer).toHaveBeenCalledTimes(cantOpenDeviceRetryLimit);
expect(event.type).toBe("disconnectedOrlockedDevice");
done();
} catch (expectError) {
done(expectError);
}
},
error: error => {
// It should not reach here
done(error);
},
});
// No need to handle the timer with a specific value as rxjs timer has been mocked
// because we could not advance the timer every time the retryWhileErrors is called
jest.advanceTimersByTime(1);
});
});
});
});