Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add import/export functionality #295

Merged
merged 10 commits into from
Mar 10, 2020

Conversation

torkelrogstad
Copy link
Contributor

@torkelrogstad torkelrogstad commented Feb 11, 2020

In this PR we add import/export functionality.

Working:

  • Add dropdown to network (where "rename" and "delete" are) for exporting. This zips a JSON file with the current network and prompts the user for where to store the zip
  • Add new "import network" page where user can select a zip file, and import this

Todo:

  • Remove user name from stringified network in zip?
  • Tests
  • Docs
  • indicator while we're importing/exporting networks
  • Adding newly imported networks to chart and persisting this to disk
  • modal saying we successfully imported network

@jamaljsr
Copy link
Owner

@torkelrogstad I won't do a full review because you marked it as a draft, but I have skimmed through the code and ran it. Overall, it works as advertised, which is awesome. Thanks for working on this feature and let me know when you feel ready for me to do a thorough review.

@torkelrogstad torkelrogstad marked this pull request as ready for review February 17, 2020 15:28
@torkelrogstad
Copy link
Contributor Author

This PR is now in a reviewable state, @jamaljsr. I've added some tests where I mimicked existing tests. The hairy parts of this PR (the zipping/unzipping/networks.json tweaking) is not tested, though... LMK what you think is important to test here. I'd also appreciate some pointers on how to approach that, w.r.t. best practices around tests that require real file system IO.

I also didn't tackle the first item on my checkbox from above, removing the user name of the user that exported the network. This would take some effort, and doesn't seem to be terribly important.

Here's an exported network you can pull down and import: polar-foo.zip

@torkelrogstad torkelrogstad changed the title Add import/export functionaliity Add import/export functionality Feb 17, 2020
@jamaljsr
Copy link
Owner

Hey @torkelrogstad, I will give the code a thorough review by tomorrow.

Regarding testing, the simplest way to determine what tests to add is to look at the code coverage report. If you run yarn test:ci it will output a list of all of the source files along with the line numbers which are not covered by tests. After that, you can also open the /coverage/lcov-report/index.html file in a browser to see a more visual view of what code has no coverage. This tells you exactly where you should add more tests to cover all possible code paths. I do have coverage reports posting to PRs (ex: 282) but unfortunately this feature doesn't work yet with GitHub Actions on PRs from forked repos.

Using coverage as a guide also helps you organize your code better. For example, I see that you have a fair amount of logic written in the NetworkView component and button click handlers. It would be challenging to write tests for these since the testing framework has to interact with the UI. It is a better practice to keep your components as small as possible and have util functions for the heavy lifting. The only logic in these files should be to determine what to render on the screen, with little to none of the plumbing necessary to perform an action. This also allows you to reuse that functionality in other components, which keeps your codebase DRY. Since this code would be difficult to test, you would be encouraged to move it to a separate file. Then you'd have a lot more flexibility to pass in arguments to the function to induce errors and handle edge cases.

I know testing feels like a pain upfront, but I can tell you from experience that it leads to a much more developer friendly codebase in the future after years of changes.

@jamaljsr
Copy link
Owner

jamaljsr commented Feb 18, 2020

