Browse Source

test(export): add and update unit tests for import & export

master
jamaljsr 5 years ago
parent
commit
15cd5fc3cf
  1. 13
      .vscode/settings.json
  2. 8
      package.json
  3. 20
      src/__mocks__/archiver.js
  4. 7
      src/__mocks__/electron.js
  5. 2
      src/__mocks__/unzipper.js
  6. 123
      src/components/network/ImportNetwork.spec.tsx
  7. 89
      src/components/network/NetworkView.spec.tsx
  8. 3
      src/i18n/locales/en-US.json
  9. 13
      src/lib/docker/dockerService.spec.ts
  10. 9
      src/setupTests.js
  11. 66
      src/store/models/network.spec.ts
  12. 2
      src/store/models/network.ts
  13. 1
      src/utils/constants.ts
  14. 32
      src/utils/network.ts
  15. 206
      src/utils/zip.spec.ts
  16. 11
      src/utils/zip.ts
  17. 30
      yarn.lock

13
.vscode/settings.json

@ -67,16 +67,5 @@
], ],
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": true
}, }
"cSpell.words": [
"Testcafe",
"antd",
"bitcoind",
"clightning",
"cmps",
"logobw",
"mrblenny",
"unzipper",
"uploader"
]
} }

8
package.json

@ -64,7 +64,7 @@
"@rescripts/rescript-use-babel-config": "0.0.9", "@rescripts/rescript-use-babel-config": "0.0.9",
"@testing-library/jest-dom": "5.1.1", "@testing-library/jest-dom": "5.1.1",
"@testing-library/react": "9.5.0", "@testing-library/react": "9.5.0",
"@types/archiver": "^3.0.0", "@types/archiver": "3.1.0",
"@types/detect-port": "1.1.0", "@types/detect-port": "1.1.0",
"@types/dockerode": "2.5.24", "@types/dockerode": "2.5.24",
"@types/fs-extra": "8.1.0", "@types/fs-extra": "8.1.0",
@ -78,12 +78,12 @@
"@types/react-router": "5.1.4", "@types/react-router": "5.1.4",
"@types/react-router-dom": "5.1.3", "@types/react-router-dom": "5.1.3",
"@types/redux-logger": "3.0.7", "@types/redux-logger": "3.0.7",
"@types/unzipper": "0.10.2",
"@types/ws": "7.2.2", "@types/ws": "7.2.2",
"@types/unzipper": "^0.10.1",
"@typescript-eslint/eslint-plugin": "2.23.0", "@typescript-eslint/eslint-plugin": "2.23.0",
"@typescript-eslint/parser": "2.23.0", "@typescript-eslint/parser": "2.23.0",
"antd": "4.0.2", "antd": "4.0.2",
"archiver": "^3.1.1", "archiver": "3.1.1",
"babel-plugin-emotion": "10.0.29", "babel-plugin-emotion": "10.0.29",
"babel-plugin-import": "1.13.0", "babel-plugin-import": "1.13.0",
"bitcoin-core": "3.0.0", "bitcoin-core": "3.0.0",
@ -143,8 +143,8 @@
"testcafe-react-selectors": "4.0.0", "testcafe-react-selectors": "4.0.0",
"ts-node": "8.6.2", "ts-node": "8.6.2",
"typescript": "3.8.3", "typescript": "3.8.3",
"unzipper": "0.10.10",
"utf-8-validate": "5.0.2", "utf-8-validate": "5.0.2",
"unzipper": "^0.10.8",
"wait-on": "4.0.1", "wait-on": "4.0.1",
"webpack": "4.41.5", "webpack": "4.41.5",
"ws": "7.2.2" "ws": "7.2.2"

20
src/__mocks__/archiver.js

@ -1,19 +1 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires module.exports = jest.fn();
const { PassThrough } = require('stream');
module.exports = () => {
let mockStream;
// return a fake stream when "archiver()" is called in the app
const ctor = function() {
mockStream = new PassThrough();
mockStream.file = jest.fn();
mockStream.directory = jest.fn();
mockStream.append = jest.fn();
mockStream.finalize = jest.fn();
return mockStream;
};
// attach a func to emit events on the stream from the tests
ctor.mockEmit = (event, data) => mockStream.emit(event, data);
return ctor;
};

7
src/__mocks__/electron.js

