diff --git a/src/components/common/RemoveNode.spec.tsx b/src/components/common/RemoveNode.spec.tsx index 5665b18..22f897c 100644 --- a/src/components/common/RemoveNode.spec.tsx +++ b/src/components/common/RemoveNode.spec.tsx @@ -1,25 +1,33 @@ import React from 'react'; import { fireEvent, waitForElement } from '@testing-library/dom'; import { Status } from 'shared/types'; -import { DockerLibrary } from 'types'; +import { BitcoindLibrary, DockerLibrary } from 'types'; import { initChartFromNetwork } from 'utils/chart'; +import { defaultRepoState } from 'utils/constants'; +import { createBitcoindNetworkNode } from 'utils/network'; import { getNetwork, injections, lightningServiceMock, renderWithProviders, suppressConsoleErrors, + testNodeDocker, } from 'utils/tests'; import RemoveNode from './RemoveNode'; const dockerServiceMock = injections.dockerService as jest.Mocked; +const bitcoindServiceMock = injections.bitcoindService as jest.Mocked; describe('RemoveNode', () => { - const renderComponent = (status?: Status) => { + const renderComponent = (status?: Status, useBitcoinNode = false) => { const network = getNetwork(1, 'test network', status); if (status === Status.Error) { network.nodes.lightning.forEach(n => (n.errorMsg = 'test-error')); } + const btcLatest = defaultRepoState.images.bitcoind.latest; + network.nodes.bitcoin.push( + createBitcoindNetworkNode(network, btcLatest, testNodeDocker), + ); const initialState = { network: { networks: [network], @@ -31,70 +39,131 @@ describe('RemoveNode', () => { activeId: 1, }, }; - const { lightning } = network.nodes; - const node = lightning[status === Status.Started ? 0 : 1]; + const { lightning, bitcoin } = network.nodes; + const node = useBitcoinNode + ? bitcoin[0] + : lightning[status === Status.Started ? 0 : 1]; const cmp = ; - const result = renderWithProviders(cmp, { initialState, wrapForm: true }); - return { - ...result, - node, - }; + return renderWithProviders(cmp, { initialState, wrapForm: true }); }; - beforeEach(() => { - lightningServiceMock.getChannels.mockResolvedValue([]); - }); + describe('lightning node', () => { + beforeEach(() => { + lightningServiceMock.getChannels.mockResolvedValue([]); + }); - it('should show the remove node modal', async () => { - const { getByText } = renderComponent(Status.Started); - expect(getByText('Remove')).toBeInTheDocument(); - fireEvent.click(getByText('Remove')); - expect( - getByText('Are you sure you want to remove alice from the network?'), - ).toBeInTheDocument(); - expect(getByText('Yes')).toBeInTheDocument(); - expect(getByText('Cancel')).toBeInTheDocument(); - }); + it('should show the remove node modal', async () => { + const { getByText } = renderComponent(Status.Started); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + expect( + getByText('Are you sure you want to remove alice from the network?'), + ).toBeInTheDocument(); + expect(getByText('Yes')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + }); - it('should remove the node with the network stopped', async () => { - const { getByText, getByLabelText } = renderComponent(Status.Started); - expect(getByText('Remove')).toBeInTheDocument(); - fireEvent.click(getByText('Remove')); - fireEvent.click(getByText('Yes')); - // wait for the error notification to be displayed - await waitForElement(() => getByLabelText('check-circle')); - expect( - getByText('The node alice has been removed from the network'), - ).toBeInTheDocument(); - expect(dockerServiceMock.removeNode).toBeCalledTimes(1); - }); + it('should remove the node with the network stopped', async () => { + const { getByText, getByLabelText } = renderComponent(Status.Started); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + fireEvent.click(getByText('Yes')); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('check-circle')); + expect( + getByText('The node alice has been removed from the network'), + ).toBeInTheDocument(); + expect(dockerServiceMock.removeNode).toBeCalledTimes(1); + }); - it('should remove the node with the network started', async () => { - const { getByText, getByLabelText } = renderComponent(Status.Started); - expect(getByText('Remove')).toBeInTheDocument(); - fireEvent.click(getByText('Remove')); - fireEvent.click(getByText('Yes')); - // wait for the error notification to be displayed - await waitForElement(() => getByLabelText('check-circle')); - expect( - getByText('The node alice has been removed from the network'), - ).toBeInTheDocument(); - expect(dockerServiceMock.removeNode).toBeCalledTimes(1); + it('should remove the node with the network started', async () => { + const { getByText, getByLabelText } = renderComponent(Status.Started); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + fireEvent.click(getByText('Yes')); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('check-circle')); + expect( + getByText('The node alice has been removed from the network'), + ).toBeInTheDocument(); + expect(dockerServiceMock.removeNode).toBeCalledTimes(1); + }); + + it('should display an error if removing the node fails', async () => { + // antd Modal.confirm logs a console error when onOk fails + // this suppresses those errors from being displayed in test runs + await suppressConsoleErrors(async () => { + dockerServiceMock.removeNode.mockRejectedValue(new Error('test error')); + const { getByText, getByLabelText } = renderComponent(Status.Started); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + fireEvent.click(getByText('Yes')); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('close-circle')); + expect(getByText('Unable to remove the node')).toBeInTheDocument(); + expect(getByText('test error')).toBeInTheDocument(); + }); + }); }); - it('should display an error if removing the node fails', async () => { - // antd Modal.confirm logs a console error when onOk fails - // this suppresses those errors from being displayed in test runs - await suppressConsoleErrors(async () => { - dockerServiceMock.removeNode.mockRejectedValue(new Error('test error')); - const { getByText, getByLabelText } = renderComponent(Status.Started); + describe('bitcoin node', () => { + beforeEach(() => { + lightningServiceMock.getChannels.mockResolvedValue([]); + lightningServiceMock.waitUntilOnline.mockResolvedValue(Promise.resolve()); + bitcoindServiceMock.waitUntilOnline.mockResolvedValue(Promise.resolve()); + }); + + it('should show the remove node modal', async () => { + const { getByText } = renderComponent(Status.Started, true); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + expect( + getByText('Are you sure you want to remove backend1 from the network?'), + ).toBeInTheDocument(); + expect(getByText('Yes')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + }); + + it('should remove the node with the network stopped', async () => { + const { getByText, getByLabelText } = renderComponent(Status.Stopped, true); expect(getByText('Remove')).toBeInTheDocument(); fireEvent.click(getByText('Remove')); fireEvent.click(getByText('Yes')); // wait for the error notification to be displayed - await waitForElement(() => getByLabelText('close-circle')); - expect(getByText('Unable to remove the node')).toBeInTheDocument(); - expect(getByText('test error')).toBeInTheDocument(); + await waitForElement(() => getByLabelText('check-circle')); + expect( + getByText('The node backend1 has been removed from the network'), + ).toBeInTheDocument(); + expect(dockerServiceMock.saveComposeFile).toBeCalledTimes(1); + }); + + it('should remove the node with the network started', async () => { + const { getByText, getByLabelText } = renderComponent(Status.Started, true); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + fireEvent.click(getByText('Yes')); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('check-circle')); + expect( + getByText('The node backend1 has been removed from the network'), + ).toBeInTheDocument(); + expect(dockerServiceMock.saveComposeFile).toBeCalledTimes(2); + }); + + it('should display an error if removing the node fails', async () => { + // antd Modal.confirm logs a console error when onOk fails + // this suppresses those errors from being displayed in test runs + await suppressConsoleErrors(async () => { + dockerServiceMock.saveComposeFile.mockRejectedValue(new Error('test error')); + const { getByText, getByLabelText } = renderComponent(Status.Stopped, true); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + fireEvent.click(getByText('Yes')); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('close-circle')); + expect(getByText('Unable to remove the node')).toBeInTheDocument(); + expect(getByText('test error')).toBeInTheDocument(); + }); }); }); }); diff --git a/src/components/designer/LinkContextMenu.spec.tsx b/src/components/designer/LinkContextMenu.spec.tsx new file mode 100644 index 0000000..44c3164 --- /dev/null +++ b/src/components/designer/LinkContextMenu.spec.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { ILink } from '@mrblenny/react-flow-chart'; +import { fireEvent } from '@testing-library/dom'; +import { initChartFromNetwork } from 'utils/chart'; +import { getNetwork, renderWithProviders } from 'utils/tests'; +import LinkContextMenu from './LinkContextMenu'; + +describe('LinkContextMenu', () => { + const createChannelLink = (): ILink => ({ + id: 'alice-carol', + from: { nodeId: 'alice', portId: 'alice-carol' }, + to: { nodeId: 'carol', portId: 'alice-carol' }, + properties: { + type: 'open-channel', + capacity: '1000', + fromBalance: '600', + toBalance: '400', + direction: 'ltr', + status: 'Open', + }, + }); + const createBackendLink = (): ILink => ({ + id: 'alice-backend1', + from: { nodeId: 'alice', portId: 'alice-backend1' }, + to: { nodeId: 'backend1', portId: 'alice-backend1' }, + properties: { + type: 'backend', + }, + }); + const renderComponent = (link: ILink) => { + const network = getNetwork(1, 'test network'); + const chart = initChartFromNetwork(network); + chart.links[link.id] = link; + const initialState = { + network: { + networks: [network], + }, + designer: { + activeId: network.id, + allCharts: { + [network.id]: chart, + }, + }, + }; + const cmp = ( + + test-child + + ); + const result = renderWithProviders(cmp, { initialState }); + // always open the context menu for all tests + fireEvent.contextMenu(result.getByText('test-child')); + return result; + }; + + it('should display the correct options for an open channel', async () => { + const { getByText } = renderComponent(createChannelLink()); + expect(getByText('Close Channel')).toBeInTheDocument(); + }); + + it('should display the correct options for a backend connection', async () => { + const { getByText } = renderComponent(createBackendLink()); + expect(getByText('Change Backend')).toBeInTheDocument(); + }); + + it('should not display a menu for an invalid link', async () => { + const { queryByText } = renderComponent({} as ILink); + expect(queryByText('Close Channel')).not.toBeInTheDocument(); + expect(queryByText('Change Backend')).not.toBeInTheDocument(); + }); + + it('should not display Close Channel an invalid node', async () => { + const link = createChannelLink(); + link.from.nodeId = 'invalid'; + const { queryByText } = renderComponent(link); + expect(queryByText('Close Channel')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/designer/custom/LinkContextMenu.tsx b/src/components/designer/LinkContextMenu.tsx similarity index 92% rename from src/components/designer/custom/LinkContextMenu.tsx rename to src/components/designer/LinkContextMenu.tsx index 9c57259..963fb2c 100644 --- a/src/components/designer/custom/LinkContextMenu.tsx +++ b/src/components/designer/LinkContextMenu.tsx @@ -3,8 +3,8 @@ import { ILink } from '@mrblenny/react-flow-chart'; import { Dropdown, Menu } from 'antd'; import { useStoreState } from 'store'; import { LinkProperties } from 'utils/chart'; -import ChangeBackendButton from '../link/ChangeBackendButton'; -import CloseChannelButton from '../link/CloseChannelButton'; +import ChangeBackendButton from './link/ChangeBackendButton'; +import CloseChannelButton from './link/CloseChannelButton'; interface Props { link: ILink; diff --git a/src/components/designer/NodeContextMenu.spec.tsx b/src/components/designer/NodeContextMenu.spec.tsx new file mode 100644 index 0000000..fca5be1 --- /dev/null +++ b/src/components/designer/NodeContextMenu.spec.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/dom'; +import { act } from '@testing-library/react'; +import { ipcChannels } from 'shared'; +import { Status } from 'shared/types'; +import { initChartFromNetwork } from 'utils/chart'; +import { getNetwork, injections, renderWithProviders } from 'utils/tests'; +import NodeContextMenu from './NodeContextMenu'; + +describe('NodeContextMenu', () => { + const renderComponent = (nodeName: string, status?: Status) => { + const network = getNetwork(1, 'test network', status); + const chart = initChartFromNetwork(network); + if (nodeName === 'invalid') { + chart.nodes.alice.id = 'invalid'; + nodeName = 'alice'; + } + const initialState = { + network: { + networks: [network], + }, + designer: { + activeId: network.id, + allCharts: { + [network.id]: chart, + }, + }, + }; + const cmp = ( + + test-child + + ); + const result = renderWithProviders(cmp, { initialState }); + // always open the context menu for all tests + fireEvent.contextMenu(result.getByText('test-child')); + return result; + }; + + it('should display the correct options for a started lightning node', async () => { + const { getByText } = renderComponent('alice', Status.Started); + expect(getByText('Create Invoice')).toBeInTheDocument(); + expect(getByText('Pay Invoice')).toBeInTheDocument(); + expect(getByText('Open Outgoing Channel')).toBeInTheDocument(); + expect(getByText('Open Incoming Channel')).toBeInTheDocument(); + expect(getByText('Launch Terminal')).toBeInTheDocument(); + expect(getByText('Stop')).toBeInTheDocument(); + expect(getByText('Advanced Options')).toBeInTheDocument(); + expect(getByText('Remove')).toBeInTheDocument(); + }); + + it('should display the correct options for a stopped lightning node', async () => { + const { getByText, queryByText } = renderComponent('alice', Status.Stopped); + expect(queryByText('Create Invoice')).not.toBeInTheDocument(); + expect(queryByText('Pay Invoice')).not.toBeInTheDocument(); + expect(queryByText('Open Outgoing Channel')).not.toBeInTheDocument(); + expect(queryByText('Open Incoming Channel')).not.toBeInTheDocument(); + expect(queryByText('Launch Terminal')).not.toBeInTheDocument(); + expect(getByText('Start')).toBeInTheDocument(); + expect(getByText('Advanced Options')).toBeInTheDocument(); + expect(getByText('Remove')).toBeInTheDocument(); + }); + + it('should display the correct options for a started bitcoin node', async () => { + const { getByText } = renderComponent('backend1', Status.Started); + expect(getByText('Launch Terminal')).toBeInTheDocument(); + expect(getByText('Stop')).toBeInTheDocument(); + expect(getByText('Advanced Options')).toBeInTheDocument(); + expect(getByText('Remove')).toBeInTheDocument(); + }); + + it('should display the correct options for a stopped bitcoin node', async () => { + const { getByText, queryByText } = renderComponent('backend1', Status.Stopped); + expect(queryByText('Launch Terminal')).not.toBeInTheDocument(); + expect(getByText('Start')).toBeInTheDocument(); + expect(getByText('Advanced Options')).toBeInTheDocument(); + expect(getByText('Remove')).toBeInTheDocument(); + }); + + it('should display a menu for an invalid node', async () => { + const { queryByText } = renderComponent('invalid', Status.Started); + expect(queryByText('Create Invoice')).not.toBeInTheDocument(); + expect(queryByText('Pay Invoice')).not.toBeInTheDocument(); + expect(queryByText('Open Outgoing Channel')).not.toBeInTheDocument(); + expect(queryByText('Open Incoming Channel')).not.toBeInTheDocument(); + expect(queryByText('Launch Terminal')).not.toBeInTheDocument(); + expect(queryByText('Stop')).not.toBeInTheDocument(); + expect(queryByText('Advanced Options')).not.toBeInTheDocument(); + expect(queryByText('Remove')).not.toBeInTheDocument(); + }); + + it('should show the create invoice modal', async () => { + const { getByText, store } = renderComponent('alice', Status.Started); + expect(store.getState().modals.createInvoice.visible).toBe(false); + fireEvent.click(getByText('Create Invoice')); + expect(store.getState().modals.createInvoice.visible).toBe(true); + }); + + it('should show the pay invoice modal', async () => { + const { getByText, store } = renderComponent('alice', Status.Started); + expect(store.getState().modals.payInvoice.visible).toBe(false); + fireEvent.click(getByText('Pay Invoice')); + expect(store.getState().modals.payInvoice.visible).toBe(true); + }); + + it('should show the open outgoing channel modal', async () => { + const { getByText, store } = renderComponent('alice', Status.Started); + expect(store.getState().modals.openChannel.visible).toBe(false); + fireEvent.click(getByText('Open Outgoing Channel')); + expect(store.getState().modals.openChannel.visible).toBe(true); + expect(store.getState().modals.openChannel.from).toBe('alice'); + }); + + it('should show the open incoming channel modal', async () => { + const { getByText, store } = renderComponent('alice', Status.Started); + expect(store.getState().modals.openChannel.visible).toBe(false); + fireEvent.click(getByText('Open Incoming Channel')); + expect(store.getState().modals.openChannel.visible).toBe(true); + expect(store.getState().modals.openChannel.to).toBe('alice'); + }); + + it('should open the terminal', async () => { + const ipcMock = injections.ipc as jest.Mock; + ipcMock.mockResolvedValue(true); + const { getByText, store } = renderComponent('alice', Status.Started); + expect(store.getState().modals.openChannel.visible).toBe(false); + await act(async () => { + fireEvent.click(getByText('Launch Terminal')); + }); + const url = '/terminal/LND/polar-n1-alice'; + expect(ipcMock).toBeCalledWith(ipcChannels.openWindow, { url }); + }); + + it('should show the start node confirmation modal', async () => { + const { getByText, findByText } = renderComponent('alice', Status.Stopped); + fireEvent.click(getByText('Start')); + expect( + await findByText('Would you like to start the alice node?'), + ).toBeInTheDocument(); + }); + + it('should show the stop node confirmation modal', async () => { + const { getByText, findByText } = renderComponent('alice', Status.Started); + fireEvent.click(getByText('Stop')); + expect( + await findByText('Are you sure you want to stop the alice node?'), + ).toBeInTheDocument(); + }); + + it('should show the advanced options modal', async () => { + const { getByText, store } = renderComponent('alice', Status.Started); + expect(store.getState().modals.advancedOptions.visible).toBe(false); + fireEvent.click(getByText('Advanced Options')); + expect(store.getState().modals.advancedOptions.visible).toBe(true); + expect(store.getState().modals.advancedOptions.nodeName).toBe('alice'); + }); + + it('should show the remove node confirmation modal', async () => { + const { getByText, findByText } = renderComponent('alice', Status.Started); + fireEvent.click(getByText('Remove')); + expect( + await findByText('Are you sure you want to remove alice from the network?'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/designer/custom/NodeContextMenu.tsx b/src/components/designer/NodeContextMenu.tsx similarity index 97% rename from src/components/designer/custom/NodeContextMenu.tsx rename to src/components/designer/NodeContextMenu.tsx index d91ab35..7a4adfc 100644 --- a/src/components/designer/custom/NodeContextMenu.tsx +++ b/src/components/designer/NodeContextMenu.tsx @@ -6,7 +6,7 @@ import { LightningNode, Status } from 'shared/types'; import { useStoreState } from 'store'; import { AdvancedOptionsButton, RemoveNode, RestartNode } from 'components/common'; import { OpenTerminalButton } from 'components/terminal'; -import { OpenChannelButtons, PaymentButtons } from '../lightning/actions'; +import { OpenChannelButtons, PaymentButtons } from './lightning/actions'; const Styled = { MenuItem: styled(Menu.Item)` @@ -44,13 +44,13 @@ const NodeContextMenu: React.FC = ({ node: { id }, children }) => { {isStarted && [ createItem( - 'pay', - , + 'inv', + , isLN, ), createItem( - 'inv', - , + 'pay', + , isLN, ), createItem( diff --git a/src/components/designer/custom/Link.tsx b/src/components/designer/custom/Link.tsx index 1bf1d09..338babb 100644 --- a/src/components/designer/custom/Link.tsx +++ b/src/components/designer/custom/Link.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { ILinkDefaultProps, IPosition } from '@mrblenny/react-flow-chart'; import { useTheme } from 'hooks/useTheme'; import { LinkProperties } from 'utils/chart'; -import LinkContextMenu from './LinkContextMenu'; +import LinkContextMenu from '../LinkContextMenu'; export const generateCurvePath = (startPos: IPosition, endPos: IPosition): string => { const width = Math.abs(startPos.x - endPos.x); diff --git a/src/components/designer/custom/NodeInner.tsx b/src/components/designer/custom/NodeInner.tsx index 243b878..85e6421 100644 --- a/src/components/designer/custom/NodeInner.tsx +++ b/src/components/designer/custom/NodeInner.tsx @@ -5,7 +5,7 @@ import { useTheme } from 'hooks/useTheme'; import { ThemeColors } from 'theme/colors'; import { LOADING_NODE_ID } from 'utils/constants'; import { Loader, StatusBadge } from 'components/common'; -import NodeContextMenu from './NodeContextMenu'; +import NodeContextMenu from '../NodeContextMenu'; const Styled = { Node: styled.div<{ size?: ISize; colors: ThemeColors['node'] }>`