I have started reviewing this PR by trying to break it in as many ways as I can think of. Here are some issues I have found by testing this feature as a user.

  1. The polar-foo.zip file that you posted in your comment appears to be corrupt. I receive an error when I try to import it as well as when I try to extract it in Finder.

    Could not unzip /Users/jamal/Downloads/polar-foo.zip into /var/folders/zq/b69vg6b50x3gxlqfxtm6vybw0000gn/T/polar-foo: Error: invalid distance too far back
     at Zlib.zlibOnError [as onerror] (zlib.js:170)
    

    image

  2. I created a network but did not start it. I then exported it, deleted it, then tried to import it. I received this error in the console.

    Uncaught (in promise) Error: ENOENT: no such file or directory, stat '/var/folders/zq/b69vg6b50x3gxlqfxtm6vybw0000gn/T/polar-import/volumes'
    
  3. I created a network, started it, then added some channels and made payments. While the network is running, I exported it. I then stopped it, deleted it, and imported it back in. Polar believes that the network is still running but it cannot connect to the nodes. Obviously the docker containers are not running for a newly imported network. I then stopped the network, and started it up again. The bitcoin node came online but the LND & c-lightning nodes did not. My high-level assumption is that the node's data is now corrupt because Polar zipped up the folders while the nodes were running. They didn't gracefully shutdown properly. I suggest a simple fix of not allowing the export of running networks. Just show an error notification if the user clicks Export while the network is running.

  4. I exported a network with a c-lightning node from a Mac and transferred the zip file to my Windows machine. I was able to import the network and start it but the c-lightning node failed to start. This is because our c-lightning docker containers are not supported on Windows yet. We should prevent importing networks with c-lightning nodes on Windows.

I also have some cosmetic suggestions to improve the UI

  1. On the Get Started screen, I don't think it is necessary to have the Import button in the center. This screen is only shown to first-time users. I think it is highly unlikely that importing a network would be a common use case for folks just starting, so it doesn't need to be this prominent in the UI. There is also the Import link in the top nav that is easily visible for those that need it.
  2. We should add an icon to the "Import Network" link in the top navbar. It looks a bit strange without one. I would suggest <ImportOutlined /> from antd.
  3. On the Import screen:
    • I think it would look better if the Card with the dropzone filled the full height of the screen. This should be doable with a bit of flexbox CSS.
    • since the user will only ever upload one file, I don't think it's necessary to have them select a file then click on the Import button. The import process should immediately initiate after the file is dropped or selected. This would remove the need to show the Remove and Import buttons.
    • there should be some sort of loader displayed while the network it being imported. It can take a few seconds and when I did it, I wasn't sure if anything was happening until the success modal popped up.

This is all the feedback I have from a user's perspective. I will go ahead and dive into the code now and provide feedback on that separately.

Copy link
Owner

@jamaljsr jamaljsr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my feedback on the code for this feature. This is a really good start. I tried to be as helpful as possible in providing guidance on how the code can be improved. If there is any confusion or disagreement, please feel free to let me know.

I also saw some spelling errors in comments which I did not mention, I'll save those for a later review. If you use VS Code as your editor, I highly recommend the Code Spell Checker plugin. It's helped me a lot. Also, the TypeScript Import Sorter plugin does a really good job at keeping import statements consistent with the rules I defined in the settings.json file.

package.json Outdated Show resolved Hide resolved
src/components/common/NavMenu.tsx Show resolved Hide resolved
src/components/home/GetStarted.tsx Outdated Show resolved Hide resolved
src/components/network/ImportNetwork.tsx Outdated Show resolved Hide resolved
src/components/network/ImportNetwork.tsx Outdated Show resolved Hide resolved
src/utils/files.ts Outdated Show resolved Hide resolved
src/utils/files.ts Outdated Show resolved Hide resolved
src/components/network/NetworkView.tsx Outdated Show resolved Hide resolved
src/components/network/ImportNetwork.tsx Outdated Show resolved Hide resolved
src/components/network/ImportNetwork.tsx Outdated Show resolved Hide resolved
@torkelrogstad torkelrogstad force-pushed the 2020-02-11-import-export branch 2 times, most recently from 15b16dc to f1f8e38 Compare February 24, 2020 09:42
await t.expect(getPageUrl()).match(/network_import/);
});

