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

20
src/__mocks__/archiver.js

@ -1,19 +1 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
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;
};
module.exports = jest.fn();

7
src/__mocks__/electron.js

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

2
src/__mocks__/unzipper.js

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

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

@ -1,35 +1,49 @@
import React from 'react';
import * as fs from 'fs-extra';
import { join } from 'path';
import { IChart } from '@mrblenny/react-flow-chart';
import { fireEvent } from '@testing-library/react';
import { PassThrough } from 'stream';
import * as unzipper from 'unzipper';
import { delay } from 'utils/async';
import * as os from 'os';
import { Network } from 'types';
import { initChartFromNetwork } from 'utils/chart';
import * as files from 'utils/files';
import { getNetwork, renderWithProviders } from 'utils/tests';
import * as zip from 'utils/zip';
import ImportNetwork from './ImportNetwork';
jest.mock('utils/files');
jest.mock('unzipper', () => ({
Extract: jest.fn(),
}));
jest.mock('utils/zip');
jest.mock('os');
const osMock = os as jest.Mocked<typeof os>;
const fsMock = fs as jest.Mocked<typeof fs>;
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', () => {
const network = getNetwork();
const chart = initChartFromNetwork(network);
let unzipStream: PassThrough;
const exportFilePath = join('/', 'tmp', 'polar', 'file', 'export.json');
let network: Network;
let chart: IChart;
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(
'input[type=file]',
) as HTMLInputElement;
// attach the file to the input so the Uploader can receive it
const file = new File(['asdf'], 'file.zip');
file.path = 'file.zip';
Object.defineProperty(fileInput, 'files', { value: [file] });
@ -41,24 +55,14 @@ describe('ImportNetwork component', () => {
};
beforeEach(() => {
fsMock.pathExists.mockResolvedValue(true as never);
fsMock.mkdirp.mockResolvedValue(true as never);
fsMock.copy.mockResolvedValue(true as never);
network = getNetwork();
chart = initChartFromNetwork(network);
filesMock.read.mockResolvedValue(JSON.stringify({ network, chart }));
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());
});
afterEach(() => {
if (unzipStream) unzipStream.destroy();
filesMock.rm.mockResolvedValue();
zipMock.unzip.mockResolvedValue();
fsMock.copy.mockResolvedValue(true as never);
osMock.tmpdir.mockReturnValue('/tmp');
osMock.platform.mockReturnValue('darwin');
});
it('should display the file upload label', async () => {
@ -83,9 +87,6 @@ describe('ImportNetwork component', () => {
expect(queryByLabelText('loading')).not.toBeInTheDocument();
fireEvent.change(fileInput);
expect(queryByLabelText('loading')).toBeInTheDocument();
// close the unzip stream after a small delay
await delay(100);
unzipStream.emit('close');
expect(
await findByText("Imported network 'my-test' successfully"),
).toBeInTheDocument();
@ -95,10 +96,62 @@ describe('ImportNetwork component', () => {
fsMock.copy.mockRejectedValue(new Error('test-error') as never);
const { findByText, fileInput } = renderComponent();
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('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 electron from 'electron';
import * as log from 'electron-log';
import fsExtra from 'fs-extra';
import { fireEvent, wait, waitForElement } from '@testing-library/dom';
import { act } from '@testing-library/react';
@ -15,9 +16,15 @@ import {
suppressConsoleErrors,
testCustomImages,
} from 'utils/tests';
import * as zip from 'utils/zip';
import NetworkView from './NetworkView';
jest.mock('utils/zip');
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<
typeof injections.bitcoindService
>;
@ -25,10 +32,6 @@ const dockerServiceMock = injections.dockerService as jest.Mocked<
typeof injections.dockerService
>;
jest.mock('utils/zip', () => ({
zip: jest.fn(),
}));
describe('NetworkView Component', () => {
const renderComponent = (
id: string | undefined,
@ -222,46 +225,39 @@ describe('NetworkView Component', () => {
});
describe('rename network', () => {
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should show the rename input', async () => {
const { getByLabelText, getByText, findByDisplayValue } = renderComponent('1');
const { getByLabelText, findByText, findByDisplayValue } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Rename'));
fireEvent.click(await findByText('Rename'));
const input = (await findByDisplayValue('test network')) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.type).toBe('text');
fireEvent.click(getByText('Cancel'));
fireEvent.click(await findByText('Cancel'));
expect(input).not.toBeInTheDocument();
expect(getByText('Start')).toBeInTheDocument();
expect(await findByText('Start')).toBeInTheDocument();
});
it('should rename the network', async () => {
const { getByLabelText, getByText, findByDisplayValue, store } = renderComponent(
const { getByLabelText, findByText, findByDisplayValue, store } = renderComponent(
'1',
);
fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Rename'));
fireEvent.click(await findByText('Rename'));
const input = await findByDisplayValue('test network');
fireEvent.change(input, { target: { value: 'new network name' } });
fireEvent.click(getByText('Save'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(await findByText('Save'));
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 () => {
const { getByLabelText, getByText, findByDisplayValue } = renderComponent('1');
const { getByLabelText, findByText, findByDisplayValue } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Rename'));
fireEvent.click(await findByText('Rename'));
const input = await findByDisplayValue('test network');
fireEvent.change(input, { target: { value: '' } });
fireEvent.click(getByText('Save'));
await wait(() => jest.runOnlyPendingTimers());
expect(getByText('Failed to rename the network')).toBeInTheDocument();
fireEvent.click(await findByText('Save'));
expect(await findByText('Failed to rename the network')).toBeInTheDocument();
});
});
@ -312,48 +308,39 @@ describe('NetworkView Component', () => {
});
describe('export network', () => {
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should fail to export a running network', async () => {
const { getByText, getByLabelText } = renderComponent('1');
const primaryBtn = getByText('Start');
fireEvent.click(primaryBtn);
// should change to stopped after some time
await wait(() => {
expect(primaryBtn).toHaveTextContent('Stop');
});
const { getByLabelText, findByText } = renderComponent('1', Status.Started);
fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Export'));
await wait(() => jest.runOnlyPendingTimers());
expect(getByText('The network must be stopped to be exported')).toBeInTheDocument();
fireEvent.click(await findByText('Export'));
expect(
await findByText('The network must be stopped to be exported'),
).toBeInTheDocument();
});
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'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Export'));
await wait(() => jest.runOnlyPendingTimers());
expect(getByText("Exported 'test network'", { exact: false })).toBeInTheDocument();
fireEvent.click(await findByText('Export'));
expect(
await findByText("Exported 'test network'", { exact: false }),
).toBeInTheDocument();
});
it('should not export the network if the user closes the file save dialogue', async () => {
jest.useFakeTimers();
// returns undefined if user closes the window
electron.remote.dialog.showSaveDialog = jest.fn(() => ({})) as any;
const { queryByText, getByText, getByLabelText } = renderComponent('1');
dialogMock.showSaveDialog.mockResolvedValue({} as any);
const { queryByText, findByText, getByLabelText } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Export'));
fireEvent.click(await findByText('Export'));
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();
jest.useRealTimers();
});
});
});

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

@ -350,5 +350,6 @@
"store.models.network.exportTitle": "Export '{{name}}'",
"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.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 fsMock = fs as jest.Mocked<typeof fs>;
const filesMock = files as jest.Mocked<typeof files>;
const composeMock = compose as jest.Mocked<typeof compose>;
const electronMock = electron as jest.Mocked<typeof electron>;
@ -337,6 +338,18 @@ describe('DockerService', () => {
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 () => {
filesMock.exists.mockResolvedValue(true);
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
message.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();
// wait for the modal to be removed before starting the next test. it uses a short animation
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 { wait } from '@testing-library/react';
import detectPort from 'detect-port';
@ -25,20 +24,6 @@ jest.mock('utils/files', () => ({
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', () => ({
zip: jest.fn(),
unzip: jest.fn(),
@ -836,57 +821,10 @@ describe('Network model', () => {
};
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 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);
await expect(exportNetwork({ id: 10 })).rejects.toThrow();
});
});
});

2
src/store/models/network.ts

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

1
src/utils/constants.ts

@ -143,7 +143,6 @@ export const dockerConfigs: Record<NodeImplementation, DockerConfig> = {
'bitcoind',
'-server=1',
'-regtest=1',
'-reindex',
'-rpcauth={{rpcUser}}:{{rpcAuth}}',
'-debug=1',
'-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));
// validate the network and chart
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))) {
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 chart = parsed.chart as IChart;
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}'`);
// update the paths for the network and nodes
network.path = netPath;
debug(`Updating network id to '${id}'`);
network.id = id;
@ -564,12 +553,25 @@ export const importNetworkFromZip = async (
} else if (ln.implementation === 'c-lightning') {
const cln = ln as CLightningNode;
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
rm(exportFilePath);
// copy the network files to correct path inside of ~/.polar
await rm(exportFilePath);
debug(`Copying '${tmpDir}' to '${network.path}'`);
await copy(tmpDir, network.path);
return [network, chart];

206
src/utils/zip.spec.ts

@ -1,107 +1,131 @@
import fsExtra from 'fs-extra';
import { join } from 'path';
import archiver from 'archiver';
import * as archiver from 'archiver';
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';
jest.mock('utils/files');
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>;
describe('unzip', () => {
it("fail to unzip something that isn't a zip", async () => {
return expect(
unzip(join(__dirname, 'tests', 'resources', 'bar.txt'), 'foobar'),
).rejects.toThrow();
describe('Zip Util', () => {
let unzipStream: PassThrough;
let zipStream: any;
let zippedPaths: string[];
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 () => {
return expect(unzip('foobar', 'bazfoo')).rejects.toThrow();
afterEach(() => {
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');
// const baz = entries.find(e => e.name === 'baz');
// const foo = entries.find(e => e.name === 'foo.json');
// expect(bar?.isFile()).toBeTruthy();
// expect(baz?.isDirectory()).toBeTruthy();
// expect(foo?.isFile()).toBeTruthy();
// });
it('should fail if there is an archiver error', async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);
const promise = zip('source', 'destination');
// 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);
describe('unzip', () => {
it('should unzip a file successfully', async () => {
const promise = unzip('source-path', 'destination-path');
// emit an error after a small delay
await delay(100);
unzipStream.emit('close');
await expect(promise).resolves.toBeUndefined();
});
it("fail to unzip something that isn't a zip", async () => {
const promise = unzip('source-path', 'destination-file');
// fail the unzip stream after a small delay
await delay(100);
unzipStream.emit('error', new Error('test-error'));
await expect(promise).rejects.toThrow();
});
it("fails to unzip something that doesn't exist", async () => {
fsMock.pathExists.mockResolvedValue(false as never);
return expect(unzip('source-path', 'destination-file')).rejects.toThrow();
});
});
it('should fail if there is an archiver warning', async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);
const promise = zip('source', 'destination');
// 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);
describe('zip', () => {
it('should zip a folder successfully', async () => {
const promise = zip('source-path', 'destination-path');
// emit an error after a small delay
await delay(100);
zipStream.emit('finish');
await expect(promise).resolves.toBeUndefined();
});
it('should not include the c-lightning RPC in the zip', async () => {
const promise = zip('source-path', 'destination-path');
// 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> =>
new Promise(async (resolve, reject) => {
info(`zipping ${source} to ${destination}`);
const output = createWriteStream(destination);
const archive = archiver('zip');
// finished
archive.on('finish', () => resolve());
archive.on('error', err => {
error(`got error when zipping ${destination}:`, err);
reject(err);
});
archive.on('warning', warning => {
warn(`got warning when zipping ${destination}:`, warning);
reject(warning);
});
// pipe all zipped data to the output
archive.pipe(output);
// pipe all zipped data to the destination file
archive.pipe(createWriteStream(destination));
// avoid including the c-lightning RPC socket
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);
return false;
}

30
yarn.lock

@ -1866,7 +1866,7 @@
"@testing-library/dom" "^6.15.0"
"@types/testing-library__react" "^9.1.2"
"@types/archiver@^3.0.0":
"@types/archiver@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-3.1.0.tgz#0d5bd922ba5cf06e137cd6793db7942439b1805e"
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"
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
"@types/unzipper@^0.10.1":
"@types/unzipper@0.10.2":
version "0.10.2"
resolved "https://registry.yarnpkg.com/@types/unzipper/-/unzipper-0.10.2.tgz#35c2da714d406b2cd7ada5006e1e206bdd0eb2f4"
integrity sha512-VgYoNEyj8xkz9I+RTWD00iB9JVViK/RBteNDjOIV3/kdCUPaskka7xAZfFlIxRwKGSPf77F8yje5bJt2PefofQ==
@ -2253,6 +2253,15 @@
regexpp "^3.0.0"
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":
version "2.23.0"
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"
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":
version "2.23.0"
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"
readable-stream "^2.0.0"
archiver@^3.1.1:
archiver@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
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"
integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
unzipper@^0.10.8:
unzipper@0.10.10:
version "0.10.10"
resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.10.tgz#d82d41fbdfa1f0731123eb11c2cfc028b45d3d42"
integrity sha512-wEgtqtrnJ/9zIBsQb8UIxOhAH1eTHfi7D/xvmrUoMEePeI6u24nq1wigazbIFtHt6ANYXdEVTvc8XYNlTurs7A==

Loading…
Cancel
Save