@ -1,6 +1,3 @@
import { tmpdir } from 'os';
import { join } from 'path';
module.exports = { module.exports = {
remote: { remote: {
app: { app: {
@ -8,9 +5,7 @@ module.exports = {
getLocale: () => 'en-US', getLocale: () => 'en-US',
}, },
dialog: { dialog: {
showSaveDialog: async () => ({ showSaveDialog: jest.fn(),
filePath: join(tmpdir(), 'polar-saved-network.zip'),
}),
}, },
process: { process: {
env: {}, env: {},

2
src/__mocks__/unzipper.js

@ -1,3 +1,3 @@
module.export = { module.exports = {
Extract: jest.fn(), Extract: jest.fn(),
}; };

123
src/components/network/ImportNetwork.spec.tsx

@ -1,35 +1,49 @@
import React from 'react'; import React from 'react';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import { join } from 'path';
import { IChart } from '@mrblenny/react-flow-chart';
import { fireEvent } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
import { PassThrough } from 'stream'; import * as os from 'os';
import * as unzipper from 'unzipper'; import { Network } from 'types';
import { delay } from 'utils/async';
import { initChartFromNetwork } from 'utils/chart'; import { initChartFromNetwork } from 'utils/chart';
import * as files from 'utils/files'; import * as files from 'utils/files';
import { getNetwork, renderWithProviders } from 'utils/tests'; import { getNetwork, renderWithProviders } from 'utils/tests';
import * as zip from 'utils/zip';
import ImportNetwork from './ImportNetwork'; import ImportNetwork from './ImportNetwork';
jest.mock('utils/files'); jest.mock('utils/files');
jest.mock('unzipper', () => ({ jest.mock('utils/zip');
Extract: jest.fn(), jest.mock('os');
}));
const osMock = os as jest.Mocked<typeof os>;
const fsMock = fs as jest.Mocked<typeof fs>; const fsMock = fs as jest.Mocked<typeof fs>;
const filesMock = files as jest.Mocked<typeof files>; const filesMock = files as jest.Mocked<typeof files>;
const unzipperMock = unzipper as jest.Mocked<typeof unzipper>; const zipMock = zip as jest.Mocked<typeof zip>;
describe('ImportNetwork component', () => { describe('ImportNetwork component', () => {
const network = getNetwork(); const exportFilePath = join('/', 'tmp', 'polar', 'file', 'export.json');
const chart = initChartFromNetwork(network); let network: Network;
let unzipStream: PassThrough; let chart: IChart;
const renderComponent = () => { const renderComponent = () => {
const result = renderWithProviders(<ImportNetwork />); const network = getNetwork(1, 'test network');
const initialState = {
network: {
networks: [network],
},
designer: {
activeId: network.id,
allCharts: {
1: initChartFromNetwork(network),
},
},
};
const result = renderWithProviders(<ImportNetwork />, { initialState });
// attach the file to the input before triggering change
const fileInput = result.container.querySelector( const fileInput = result.container.querySelector(
'input[type=file]', 'input[type=file]',
) as HTMLInputElement; ) as HTMLInputElement;
// attach the file to the input so the Uploader can receive it
const file = new File(['asdf'], 'file.zip'); const file = new File(['asdf'], 'file.zip');
file.path = 'file.zip'; file.path = 'file.zip';
Object.defineProperty(fileInput, 'files', { value: [file] }); Object.defineProperty(fileInput, 'files', { value: [file] });
@ -41,24 +55,14 @@ describe('ImportNetwork component', () => {
}; };
beforeEach(() => { beforeEach(() => {
fsMock.pathExists.mockResolvedValue(true as never); network = getNetwork();
fsMock.mkdirp.mockResolvedValue(true as never); chart = initChartFromNetwork(network);
fsMock.copy.mockResolvedValue(true as never);
filesMock.read.mockResolvedValue(JSON.stringify({ network, chart })); filesMock.read.mockResolvedValue(JSON.stringify({ network, chart }));
fsMock.createReadStream.mockImplementation(() => { filesMock.rm.mockResolvedValue();
return { zipMock.unzip.mockResolvedValue();
pipe: jest.fn(() => { fsMock.copy.mockResolvedValue(true as never);
// return the mock stream when "pipe()" is called osMock.tmpdir.mockReturnValue('/tmp');
unzipStream = new PassThrough(); osMock.platform.mockReturnValue('darwin');
return unzipStream;
}),
} as any;
});
unzipperMock.Extract.mockImplementation(jest.fn());
});
afterEach(() => {
if (unzipStream) unzipStream.destroy();
}); });
it('should display the file upload label', async () => { it('should display the file upload label', async () => {
@ -83,9 +87,6 @@ describe('ImportNetwork component', () => {
expect(queryByLabelText('loading')).not.toBeInTheDocument(); expect(queryByLabelText('loading')).not.toBeInTheDocument();
fireEvent.change(fileInput); fireEvent.change(fileInput);
expect(queryByLabelText('loading')).toBeInTheDocument(); expect(queryByLabelText('loading')).toBeInTheDocument();
// close the unzip stream after a small delay
await delay(100);
unzipStream.emit('close');
expect( expect(
await findByText("Imported network 'my-test' successfully"), await findByText("Imported network 'my-test' successfully"),
).toBeInTheDocument(); ).toBeInTheDocument();
@ -95,10 +96,62 @@ describe('ImportNetwork component', () => {
fsMock.copy.mockRejectedValue(new Error('test-error') as never); fsMock.copy.mockRejectedValue(new Error('test-error') as never);
const { findByText, fileInput } = renderComponent(); const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput); fireEvent.change(fileInput);
// fail the unzip stream after a small delay
await delay(100);
unzipStream.emit('error', new Error('test-error'));
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument(); expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
expect(await findByText('test-error')).toBeInTheDocument(); expect(await findByText('test-error')).toBeInTheDocument();
}); });
it('should throw if the network is missing', async () => {
filesMock.read.mockResolvedValue(JSON.stringify({ chart }));
const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput);
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
const msg = `${exportFilePath} did not contain a valid network`;
expect(await findByText(msg)).toBeInTheDocument();
});
it('should throw if the network is not valid', async () => {
filesMock.read.mockResolvedValue(JSON.stringify({ network: {}, chart }));
const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput);
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
const msg = `${exportFilePath} did not contain a valid network`;
expect(await findByText(msg)).toBeInTheDocument();
});
it('should throw if the chart is missing', async () => {
filesMock.read.mockResolvedValue(JSON.stringify({ network }));
const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput);
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
const msg = `${exportFilePath} did not contain a valid chart`;
expect(await findByText(msg)).toBeInTheDocument();
});
it('should throw if the chart is not valid', async () => {
filesMock.read.mockResolvedValue(JSON.stringify({ network, chart: {} }));
const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput);
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
const msg = `${exportFilePath} did not contain a valid chart`;
expect(await findByText(msg)).toBeInTheDocument();
});
it('should throw if the network is not supported', async () => {
osMock.platform.mockReturnValue('win32');
const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput);
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
const msg = 'Importing networks with c-lightning nodes is not supported on windows';
expect(await findByText(msg)).toBeInTheDocument();
});
it('should throw for an unknown LN implementation', async () => {
network.nodes.lightning[0].implementation = 'asdf' as any;
filesMock.read.mockResolvedValue(JSON.stringify({ network, chart }));
const { findByText, fileInput } = renderComponent();
fireEvent.change(fileInput);
expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument();
const msg = "Cannot import unknown Lightning implementation 'asdf'";
expect(await findByText(msg)).toBeInTheDocument();
});
}); });

89
src/components/network/NetworkView.spec.tsx

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import electron from 'electron'; import electron from 'electron';
import * as log from 'electron-log';
import fsExtra from 'fs-extra'; import fsExtra from 'fs-extra';
import { fireEvent, wait, waitForElement } from '@testing-library/dom'; import { fireEvent, wait, waitForElement } from '@testing-library/dom';
import { act } from '@testing-library/react'; import { act } from '@testing-library/react';
@ -15,9 +16,15 @@ import {
suppressConsoleErrors, suppressConsoleErrors,
testCustomImages, testCustomImages,
} from 'utils/tests'; } from 'utils/tests';
import * as zip from 'utils/zip';
import NetworkView from './NetworkView'; import NetworkView from './NetworkView';
jest.mock('utils/zip');
const fsMock = fsExtra as jest.Mocked<typeof fsExtra>; const fsMock = fsExtra as jest.Mocked<typeof fsExtra>;
const logMock = log as jest.Mocked<typeof log>;
const zipMock = zip as jest.Mocked<typeof zip>;
const dialogMock = electron.remote.dialog as jest.Mocked<typeof electron.remote.dialog>;
const bitcoindServiceMock = injections.bitcoindService as jest.Mocked< const bitcoindServiceMock = injections.bitcoindService as jest.Mocked<
typeof injections.bitcoindService typeof injections.bitcoindService
>; >;
@ -25,10 +32,6 @@ const dockerServiceMock = injections.dockerService as jest.Mocked<
typeof injections.dockerService typeof injections.dockerService
>; >;
jest.mock('utils/zip', () => ({
zip: jest.fn(),
}));
describe('NetworkView Component', () => { describe('NetworkView Component', () => {
const renderComponent = ( const renderComponent = (
id: string | undefined, id: string | undefined,
@ -222,46 +225,39 @@ describe('NetworkView Component', () => {
}); });
describe('rename network', () => { describe('rename network', () => {
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should show the rename input', async () => { it('should show the rename input', async () => {
const { getByLabelText, getByText, findByDisplayValue } = renderComponent('1'); const { getByLabelText, findByText, findByDisplayValue } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more')); fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers()); fireEvent.click(await findByText('Rename'));
fireEvent.click(getByText('Rename'));
const input = (await findByDisplayValue('test network')) as HTMLInputElement; const input = (await findByDisplayValue('test network')) as HTMLInputElement;
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
expect(input.type).toBe('text'); expect(input.type).toBe('text');
fireEvent.click(getByText('Cancel')); fireEvent.click(await findByText('Cancel'));
expect(input).not.toBeInTheDocument(); expect(input).not.toBeInTheDocument();
expect(getByText('Start')).toBeInTheDocument(); expect(await findByText('Start')).toBeInTheDocument();
}); });
it('should rename the network', async () => { it('should rename the network', async () => {
const { getByLabelText, getByText, findByDisplayValue, store } = renderComponent( const { getByLabelText, findByText, findByDisplayValue, store } = renderComponent(
'1', '1',
); );
fireEvent.mouseOver(getByLabelText('more')); fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers()); fireEvent.click(await findByText('Rename'));
fireEvent.click(getByText('Rename'));
const input = await findByDisplayValue('test network'); const input = await findByDisplayValue('test network');
fireEvent.change(input, { target: { value: 'new network name' } }); fireEvent.change(input, { target: { value: 'new network name' } });
fireEvent.click(getByText('Save')); fireEvent.click(await findByText('Save'));
await wait(() => jest.runOnlyPendingTimers());
expect(store.getState().network.networkById(1).name).toBe('new network name'); expect(store.getState().network.networkById(1).name).toBe('new network name');
expect(await findByText('Start')).toBeInTheDocument();
}); });
it('should display an error if renaming fails', async () => { it('should display an error if renaming fails', async () => {
const { getByLabelText, getByText, findByDisplayValue } = renderComponent('1'); const { getByLabelText, findByText, findByDisplayValue } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more')); fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers()); fireEvent.click(await findByText('Rename'));
fireEvent.click(getByText('Rename'));
const input = await findByDisplayValue('test network'); const input = await findByDisplayValue('test network');
fireEvent.change(input, { target: { value: '' } }); fireEvent.change(input, { target: { value: '' } });
fireEvent.click(getByText('Save')); fireEvent.click(await findByText('Save'));
await wait(() => jest.runOnlyPendingTimers()); expect(await findByText('Failed to rename the network')).toBeInTheDocument();
expect(getByText('Failed to rename the network')).toBeInTheDocument();
}); });
}); });
@ -312,48 +308,39 @@ describe('NetworkView Component', () => {
}); });
describe('export network', () => { describe('export network', () => {
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should fail to export a running network', async () => { it('should fail to export a running network', async () => {
const { getByText, getByLabelText } = renderComponent('1'); const { getByLabelText, findByText } = renderComponent('1', Status.Started);
const primaryBtn = getByText('Start');
fireEvent.click(primaryBtn);
// should change to stopped after some time
await wait(() => {
expect(primaryBtn).toHaveTextContent('Stop');
});
fireEvent.mouseOver(getByLabelText('more')); fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers()); fireEvent.click(await findByText('Export'));
expect(
fireEvent.click(getByText('Export')); await findByText('The network must be stopped to be exported'),
await wait(() => jest.runOnlyPendingTimers()); ).toBeInTheDocument();
expect(getByText('The network must be stopped to be exported')).toBeInTheDocument();
}); });
it('should export a stopped network', async () => { it('should export a stopped network', async () => {
const { getByText, getByLabelText } = renderComponent('1'); dialogMock.showSaveDialog.mockResolvedValue({ filePath: 'file.zip' } as any);
const { findByText, getByLabelText } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more')); fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers()); fireEvent.click(await findByText('Export'));
expect(
fireEvent.click(getByText('Export')); await findByText("Exported 'test network'", { exact: false }),
await wait(() => jest.runOnlyPendingTimers()); ).toBeInTheDocument();
expect(getByText("Exported 'test network'", { exact: false })).toBeInTheDocument();
}); });
it('should not export the network if the user closes the file save dialogue', async () => { it('should not export the network if the user closes the file save dialogue', async () => {
jest.useFakeTimers();
// returns undefined if user closes the window // returns undefined if user closes the window
electron.remote.dialog.showSaveDialog = jest.fn(() => ({})) as any; dialogMock.showSaveDialog.mockResolvedValue({} as any);
const { queryByText, findByText, getByLabelText } = renderComponent('1');
const { queryByText, getByText, getByLabelText } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more')); fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers()); await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(await findByText('Export'));
fireEvent.click(getByText('Export'));
await wait(() => jest.runOnlyPendingTimers()); await wait(() => jest.runOnlyPendingTimers());
expect(logMock.info).toBeCalledWith('User aborted network export');
expect(dialogMock.showSaveDialog).toBeCalled();
expect(zipMock.zip).not.toBeCalled();
expect(queryByText("Exported 'test network'", { exact: false })).toBeNull(); expect(queryByText("Exported 'test network'", { exact: false })).toBeNull();
jest.useRealTimers();
}); });
}); });
}); });

