import { act, renderHook } from "@testing-library/react-hooks"; import { generateTestingUtils } from "eth-testing"; import { useMetaMask, MetaMaskProvider } from "../"; describe("MetaMask provider", () => { const addChainPrams = { chainId: "0x64", chainName: "Gnosis", nativeCurrency: { name: "xDai", symbol: "XDAI", decimals: 18, }, rpcUrls: ["https://rpc.gnosischain.com/"], blockExplorerUrls: ["https://gnosisscan.io/"], }; const address = "0x19F7Fa0a30d5829acBD9B35bA2253a759a37EfC5"; describe("when MetaMask is not available", () => { test("when there is no `ethereum` object in the window, it should synchronise into `unavailable` status", async () => { let originalEth = (window as any).ethereum; const testingUtils = generateTestingUtils(); const unknownProvider = testingUtils.getProvider(); (window as any).ethereum = unknownProvider; const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); expect(result.current.status).toEqual("unavailable"); (window as any).ethereum = originalEth; }); test("when there is an `ethereum` object in the window but it is not a MetaMask type, it should synchronise into `unavailable` status", async () => { const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); expect(result.current.status).toEqual("unavailable"); }); test("calling `connect` should return an empty array and warn the developer", async () => { const warn = jest.spyOn(console, "warn").mockImplementationOnce(() => {}); const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); let accounts; await act(async () => { accounts = await result.current.connect(); }); expect(accounts).toEqual([]); expect(warn).toHaveBeenCalledTimes(1); }); test("calling `addChain` should immediately return and warn the developer", async () => { const warn = jest.spyOn(console, "warn").mockImplementationOnce(() => {}); const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); await act(async () => { await result.current.addChain(addChainPrams); }); expect(warn).toHaveBeenCalledTimes(1); }); test("calling `switchChain` should immediately return and warn the developer", async () => { const warn = jest.spyOn(console, "warn").mockImplementationOnce(() => {}); const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); await act(async () => { await result.current.switchChain("0x1"); }); expect(warn).toHaveBeenCalledTimes(1); }); }); describe("when MetaMask is available", () => { let originalEth: any; const testingUtils = generateTestingUtils({ providerType: "MetaMask", }); beforeAll(() => { originalEth = (window as any).ethereum; (window as any).ethereum = testingUtils.getProvider(); }); afterAll(() => { (window as any).ethereum = originalEth; }); afterEach(() => { testingUtils.clearAllMocks(); }); test("when chain changes, it should reflect on the state", async () => { const otherChainId = "0x2"; testingUtils.mockAccounts([]); testingUtils.mockChainId("0x1"); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); await waitForNextUpdate(); expect(result.current.chainId).toEqual("0x1"); act(() => { testingUtils.mockChainChanged("0x2"); }); expect(result.current.chainId).toEqual(otherChainId); }); describe("chain utils", () => { beforeEach(() => { testingUtils.mockAccounts([]); testingUtils.mockChainId("0x1"); }); test("calling `addChain` should trigger the expected RPC request", async () => { testingUtils.lowLevel.mockRequest("wallet_addEthereumChain", undefined); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); await act(async () => { return result.current.addChain(addChainPrams).then(() => { testingUtils.mockChainChanged(addChainPrams.chainId); }); }); expect(result.current.chainId).toEqual(addChainPrams.chainId); }); test("calling `addChain` when a current request is pending should return", async () => { const error = { code: -32002, }; testingUtils.lowLevel.mockRequest("wallet_addEthereumChain", error, { shouldThrow: true, }); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); await act(async () => { return result.current.addChain(addChainPrams).then(() => { testingUtils.mockChainChanged(addChainPrams.chainId); }); }); expect(result.current.chainId).toEqual(addChainPrams.chainId); }); test("calling `addChain` should throw if the underlying request throws with a unhandled code", async () => { const error = { code: -32003, }; testingUtils.lowLevel.mockRequest("wallet_addEthereumChain", error, { shouldThrow: true, }); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); await expect(() => result.current.addChain(addChainPrams) ).rejects.toEqual(error); }); test("calling `switchChain` should trigger the expected RPC request", async () => { const newChainId = "0x2"; testingUtils.lowLevel.mockRequest( "wallet_switchEthereumChain", undefined ); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); await act(async () => { return result.current.switchChain(newChainId).then(() => { testingUtils.mockChainChanged(newChainId); }); }); expect(result.current.chainId).toEqual(newChainId); }); test("calling `switchChain` when a current request is pending should return", async () => { const newChainId = "0x2"; const error = { code: -32002, }; testingUtils.lowLevel.mockRequest("wallet_switchEthereumChain", error, { shouldThrow: true, }); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); await act(async () => { return result.current.switchChain(newChainId).then(() => { testingUtils.mockChainChanged(newChainId); }); }); expect(result.current.chainId).toEqual(newChainId); }); test("calling `switchChain` should throw if the underlying request throws with a unhandled code", async () => { const error = { code: -32003, }; testingUtils.lowLevel.mockRequest("wallet_switchEthereumChain", error, { shouldThrow: true, }); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); await expect(() => result.current.switchChain("0x2")).rejects.toEqual( error ); }); }); describe("when MetaMask is not connected", () => { beforeEach(() => { testingUtils.mockAccounts([]); testingUtils.mockChainId("0x1"); }); test("when no account is connected, it should end up in the `notConnected` status", async () => { const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.chainId).toEqual("0x1"); expect(result.current.status).toEqual("notConnected"); }); test("when a manual connection happens, account should be available", async () => { const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); act(() => { testingUtils.mockAccountsChanged([address]); }); await waitForNextUpdate(); expect(result.current.status).toEqual("connected"); expect(result.current.account).toEqual(address); }); test("when an empty accounts changed event happens, nothing should happen", async () => { const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); act(() => { testingUtils.mockAccountsChanged([]); }); expect(result.current.status).toEqual("notConnected"); }); test("calling `connect` method should end in a successful connection", async () => { testingUtils.lowLevel.mockRequest("eth_requestAccounts", [address]); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); act(() => { result.current.connect(); }); expect(result.current.status).toEqual("connecting"); await waitForNextUpdate(); expect(result.current.status).toEqual("connected"); expect(result.current.account).toEqual(address); }); test("calling `connect` method while a pending Metamask request is pending should end in a successful connection", async () => { const error = { code: -32002, }; testingUtils.clearAllMocks(); testingUtils.lowLevel.mockRequest("eth_accounts", []); testingUtils.lowLevel.mockRequest("eth_requestAccounts", error, { shouldThrow: true, }); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); act(() => { result.current.connect(); }); expect(result.current.status).toEqual("connecting"); act(() => { testingUtils.lowLevel.mockRequest("eth_accounts", []); testingUtils.lowLevel.mockRequest("eth_accounts", [address]); }); await waitForNextUpdate(); expect(result.current.status).toEqual("connected"); expect(result.current.account).toEqual(address); }); test("calling `connect` method should end in the `notConnected` status if the request fails", async () => { const error = { code: -21 }; testingUtils.lowLevel.mockRequest("eth_requestAccounts", error, { shouldThrow: true, }); const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("notConnected"); let thrownError; act(() => { result.current.connect().catch((err) => (thrownError = err)); }); expect(result.current.status).toEqual("connecting"); await waitForNextUpdate(); expect(thrownError).toEqual(error); expect(result.current.status).toEqual("notConnected"); expect(result.current.account).toEqual(null); }); }); describe("when MetaMask is already connected", () => { beforeEach(() => { testingUtils.mockAccounts([address]); testingUtils.mockChainId("0x1"); }); test("initialization should successfully connect to the account", async () => { const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); expect(result.current.status).toEqual("initializing"); await waitForNextUpdate(); expect(result.current.status).toEqual("connected"); expect(result.current.account).toEqual(address); }); test("when account changes, it should reflect on the state", async () => { const otherAddress = "0x19F7Fa0a30d5829acBD9B35bA2253a759a37EfC6"; const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); await waitForNextUpdate(); expect(result.current.account).toEqual(address); act(() => { testingUtils.mockAccountsChanged([otherAddress]); }); expect(result.current.status).toEqual("connected"); expect(result.current.account).toEqual(otherAddress); }); test("when account changes with empty account, it should lead to `notConnected` status", async () => { const { result, waitForNextUpdate } = renderHook(useMetaMask, { wrapper: MetaMaskProvider, }); await waitForNextUpdate(); expect(result.current.account).toEqual(address); act(() => { testingUtils.mockAccountsChanged([]); }); expect(result.current.status).toEqual("notConnected"); expect(result.current.account).toEqual(null); }); }); }); });