Skip to content

Commit

Permalink
feat(llc): useOnboardingStatePolling hook
Browse files Browse the repository at this point in the history
  • Loading branch information
alexandremgo committed Jun 28, 2022
1 parent 3f5518e commit 3f9068e
Show file tree
Hide file tree
Showing 2 changed files with 387 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import { timer, of } from "rxjs";
import { map, delayWhen } from "rxjs/operators";
import { renderHook, act } from "@testing-library/react-hooks";
import { DeviceModelId } from "@ledgerhq/devices";
import { DisconnectedDevice } from "@ledgerhq/errors";
import { useOnboardingStatePolling } from "./useOnboardingStatePolling";
import {
OnboardingState,
OnboardingStep,
} from "../../hw/extractOnboardingState";
import { SeedPhraseType } from "../../types/manager";
import { getOnboardingStatePolling } from "../../hw/getOnboardingStatePolling";

jest.mock("../../hw/getOnboardingStatePolling");
jest.useFakeTimers();

const aDevice = {
deviceId: "DEVICE_ID_A",
deviceName: "DEVICE_NAME_A",
modelId: DeviceModelId.nanoFTS,
wired: false,
};

const pollingPeriodMs = 1000;

const mockedGetOnboardingStatePolling = jest.mocked(getOnboardingStatePolling);

describe("useOnboardingStatePolling", () => {
let anOnboardingState: OnboardingState;
let aSecondOnboardingState: OnboardingState;

beforeEach(() => {
anOnboardingState = {
isOnboarded: false,
isInRecoveryMode: false,
seedPhraseType: SeedPhraseType.TwentyFour,
currentSeedWordIndex: 0,
currentOnboardingStep: OnboardingStep.NewDevice,
};

aSecondOnboardingState = {
...anOnboardingState,
currentOnboardingStep: OnboardingStep.NewDeviceConfirming,
};
});

afterEach(() => {
mockedGetOnboardingStatePolling.mockClear();
});

describe("When polling returns a correct device state", () => {
beforeEach(() => {
mockedGetOnboardingStatePolling.mockReturnValue(
of(
{
onboardingState: { ...anOnboardingState },
allowedError: null,
},
{
onboardingState: { ...aSecondOnboardingState },
allowedError: null,
}
).pipe(
delayWhen((_, index) => {
// "delay" or "delayWhen" piped to a streaming source, for ex the "of" operator, will not block the next
// Observable to be streamed. They return an Observable that delays the emission of the source Observable,
// but do not create a delay in-between each emission. That's why the delay is increased by multiplying by "index".
// "concatMap" could have been used to wait for the previous Observable to complete, but
// the "index" arg given to "delayWhen" would always be 0
return timer(index * pollingPeriodMs);
})
)
);
});

it("should update the onboarding state returned to the consumer", async () => {
const device = aDevice;

const { result } = renderHook(() =>
useOnboardingStatePolling({ device, pollingPeriodMs })
);

await act(async () => {
jest.advanceTimersByTime(1);
});

expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);
});

it("should fetch again the state at a defined frequency and update (if new) the onboarding state returned to the consumer", async () => {
const device = aDevice;

const { result } = renderHook(() =>
useOnboardingStatePolling({ device, pollingPeriodMs })
);

await act(async () => {
jest.advanceTimersByTime(1);
});

expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);

// Next polling
await act(async () => {
jest.advanceTimersByTime(pollingPeriodMs);
});

expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(aSecondOnboardingState);
});

describe("and when the hook consumer stops the polling", () => {
it("should stop the polling and stop fetching the device onboarding state", async () => {
const device = aDevice;
let stopPolling = false;

const { result, rerender } = renderHook(() =>
useOnboardingStatePolling({ device, pollingPeriodMs, stopPolling })
);

await act(async () => {
jest.advanceTimersByTime(1);
});

// Everything is normal on the first run
expect(mockedGetOnboardingStatePolling).toHaveBeenCalledTimes(1);
expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);

// The consumer stops the polling
stopPolling = true;
rerender({ device, pollingPeriodMs, stopPolling });

await act(async () => {
// Waits as long as we want
jest.advanceTimersByTime(10 * pollingPeriodMs);
});

// While the hook was rerendered, it did not call a new time getOnboardingStatePolling
expect(mockedGetOnboardingStatePolling).toHaveBeenCalledTimes(1);
// And the state should stay the same (and not be aSecondOnboardingState)
expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);
});
});
});

describe("When an allowed error occurs while polling the device state", () => {
beforeEach(() => {
mockedGetOnboardingStatePolling.mockReturnValue(
of(
{
onboardingState: { ...anOnboardingState },
allowedError: null,
},
{
onboardingState: null,
allowedError: new DisconnectedDevice("An allowed error"),
},
{
onboardingState: { ...aSecondOnboardingState },
allowedError: null,
}
).pipe(
delayWhen((_, index) => {
return timer(index * pollingPeriodMs);
})
)
);
});

it("should update the allowed error returned to the consumer, update the fatal error to null and keep the previous onboarding state", async () => {
const device = aDevice;

const { result } = renderHook(() =>
useOnboardingStatePolling({ device, pollingPeriodMs })
);

await act(async () => {
jest.advanceTimersByTime(1);
});

// Everything is ok on the first run
expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);

await act(async () => {
jest.advanceTimersByTime(pollingPeriodMs);
});

expect(result.current.allowedError).toBeInstanceOf(DisconnectedDevice);
expect(result.current.fatalError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);
});

it("should be able to recover once the allowed error is fixed and the onboarding state is updated", async () => {
const device = aDevice;

const { result } = renderHook(() =>
useOnboardingStatePolling({ device, pollingPeriodMs })
);

await act(async () => {
jest.advanceTimersByTime(pollingPeriodMs + 1);
});

// Allowed error occured
expect(result.current.allowedError).toBeInstanceOf(DisconnectedDevice);
expect(result.current.fatalError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);

await act(async () => {
jest.advanceTimersByTime(pollingPeriodMs);
});

// Everything is ok on the next run
expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(aSecondOnboardingState);
});
});