3
src/i18n/locales/en-US.json

@ -350,5 +350,6 @@
"store.models.network.exportTitle": "Export '{{name}}'", "store.models.network.exportTitle": "Export '{{name}}'",
"store.models.network.exportBadStatus": "The network must be stopped to be exported", "store.models.network.exportBadStatus": "The network must be stopped to be exported",
"utils.network.backendCompatError": "This network does not contain a Bitcoin Core v{{requiredVersion}} (or lower) node which is required for {{implementation}} v{{version}}", "utils.network.backendCompatError": "This network does not contain a Bitcoin Core v{{requiredVersion}} (or lower) node which is required for {{implementation}} v{{version}}",
"utils.network.incompatibleImplementation": "Importing networks with {{implementation}} nodes is not supported on {{platform}}" "utils.network.incompatibleImplementation": "Importing networks with {{implementation}} nodes is not supported on {{platform}}",
"utils.network.unknownImplementation": "Cannot import unknown Lightning implementation '{{implementation}}'"
} }

13
src/lib/docker/dockerService.spec.ts

@ -23,6 +23,7 @@ jest.mock('utils/files', () => ({
})); }));
const mockOS = os as jest.Mocked<typeof os>; const mockOS = os as jest.Mocked<typeof os>;
const fsMock = fs as jest.Mocked<typeof fs>;
const filesMock = files as jest.Mocked<typeof files>; const filesMock = files as jest.Mocked<typeof files>;
const composeMock = compose as jest.Mocked<typeof compose>; const composeMock = compose as jest.Mocked<typeof compose>;
const electronMock = electron as jest.Mocked<typeof electron>; const electronMock = electron as jest.Mocked<typeof electron>;
@ -337,6 +338,18 @@ describe('DockerService', () => {
expect(networks[0].path).toEqual(join(networksPath, `${networks[0].id}`)); expect(networks[0].path).toEqual(join(networksPath, `${networks[0].id}`));
}); });
it('should not throw if older version folder fails to copy', async () => {
filesMock.exists.mockResolvedValueOnce(true); // legacy path
filesMock.exists.mockResolvedValueOnce(false); // current path before copy
fsMock.copy.mockRejectedValue(new Error('test-error') as never);
filesMock.exists.mockResolvedValueOnce(true); // current path after copy
filesMock.read.mockResolvedValue(createLegacyNetworksFile());
const { networks, version } = await dockerService.loadNetworks();
expect(version).toEqual(APP_VERSION);
expect(networks.length).toBe(1);
expect(networks[0].path).toEqual(join(networksPath, `${networks[0].id}`));
});
it('should migrate network data from an older version', async () => { it('should migrate network data from an older version', async () => {
filesMock.exists.mockResolvedValue(true); filesMock.exists.mockResolvedValue(true);
filesMock.read.mockResolvedValue(createLegacyNetworksFile()); filesMock.read.mockResolvedValue(createLegacyNetworksFile());

9
src/setupTests.js

@ -48,8 +48,15 @@ afterEach(async () => {
// code below destroys those components before the next test is run // code below destroys those components before the next test is run
message.destroy(); message.destroy();
notification.destroy(); notification.destroy();
// wait for the modal to be removed before starting the next test. it uses a short animation
const getNotification = () => document.querySelector('.ant-notification');
if (getNotification()) {
await waitForElementToBeRemoved(getNotification);
}
Modal.destroyAll(); Modal.destroyAll();
// wait for the modal to be removed before starting the next test. it uses a short animation // wait for the modal to be removed before starting the next test. it uses a short animation
const getModal = () => document.querySelector('.ant-modal-root'); const getModal = () => document.querySelector('.ant-modal-root');
if (getModal()) await waitForElementToBeRemoved(getModal); if (getModal()) {
await waitForElementToBeRemoved(getModal);
}
}); });

66
src/store/models/network.spec.ts

@ -1,4 +1,3 @@
import electron from 'electron';
import * as log from 'electron-log'; import * as log from 'electron-log';
import { wait } from '@testing-library/react'; import { wait } from '@testing-library/react';
import detectPort from 'detect-port'; import detectPort from 'detect-port';
@ -25,20 +24,6 @@ jest.mock('utils/files', () => ({
rm: jest.fn(), rm: jest.fn(),
})); }));
jest.mock('utils/network', () => ({
...jest.requireActual('utils/network'),
importNetworkFromZip: () => {
const network = {
id: 1,
nodes: {
bitcoin: [{}],
lightning: [{}],
},
};
return [network, {}];
},
}));
jest.mock('utils/zip', () => ({ jest.mock('utils/zip', () => ({
zip: jest.fn(), zip: jest.fn(),
unzip: jest.fn(), unzip: jest.fn(),
@ -836,57 +821,10 @@ describe('Network model', () => {
}; };
await expect(updateAdvancedOptions({ node, command: '' })).rejects.toThrow(); await expect(updateAdvancedOptions({ node, command: '' })).rejects.toThrow();
}); });
});
describe('Export', () => {
beforeEach(() => {
const { addNetwork } = store.getActions().network;
addNetwork(addNetworkArgs);
});
it('should export a network and show a save dialogue', async () => { it('should fail to export with an invalid id', async () => {
const { exportNetwork } = store.getActions().network; const { exportNetwork } = store.getActions().network;
await expect(exportNetwork({ id: 10 })).rejects.toThrow();
const spy = jest.spyOn(electron.remote.dialog, 'showSaveDialog');
const exported = await exportNetwork(firstNetwork());
expect(exported).toBeDefined();
expect(spy).toHaveBeenCalled();
});
it('should not export a network if the user closes the dialogue', async () => {
const mock = electron.remote.dialog.showSaveDialog as jest.MockedFunction<
typeof electron.remote.dialog.showSaveDialog
>;
// returns undefined if user closes the window
mock.mockImplementation(() => ({} as any));
const { exportNetwork } = store.getActions().network;
const exported = await exportNetwork(firstNetwork());
expect(exported).toBeUndefined();
});
});
describe('Import', () => {
it('should import a network', async () => {
const { importNetwork } = store.getActions().network;
const statePreImport = store.getState();
const imported = await importNetwork('zip');
expect(imported.id).toBeDefined();
expect(imported.nodes.bitcoin.length).toBeGreaterThan(0);
expect(imported.nodes.lightning.length).toBeGreaterThan(0);
const statePostImport = store.getState();
expect(statePostImport.network.networks.length).toEqual(
statePreImport.network.networks.length + 1,
);
const numChartsPost = Object.keys(statePostImport.designer.allCharts).length;
const numChartsPre = Object.keys(statePreImport.designer.allCharts).length;
expect(numChartsPost).toEqual(numChartsPre + 1);
}); });
}); });
}); });