test('when the user aborts the file dialog, nothing should happen', async t => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having issues with this test. I can't get Testcafe to trigger the file dialog... Any idea what I'm doing wrong?

Copy link
Owner

@jamaljsr jamaljsr Feb 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bit of playing around with this and I think I see the problem. The antd Dragger component uses the native HTML5 input[type=file] element to handle the file selection. This means that the electron dialog.showOpenDialog function is not being used, so the setElectronDialogHandler handler will never be called in the test. It looks like Testcafe has a different setFilesToUpload API to trigger file selection for inputs. You can give that a try. Or if you want to take a different approach, you could create an upload component that uses dialog.showOpenDialog instead of the antd Dragger with an input[type=file] element. I do not have a suggestion on which one is better/easier than the other.

@torkelrogstad torkelrogstad force-pushed the 2020-02-11-import-export branch 2 times, most recently from e24da98 to 5c481ae Compare February 24, 2020 09:45

// finished
archive.on('finish', () => resolve());
archive.on('error', err => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having some issues with testing the error and warning branches here. How would I solve this with a mock?

Copy link
Owner

@jamaljsr jamaljsr Feb 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit tricky because the archiver package exposes its main function as the default export. After a little tinkering, I've gotten a couple tests passing which simulate the error and warning events.

  1. The jest.mock('archiver, ...);` code should be placed in a global mock file because it should be mocked in all tests. I just put it all in one file for easier pasting.
  2. I mocked fs in these tests because your code used that module. I suggest switching to fs-extra which already has a global mock in __mocks__. It would just need to be updated with any missing functions declared there, like createWriteStream.
import fs from 'fs';
import * as archiver from 'archiver';
import { PassThrough } from 'stream';
import { zip } from './zip';

jest.mock('fs');
jest.mock('archiver', () => {
  // TODO: this code should live in __mocks__/archiver.js

  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const { PassThrough } = require('stream');
  let mockStream: any;
  // 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: string, data: any) => mockStream.emit(event, data);

  return ctor;
});

const fsMock = fs as jest.Mocked<typeof fs>;
const archiverMock = archiver as jest.Mocked<any>;

it('should fail if there is an archiver error', async () => {
  fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);

  const promise = zip({
    destination: 'path-to-zip',
    objects: [],
    paths: [],
  });

  // emit an error after a small delay
  const mockError = new Error('test-error');
  setTimeout(() => {
    archiverMock.mockEmit('error', mockError);
  }, 100);

  await expect(promise).rejects.toEqual(mockError);
});

it('should fail if there is an archiver warning', async () => {
  fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);

  const promise = zip({
    destination: 'path-to-zip',
    objects: [],
    paths: [],
  });

  // emit an error after a small delay
  const mockError = new Error('test-warning');
  setTimeout(() => {
    archiverMock.mockEmit('warning', mockError);
  }, 100);

  await expect(promise).rejects.toEqual(mockError);
});

Comment on lines 34 to 58
const ImportNetwork: React.FC<RouteComponentProps> = () => {
const { navigateTo, notify } = useStoreActions(s => s.app);
const { importNetwork } = useStoreActions(s => s.network);
const { l } = usePrefixedTranslation('cmps.network.ImportNetwork');
const [importing, setImporting] = useState(false);

const doImportNetwork = (file: RcFile) => {
setImporting(true);

// we kick off the import promise, but don't wait for it
importNetwork(file.path)
.then(network => {
notify({ message: l('importSuccess', { name: network.name }) });
navigateTo(HOME);
})
.catch(error => {
notify({ message: l('importError', { file: file.name }), error });
})
.then(() => {
setImporting(false);
});

// return false to prevent the Upload.Dragger from sending the file somewhere
return false;
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is not tested in unit tests. One solution here could be to create a hidden element on the page that has a onClick set to this, and then assert on that in tests. Any thoughts on that?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could trigger the change event on the file input in order to execute this code. I'm not sure if that will work, but it's worth a try.

Copy link
Owner

@jamaljsr jamaljsr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review took a lot longer than I expected because I was playing around with the tests. I've left a bunch of feedback on how this can be further improved. Please let me know if you have any questions of need assistance with anything.

await t.expect(getPageUrl()).match(/network_import/);
});

test('when the user aborts the file dialog, nothing should happen', async t => {
Copy link
Owner

@jamaljsr jamaljsr Feb 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bit of playing around with this and I think I see the problem. The antd Dragger component uses the native HTML5 input[type=file] element to handle the file selection. This means that the electron dialog.showOpenDialog function is not being used, so the setElectronDialogHandler handler will never be called in the test. It looks like Testcafe has a different setFilesToUpload API to trigger file selection for inputs. You can give that a try. Or if you want to take a different approach, you could create an upload component that uses dialog.showOpenDialog instead of the antd Dragger with an input[type=file] element. I do not have a suggestion on which one is better/easier than the other.

src/store/models/network.spec.ts Outdated Show resolved Hide resolved
module.exports = {
remote: {
app: {
getPath: p => `ELECTRON_PATH[${p}]`,
getLocale: () => 'en-US',
},
dialog: {
showSaveDialog: async () => ({
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mock would not be needed if you do not use electron.remote.dialog.showSaveDialog() in the app.

Comment on lines 34 to 58
const ImportNetwork: React.FC<RouteComponentProps> = () => {
const { navigateTo, notify } = useStoreActions(s => s.app);
const { importNetwork } = useStoreActions(s => s.network);
const { l } = usePrefixedTranslation('cmps.network.ImportNetwork');
const [importing, setImporting] = useState(false);

const doImportNetwork = (file: RcFile) => {
setImporting(true);

// we kick off the import promise, but don't wait for it
importNetwork(file.path)
.then(network => {
notify({ message: l('importSuccess', { name: network.name }) });
navigateTo(HOME);
})
.catch(error => {
notify({ message: l('importError', { file: file.name }), error });
})
.then(() => {
setImporting(false);
});

// return false to prevent the Upload.Dragger from sending the file somewhere
return false;
};
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could trigger the change event on the file input in order to execute this code. I'm not sure if that will work, but it's worth a try.

src/components/network/ImportNetwork.tsx Outdated Show resolved Hide resolved
src/utils/network.ts Outdated Show resolved Hide resolved
src/utils/zip.ts Outdated Show resolved Hide resolved

// finished
archive.on('finish', () => resolve());
archive.on('error', err => {
Copy link
Owner

@jamaljsr jamaljsr Feb 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit tricky because the archiver package exposes its main function as the default export. After a little tinkering, I've gotten a couple tests passing which simulate the error and warning events.

  1. The jest.mock('archiver, ...);` code should be placed in a global mock file because it should be mocked in all tests. I just put it all in one file for easier pasting.
  2. I mocked fs in these tests because your code used that module. I suggest switching to fs-extra which already has a global mock in __mocks__. It would just need to be updated with any missing functions declared there, like createWriteStream.
import fs from 'fs';
import * as archiver from 'archiver';
import { PassThrough } from 'stream';
import { zip } from './zip';

jest.mock('fs');
jest.mock('archiver', () => {
  // TODO: this code should live in __mocks__/archiver.js

  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const { PassThrough } = require('stream');
  let mockStream: any;
  // 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: string, data: any) => mockStream.emit(event, data);

  return ctor;
});

const fsMock = fs as jest.Mocked<typeof fs>;
const archiverMock = archiver as jest.Mocked<any>;

it('should fail if there is an archiver error', async () => {
  fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);

  const promise = zip({
    destination: 'path-to-zip',
    objects: [],
    paths: [],
  });

  // emit an error after a small delay
  const mockError = new Error('test-error');
  setTimeout(() => {
    archiverMock.mockEmit('error', mockError);
  }, 100);

  await expect(promise).rejects.toEqual(mockError);
});

it('should fail if there is an archiver warning', async () => {
  fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);

  const promise = zip({
    destination: 'path-to-zip',
    objects: [],
    paths: [],
  });

  // emit an error after a small delay
  const mockError = new Error('test-warning');
  setTimeout(() => {
    archiverMock.mockEmit('warning', mockError);
  }, 100);

  await expect(promise).rejects.toEqual(mockError);
});

tsconfig.json Outdated Show resolved Hide resolved
.eslintignore Outdated Show resolved Hide resolved
@jamaljsr
Copy link
Owner

jamaljsr commented Mar 1, 2020

Hey @torkelrogstad are you still working on this PR? I want to package up a new release this coming week and would love to include this feature. If you don't have time to finish this up now, I am happy to work on it myself. I just don't want to take over if you plan to continue with it yourself.

@torkelrogstad
Copy link
Contributor Author

@jamaljsr Sorry for being a bit off the grid for the last days. Hoping to finish it up today, working on addressing your latest round of review now.

@jamaljsr
Copy link
Owner

jamaljsr commented Mar 3, 2020

Ok great. Thanks for letting me know.

@torkelrogstad torkelrogstad force-pushed the 2020-02-11-import-export branch 2 times, most recently from 35ba38c to bb7628d Compare March 3, 2020 16:13
@torkelrogstad
Copy link
Contributor Author

I pushed what I had time to do today. I've addressed some of your comments, but did not get around to all of them. If you want to get this feature wrapped up so you can do a release, feel free to push changes here.

@jamaljsr
Copy link
Owner

jamaljsr commented Mar 3, 2020

Ok, this looks pretty close to being ready. I'll finish it up in a day or two. Thanks for contributing what you have so far.

@jamaljsr jamaljsr self-requested a review March 7, 2020 03:28
@jamaljsr jamaljsr force-pushed the 2020-02-11-import-export branch 2 times, most recently from c8de332 to e49ac58 Compare March 7, 2020 04:25
@codecov-io
Copy link

codecov-io commented Mar 7, 2020

Codecov Report

Merging #295 into master will not change coverage.
The diff coverage is 100%.

Impacted file tree graph

@@          Coverage Diff           @@
##           master   #295    +/-   ##
======================================
  Coverage     100%   100%            
======================================
  Files         109    111     +2     
  Lines        2804   2954   +150     
  Branches      509    535    +26     
======================================
+ Hits         2804   2954   +150
Impacted Files Coverage Δ
src/components/routing/Routes.tsx 100% <ø> (ø) ⬆️
src/components/network/NetworkActions.tsx 100% <ø> (ø) ⬆️
src/components/home/GetStarted.tsx 100% <ø> (ø) ⬆️
src/utils/zip.ts 100% <100%> (ø)
src/store/models/app.ts 100% <100%> (ø) ⬆️
src/components/home/NetworkCard.tsx 100% <100%> (ø) ⬆️
src/components/network/ImportNetwork.tsx 100% <100%> (ø)
src/utils/network.ts 100% <100%> (ø) ⬆️
src/components/common/NavMenu.tsx 100% <100%> (ø) ⬆️
src/components/network/NetworkView.tsx 100% <100%> (ø) ⬆️
... and 3 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 75acb8f...1a6c872. Read the comment docs.

@jamaljsr jamaljsr removed their request for review March 7, 2020 22:55
@jamaljsr jamaljsr force-pushed the 2020-02-11-import-export branch 2 times, most recently from 36b966b to d47760a Compare March 10, 2020 07:10
torkelrogstad and others added 10 commits March 10, 2020 03:52
Add functionality to export and import networks and data from the application. On export we zip the
docker-compose file, the data directories for the nodes and JSON representations of the chart and
network definitions. This zip file can later be imported, where we do the reverse operation and copy
the docker-compose file and the data directories, and insert the network and chart definitions into
the networks.json file.

fix 203
feat: add import/export network functionality

Modularize import/export network and zip/unzip functionality. We also
add some tests.
feat: add import/export network functionality

Add thunk and (attempt at) test for importing network.
feat: add import/export network functionality
@jamaljsr
Copy link
Owner

@torkelrogstad I finished up this PR and will merge it. Thank so much for your work on this feature. 👍

@jamaljsr jamaljsr merged commit 15cd5fc into jamaljsr:master Mar 10, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature request: Allow export and import of networks
3 participants