describe("When a (fatal) error is thrown while polling the device state", () => {
const anOnboardingStateThatShouldNeverBeReached = {
...aSecondOnboardingState,
};

beforeEach(() => {
mockedGetOnboardingStatePolling.mockReturnValue(
of(
{
onboardingState: { ...anOnboardingState },
allowedError: null,
},
{
onboardingState: { ...anOnboardingState },
allowedError: null,
},
{
// It should never be reached
onboardingState: { ...anOnboardingStateThatShouldNeverBeReached },
allowedError: null,
}
).pipe(
delayWhen((_, index) => {
return timer(index * pollingPeriodMs);
}),
map((value, index) => {
// Throws an error the second time
if (index === 1) {
throw new Error("An unallowed error");
}
return value;
})
)
);
});

it("should update the fatal error returned to the consumer, update the allowed error to null, keep the previous onboarding state and stop the polling", async () => {
const device = aDevice;

const { result } = renderHook(() =>
useOnboardingStatePolling({ device, pollingPeriodMs })
);

await act(async () => {
jest.advanceTimersByTime(1);
});

// Everything is ok on the first run
expect(result.current.fatalError).toBeNull();
expect(result.current.allowedError).toBeNull();
expect(result.current.onboardingState).toEqual(anOnboardingState);

await act(async () => {
jest.advanceTimersByTime(pollingPeriodMs);
});

// Fatal error on the second run
expect(result.current.allowedError).toBeNull();
expect(result.current.fatalError).toBeInstanceOf(Error);
expect(result.current.onboardingState).toEqual(anOnboardingState);

await act(async () => {
jest.advanceTimersByTime(pollingPeriodMs);
});

// The polling should have been stopped, and we never update the onboardingState
expect(result.current.allowedError).toBeNull();
expect(result.current.fatalError).toBeInstanceOf(Error);
expect(result.current.onboardingState).not.toEqual(
anOnboardingStateThatShouldNeverBeReached
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState, useEffect } from "react";
import { Subscription } from "rxjs";
import type { Device } from "../../hw/actions/types";
import { DeviceOnboardingStatePollingError } from "@ledgerhq/errors";

import type { OnboardingStatePollingResult } from "../../hw/getOnboardingStatePolling";
import { getOnboardingStatePolling } from "../../hw/getOnboardingStatePolling";
import { OnboardingState } from "../../hw/extractOnboardingState";

export type UseOnboardingStatePollingResult = OnboardingStatePollingResult & {
fatalError: Error | null;
};

/**
* Polls the current device onboarding state, and notify the hook consumer of
* any allowed errors and fatal errors
* @param device A Device object
* @param pollingPeriodMs The period in ms after which the device onboarding state is fetched again
* @param stopPolling Flag to stop or continue the polling
* @returns The onboardingState, allowedError and fatalError that were emitted
*/
export const useOnboardingStatePolling = ({
device,
pollingPeriodMs,
stopPolling = false,
}: {
device: Device | null;
pollingPeriodMs: number;
stopPolling?: boolean;
}): UseOnboardingStatePollingResult => {
const [onboardingState, setOnboardingState] =
useState<OnboardingState | null>(null);
const [allowedError, setAllowedError] = useState<Error | null>(null);
const [fatalError, setFatalError] = useState<Error | null>(null);

useEffect(() => {
let onboardingStatePollingSubscription: Subscription;

// If stopPolling is updated and set to true, the useEffect hook will call its
// cleanup function (return) and the polling won't restart with the below condition
if (device && !stopPolling) {
onboardingStatePollingSubscription = getOnboardingStatePolling({
deviceId: device.deviceId,
pollingPeriodMs,
}).subscribe({
next: (onboardingStatePollingResult: OnboardingStatePollingResult) => {
if (onboardingStatePollingResult) {
setFatalError(null);
setAllowedError(onboardingStatePollingResult.allowedError);

// Does not update the onboarding state if an allowed error occurred
if (!onboardingStatePollingResult.allowedError) {
setOnboardingState(onboardingStatePollingResult.onboardingState);
}
}
},
error: (error) => {
setAllowedError(null);
setFatalError(
error instanceof Error
? error
: new DeviceOnboardingStatePollingError(
`Error from: ${error?.name ?? error} ${error?.message}`
)
);
},
});
}

return () => {
onboardingStatePollingSubscription?.unsubscribe();
};
}, [
device,
pollingPeriodMs,
setOnboardingState,
setAllowedError,
setFatalError,
stopPolling,
]);

return { onboardingState, allowedError, fatalError };
};

0 comments on commit 3f9068e

Please sign in to comment.