2
src/store/models/network.ts

@ -645,7 +645,7 @@ const networkModel: NetworkModel = {
return filePath; return filePath;
}), }),
importNetwork: thunk( importNetwork: thunk(
async (_, path, { getStoreState, getStoreActions, injections }) => { async (actions, path, { getStoreState, getStoreActions, injections }) => {
const { networks } = getStoreState().network; const { networks } = getStoreState().network;
const { add, save } = getStoreActions().network; const { add, save } = getStoreActions().network;
const { setChart } = getStoreActions().designer; const { setChart } = getStoreActions().designer;

1
src/utils/constants.ts

@ -143,7 +143,6 @@ export const dockerConfigs: Record<NodeImplementation, DockerConfig> = {
'bitcoind', 'bitcoind',
'-server=1', '-server=1',
'-regtest=1', '-regtest=1',
'-reindex',
'-rpcauth={{rpcUser}}:{{rpcAuth}}', '-rpcauth={{rpcUser}}:{{rpcAuth}}',
'-debug=1', '-debug=1',
'-zmqpubrawblock=tcp://0.0.0.0:28334', '-zmqpubrawblock=tcp://0.0.0.0:28334',

32
src/utils/network.ts

@ -529,27 +529,16 @@ export const importNetworkFromZip = async (
const parsed = JSON.parse(await read(exportFilePath)); const parsed = JSON.parse(await read(exportFilePath));
// validate the network and chart // validate the network and chart
if (!(parsed.network && isNetwork(parsed.network))) { if (!(parsed.network && isNetwork(parsed.network))) {
throw Error(`${exportFilePath} did not contain a valid network`); throw new Error(`${exportFilePath} did not contain a valid network`);
} }
if (!(parsed.chart && isChart(parsed.chart))) { if (!(parsed.chart && isChart(parsed.chart))) {
throw Error(`${exportFilePath} did not contain a valid chart`); throw new Error(`${exportFilePath} did not contain a valid chart`);
} }
const network = parsed.network as Network; const network = parsed.network as Network;
const chart = parsed.chart as IChart; const chart = parsed.chart as IChart;
const netPath = join(dataPath, 'networks', `${id}`); const netPath = join(dataPath, 'networks', `${id}`);
// confirms all nodes in the network are supported on the current OS
const platform = getPolarPlatform();
for (const { implementation } of network.nodes.lightning) {
const { platforms } = dockerConfigs[implementation];
const nodeSupportsPlatform = platforms.includes(platform);
if (!nodeSupportsPlatform) {
throw Error(l('incompatibleImplementation', { implementation, platform }));
}
}
debug(`Updating the network path from '${network.path}' to '${netPath}'`); debug(`Updating the network path from '${network.path}' to '${netPath}'`);
// update the paths for the network and nodes
network.path = netPath; network.path = netPath;
debug(`Updating network id to '${id}'`); debug(`Updating network id to '${id}'`);
network.id = id; network.id = id;
@ -564,12 +553,25 @@ export const importNetworkFromZip = async (
} else if (ln.implementation === 'c-lightning') { } else if (ln.implementation === 'c-lightning') {
const cln = ln as CLightningNode; const cln = ln as CLightningNode;
cln.paths = getCLightningFilePaths(cln.name, network); cln.paths = getCLightningFilePaths(cln.name, network);
} else {
throw new Error(l('unknownImplementation', { implementation: ln.implementation }));
} }
}); });
// confirms all nodes in the network are supported on the current OS
const platform = getPolarPlatform();
for (const { implementation } of network.nodes.lightning) {
const { platforms } = dockerConfigs[implementation];
const nodeSupportsPlatform = platforms.includes(platform);
if (!nodeSupportsPlatform) {
throw new Error(l('incompatibleImplementation', { implementation, platform }));
}
}
// remove the export file as it is no longer needed // remove the export file as it is no longer needed
rm(exportFilePath); await rm(exportFilePath);
// copy the network files to correct path inside of ~/.polar
debug(`Copying '${tmpDir}' to '${network.path}'`);
await copy(tmpDir, network.path); await copy(tmpDir, network.path);
return [network, chart]; return [network, chart];

206
src/utils/zip.spec.ts

@ -1,107 +1,131 @@
import fsExtra from 'fs-extra'; import fsExtra from 'fs-extra';
import { join } from 'path'; import { join } from 'path';
import archiver from 'archiver'; import * as archiver from 'archiver';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import unzipper from 'unzipper';
import * as files from 'utils/files';
import { delay } from './async';
import { initChartFromNetwork } from './chart';
import { getNetwork } from './tests';
import { unzip, zip } from './zip'; import { unzip, zip } from './zip';
jest.mock('utils/files');
const fsMock = fsExtra as jest.Mocked<typeof fsExtra>; const fsMock = fsExtra as jest.Mocked<typeof fsExtra>;
const filesMock = files as jest.Mocked<typeof files>;
const unzipperMock = unzipper as jest.Mocked<typeof unzipper>;
const archiverMock = archiver as jest.Mocked<any>; const archiverMock = archiver as jest.Mocked<any>;
describe('unzip', () => { describe('Zip Util', () => {
it("fail to unzip something that isn't a zip", async () => { let unzipStream: PassThrough;
return expect( let zipStream: any;
unzip(join(__dirname, 'tests', 'resources', 'bar.txt'), 'foobar'), let zippedPaths: string[];
).rejects.toThrow(); const rpcPath = join('alice', 'lightningd', 'regtest', 'lightning-rpc');
beforeEach(() => {
const network = getNetwork();
const chart = initChartFromNetwork(network);
filesMock.read.mockResolvedValue(JSON.stringify({ network, chart }));
fsMock.pathExists.mockResolvedValue(true as never);
fsMock.mkdirp.mockResolvedValue(true as never);
fsMock.copy.mockResolvedValue(true as never);
fsMock.createWriteStream.mockReturnValue(new PassThrough() as any);
fsMock.createReadStream.mockImplementation(() => {
return {
pipe: jest.fn(() => {
// return the mock stream when "pipe()" is called
unzipStream = new PassThrough();
return unzipStream;
}),
} as any;
});
unzipperMock.Extract.mockImplementation(jest.fn());
archiverMock.mockImplementation(() => {
// return a fake stream when "archiver()" is called in the app
zipStream = new PassThrough();
zipStream.file = jest.fn();
zipStream.directory = jest.fn(
(src: string, dest: string, entryData: archiver.EntryDataFunction) => {
// call the entryData func on some test paths
const mockPaths = ['test.yml', 'other.json', rpcPath];
zippedPaths = [];
mockPaths.forEach(name => {
const result = entryData({ name });
if (result) zippedPaths.push(name);
});
},
);
zipStream.append = jest.fn();
zipStream.finalize = jest.fn();
// attach a func to emit events on the stream from the tests
zipStream.mockEmit = (event: any, data: any) => zipStream.emit(event, data);
return zipStream;
});
}); });
it("fails to unzip something that doesn't exist", async () => { afterEach(() => {
return expect(unzip('foobar', 'bazfoo')).rejects.toThrow(); if (unzipStream) unzipStream.destroy();
if (zipStream) zipStream.destroy();
}); });
});
describe('zip', () => {
// it('zips objects', async () => {
// const objects: Array<{ name: string; object: any }> = [
// {
// name: 'firstObject',
// object: 2,
// },
// {
// name: 'secondObject',
// object: { baz: 'baz' },
// },
// {
// name: 'thirdObject',
// object: [2, { foo: 'foo' }, false],
// },
// ];
// const zipped = join(tmpdir(), `zip-test-${Date.now()}.zip`);
// await zip({
// destination: zipped,
// objects,
// paths: [],
// });
// const unzipped = join(tmpdir(), `zip-test-${Date.now()}`);
// await unzip(zipped, unzipped);
// for (const obj of objects) {
// const read = await fsExtra
// .readFile(join(unzipped, obj.name))
// .then(read => JSON.parse(read.toString('utf-8')));
// expect(read).toEqual(obj.object);
// }
// });
// it('zips paths', async () => {
// const files = [
// join(__dirname, 'tests', 'resources', 'bar.txt'),
// join(__dirname, 'tests', 'resources', 'foo.json'),
// join(__dirname, 'tests', 'resources', 'baz'),
// ];
// const zipped = join(tmpdir(), `zip-test-${Date.now()}.zip`);
// await zip({ destination: zipped, objects: [], paths: files });
// const unzipped = join(tmpdir(), `zip-test-${Date.now()}`);
// await unzip(zipped, unzipped);
// const entries = await fs..readdir(unzipped, { withFileTypes: true });
// const bar = entries.find(e => e.name === 'bar.txt'); describe('unzip', () => {
// const baz = entries.find(e => e.name === 'baz'); it('should unzip a file successfully', async () => {
// const foo = entries.find(e => e.name === 'foo.json'); const promise = unzip('source-path', 'destination-path');
// emit an error after a small delay
// expect(bar?.isFile()).toBeTruthy(); await delay(100);
// expect(baz?.isDirectory()).toBeTruthy(); unzipStream.emit('close');
// expect(foo?.isFile()).toBeTruthy(); await expect(promise).resolves.toBeUndefined();
// }); });
it('should fail if there is an archiver error', async () => { it("fail to unzip something that isn't a zip", async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any); const promise = unzip('source-path', 'destination-file');
// fail the unzip stream after a small delay
const promise = zip('source', 'destination'); await delay(100);
unzipStream.emit('error', new Error('test-error'));
// emit an error after a small delay await expect(promise).rejects.toThrow();
const mockError = new Error('test-error'); });
setTimeout(() => {
archiverMock.mockEmit('error', mockError); it("fails to unzip something that doesn't exist", async () => {
}, 100); fsMock.pathExists.mockResolvedValue(false as never);
return expect(unzip('source-path', 'destination-file')).rejects.toThrow();
await expect(promise).rejects.toEqual(mockError); });
}); });
it('should fail if there is an archiver warning', async () => { describe('zip', () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any); it('should zip a folder successfully', async () => {
const promise = zip('source-path', 'destination-path');
const promise = zip('source', 'destination'); // emit an error after a small delay
await delay(100);
// emit an error after a small delay zipStream.emit('finish');
const mockError = new Error('test-warning'); await expect(promise).resolves.toBeUndefined();
setTimeout(() => { });
archiverMock.mockEmit('warning', mockError);
}, 100); it('should not include the c-lightning RPC in the zip', async () => {
const promise = zip('source-path', 'destination-path');
await expect(promise).rejects.toEqual(mockError); // emit an error after a small delay
await delay(100);
zipStream.emit('finish');
await expect(promise).resolves.toBeUndefined();
expect(zippedPaths).not.toContain(rpcPath);
expect(zippedPaths).toEqual(['test.yml', 'other.json']);
});
it('should fail if there is an archiver error', async () => {
const promise = zip('source-path', 'destination-path');
// emit an error after a small delay
await delay(100);
const mockError = new Error('test-error');
zipStream.emit('error', mockError);
await expect(promise).rejects.toEqual(mockError);
});
it('should fail if there is an archiver warning', async () => {
const promise = zip('source-path', 'destination-path');
// emit a warning after a small delay
const mockError = new Error('test-warning');
zipStream.emit('warning', mockError);
await expect(promise).rejects.toEqual(mockError);
});
}); });
}); });

11
src/utils/zip.ts

@ -39,27 +39,24 @@ export const unzip = (filePath: string, destination: string): Promise<void> => {
export const zip = (source: string, destination: string): Promise<void> => export const zip = (source: string, destination: string): Promise<void> =>
new Promise(async (resolve, reject) => { new Promise(async (resolve, reject) => {
info(`zipping ${source} to ${destination}`); info(`zipping ${source} to ${destination}`);
const output = createWriteStream(destination);
const archive = archiver('zip'); const archive = archiver('zip');
// finished
archive.on('finish', () => resolve()); archive.on('finish', () => resolve());
archive.on('error', err => { archive.on('error', err => {
error(`got error when zipping ${destination}:`, err); error(`got error when zipping ${destination}:`, err);
reject(err); reject(err);
}); });
archive.on('warning', warning => { archive.on('warning', warning => {
warn(`got warning when zipping ${destination}:`, warning); warn(`got warning when zipping ${destination}:`, warning);
reject(warning); reject(warning);
}); });
// pipe all zipped data to the output // pipe all zipped data to the destination file
archive.pipe(output); archive.pipe(createWriteStream(destination));
// avoid including the c-lightning RPC socket // avoid including the c-lightning RPC socket
const entryData: archiver.EntryDataFunction = entry => { const entryData: archiver.EntryDataFunction = entry => {
if (entry.name?.endsWith(join('lightningd', 'regtest', 'lightning-rpc'))) { const rpcPath = join('lightningd', 'regtest', 'lightning-rpc');
if (entry.name && entry.name.endsWith(rpcPath)) {
info('skipping', entry); info('skipping', entry);
return false; return false;
} }

30
yarn.lock

@ -1866,7 +1866,7 @@
"@testing-library/dom" "^6.15.0" "@testing-library/dom" "^6.15.0"
"@types/testing-library__react" "^9.1.2" "@types/testing-library__react" "^9.1.2"
"@types/archiver@^3.0.0": "@types/archiver@3.1.0":
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-3.1.0.tgz#0d5bd922ba5cf06e137cd6793db7942439b1805e" resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-3.1.0.tgz#0d5bd922ba5cf06e137cd6793db7942439b1805e"
integrity sha512-nTvHwgWONL+iXG+9CX+gnQ/tTOV+qucAjwpXqeUn4OCRMxP42T29FFP/7XaOo0EqqO3TlENhObeZEe7RUJAriw== integrity sha512-nTvHwgWONL+iXG+9CX+gnQ/tTOV+qucAjwpXqeUn4OCRMxP42T29FFP/7XaOo0EqqO3TlENhObeZEe7RUJAriw==
@ -2177,7 +2177,7 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
"@types/unzipper@^0.10.1": "@types/unzipper@0.10.2":
version "0.10.2" version "0.10.2"
resolved "https://registry.yarnpkg.com/@types/unzipper/-/unzipper-0.10.2.tgz#35c2da714d406b2cd7ada5006e1e206bdd0eb2f4" resolved "https://registry.yarnpkg.com/@types/unzipper/-/unzipper-0.10.2.tgz#35c2da714d406b2cd7ada5006e1e206bdd0eb2f4"
integrity sha512-VgYoNEyj8xkz9I+RTWD00iB9JVViK/RBteNDjOIV3/kdCUPaskka7xAZfFlIxRwKGSPf77F8yje5bJt2PefofQ== integrity sha512-VgYoNEyj8xkz9I+RTWD00iB9JVViK/RBteNDjOIV3/kdCUPaskka7xAZfFlIxRwKGSPf77F8yje5bJt2PefofQ==
@ -2253,6 +2253,15 @@
regexpp "^3.0.0" regexpp "^3.0.0"
tsutils "^3.17.1" tsutils "^3.17.1"
"@typescript-eslint/experimental-utils@2.22.0":
version "2.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.22.0.tgz#4d00c91fbaaa68e56e7869be284999a265707f85"
integrity sha512-sJt1GYBe6yC0dWOQzXlp+tiuGglNhJC9eXZeC8GBVH98Zv9jtatccuhz0OF5kC/DwChqsNfghHx7OlIDQjNYAQ==
dependencies:
"@types/json-schema" "^7.0.3"
"@typescript-eslint/typescript-estree" "2.22.0"
eslint-scope "^5.0.0"
"@typescript-eslint/experimental-utils@2.23.0": "@typescript-eslint/experimental-utils@2.23.0":
version "2.23.0" version "2.23.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.23.0.tgz#5d2261c8038ec1698ca4435a8da479c661dc9242" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.23.0.tgz#5d2261c8038ec1698ca4435a8da479c661dc9242"
@ -2282,6 +2291,19 @@
"@typescript-eslint/typescript-estree" "2.22.0" "@typescript-eslint/typescript-estree" "2.22.0"
eslint-visitor-keys "^1.1.0" eslint-visitor-keys "^1.1.0"
"@typescript-eslint/typescript-estree@2.22.0":
version "2.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.22.0.tgz#a16ed45876abf743e1f5857e2f4a1c3199fd219e"
integrity sha512-2HFZW2FQc4MhIBB8WhDm9lVFaBDy6h9jGrJ4V2Uzxe/ON29HCHBTj3GkgcsgMWfsl2U5as+pTOr30Nibaw7qRQ==
dependencies:
debug "^4.1.1"
eslint-visitor-keys "^1.1.0"
glob "^7.1.6"
is-glob "^4.0.1"
lodash "^4.17.15"
semver "^6.3.0"
tsutils "^3.17.1"
"@typescript-eslint/typescript-estree@2.23.0": "@typescript-eslint/typescript-estree@2.23.0":
version "2.23.0" version "2.23.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.23.0.tgz#d355960fab96bd550855488dcc34b9a4acac8d36" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.23.0.tgz#d355960fab96bd550855488dcc34b9a4acac8d36"
@ -2874,7 +2896,7 @@ archiver-utils@^2.1.0:
normalize-path "^3.0.0" normalize-path "^3.0.0"
readable-stream "^2.0.0" readable-stream "^2.0.0"
archiver@^3.1.1: archiver@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0" resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg== integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
@ -17758,7 +17780,7 @@ unzip-response@^2.0.1:
resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
unzipper@^0.10.8: unzipper@0.10.10:
version "0.10.10" version "0.10.10"
resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.10.tgz#d82d41fbdfa1f0731123eb11c2cfc028b45d3d42" resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.10.tgz#d82d41fbdfa1f0731123eb11c2cfc028b45d3d42"
integrity sha512-wEgtqtrnJ/9zIBsQb8UIxOhAH1eTHfi7D/xvmrUoMEePeI6u24nq1wigazbIFtHt6ANYXdEVTvc8XYNlTurs7A== integrity sha512-wEgtqtrnJ/9zIBsQb8UIxOhAH1eTHfi7D/xvmrUoMEePeI6u24nq1wigazbIFtHt6ANYXdEVTvc8XYNlTurs7A==

Loading…
Cancel
Save