diff --git a/app/package.json b/app/package.json index 05ff03921a..6f909672c2 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "casual": "^1.5.17", "clipboard": "^1.6.0", "cosmos-delegation-game": "^0.1.0", - "cosmos-sdk": "^1.5.0", + "cosmos-sdk": "^1.5.1", "event-to-promise": "^0.8.0", "express": "^4.16.2", "express-http-proxy": "^1.1.0", diff --git a/app/src/main/index.js b/app/src/main/index.js index d5a35f6550..e9867791e3 100644 --- a/app/src/main/index.js +++ b/app/src/main/index.js @@ -9,8 +9,9 @@ let semver = require('semver') // this dependency is wrapped in a file as it was not possible to mock the import with jest any other way let event = require('event-to-promise') let toml = require('toml') +let axios = require('axios') let pkg = require('../../../package.json') -let mockServer = require('./mockServer.js') +let relayServer = require('./relayServer.js') let started = false let shuttingDown = false @@ -18,7 +19,10 @@ let mainWindow let baseserverProcess let streams = [] let nodeIP +let connecting = false +const root = require('../root.js') +const baseserverHome = join(root, 'baseserver') const WIN = /^win/.test(process.platform) const DEV = process.env.NODE_ENV === 'development' const TEST = JSON.parse(process.env.COSMOS_TEST || 'false') !== false @@ -28,6 +32,7 @@ const MOCK = JSON.parse(process.env.MOCK || !TEST && DEV) !== false const winURL = DEV ? `http://localhost:${require('../../../config').port}` : `file://${__dirname}/index.html` +const NODE = process.env.COSMOS_NODE // this network gets used if none is specified via the // COSMOS_NETWORK env var @@ -179,23 +184,16 @@ app.on('activate', () => { app.on('ready', () => createWindow()) // start baseserver REST API -async function startBaseserver (home) { +async function startBaseserver (home, nodeIP) { log('startBaseserver', home) let child = startProcess(SERVER_BINARY, [ 'rest-server', - '--home', home // , + '--home', home, + '--node', nodeIP // '--trust-node' ]) logProcess(child, join(home, 'baseserver.log')) - // restore baseserver if it crashes - child.on('exit', async () => { - if (shuttingDown) return - log('baseserver crashed, restarting') - await sleep(1000) - await startBaseserver(home) - }) - while (true) { if (shuttingDown) break @@ -311,6 +309,48 @@ function consistentConfigDir (versionPath, genesisPath, configPath) { return exists(genesisPath) && exists(versionPath) && exists(configPath) } +function pickNode (seeds) { + let nodeIP = NODE || seeds[Math.floor(Math.random() * seeds.length)] + // let nodeRegex = /([http[s]:\/\/]())/g + log('Picked seed:', nodeIP, 'of', seeds) + // replace port with default RPC port + nodeIP = `${nodeIP.split(':')[0]}:46657` + + return nodeIP +} + +async function connect (seeds, nodeIP) { + log(`starting gaia server with nodeIP ${nodeIP}`) + baseserverProcess = await startBaseserver(baseserverHome, nodeIP) + log('gaia server ready') + + return nodeIP +} + +async function reconnect (seeds) { + if (connecting) return + connecting = true + + let nodeAlive = false + while (!nodeAlive) { + let nodeIP = pickNode(seeds) + nodeAlive = await axios('http://' + nodeIP) + .then(() => true, () => false) + log(`${new Date().toLocaleTimeString()} ${nodeIP} is ${nodeAlive ? 'alive' : 'down'}`) + + if (!nodeAlive) await sleep(2000) + } + + log('quitting running baseserver') + baseserverProcess.kill('SIGKILL') + + await connect(seeds, nodeIP) + + connecting = false + + return nodeIP +} + async function main () { // the windows installer opens the app once when installing // the package recommends, that we exit if this happens @@ -323,7 +363,6 @@ async function main () { return } - let root = require('../root.js') let versionPath = join(root, 'app_version') let genesisPath = join(root, 'genesis.json') let configPath = join(root, 'config.toml') @@ -398,29 +437,25 @@ async function main () { throw new Error(`Can't open config.toml: ${e.message}`) } let config = toml.parse(configText) - let seeds = config.p2p.seeds.split(',') - if (config.p2p.seeds === '' || seeds.length === 0) { + let seeds = config.p2p.seeds.split(',').filter(x => x !== '') + if (seeds.length === 0) { throw new Error('No seeds specified in config.toml') } - nodeIP = seeds[Math.floor(Math.random() * seeds.length)] - log('Picked seed:', nodeIP, 'of', seeds) - // replace port with default RPC port - nodeIP = `${nodeIP.split(':')[0]}:46657` - log(`Initializing baseserver with remote node ${nodeIP}`) + nodeIP = pickNode(seeds) - let baseserverHome = join(root, 'baseserver') if (init) { + log(`Initializing baseserver with remote node ${nodeIP}`) await initBaseserver(chainId, baseserverHome, nodeIP) } - log('starting gaia server') - baseserverProcess = await startBaseserver(baseserverHome) - log('gaia server ready') + await connect(seeds, nodeIP) - if (MOCK) { - // start mock API server on port 8999 - mockServer(8999) - } + // the view can communicate with the main process by sending requests to the relay server + // the relay server also proxies to the LCD + relayServer({ + mock: MOCK, + onReconnectReq: reconnect.bind(this, seeds) + }) started = true if (mainWindow) { diff --git a/app/src/main/mockServer.js b/app/src/main/mockServer.js deleted file mode 100644 index 95f9d4dd97..0000000000 --- a/app/src/main/mockServer.js +++ /dev/null @@ -1,119 +0,0 @@ -let express = require('express') -let proxy = require('express-http-proxy') -let randomBytes = require('crypto').pseudoRandomBytes -let casual = require('casual') - -let randomAddress = () => randomBytes(20).toString('hex') - -let randomTime = () => Date.now() - casual.integer(0, 32e7) - -let randomBondTx = (address, delegator) => ({ - tx: { - type: Math.random() > 0.5 ? 'bond' : 'unbond', - validator: delegator ? randomAddress() : address, - address: delegator ? address : randomAddress(), - shares: casual.integer(1, 1e6) - }, - time: randomTime(), - height: 1000 -}) - -module.exports = function (port = 8999) { - let app = express() - - // TODO this lets the server timeout until there is an external request - // log all requests - // app.use((req, res, next) => { - // console.log('REST request:', req.method, req.originalUrl, req.body) - // next() - // }) - - // delegation mock API - app.post('/tx/stake/delegate/:pubkey/:amount', (req, res) => { - res.json({ - 'type': 'sigs/one', - 'data': { - 'tx': { - 'type': 'chain/tx', - 'data': { - 'chain_id': 'gaia-1', - 'expires_at': 0, - 'tx': { - 'type': 'nonce', - 'data': { - 'sequence': 1, - 'signers': [ - { - 'chain': '', - 'app': 'sigs', - 'addr': '84A057DCE7E1DB8EBE3903FC6B2D912E63EF9BEA' - } - ], - 'tx': { - 'type': 'coin/send', - 'data': { - 'inputs': [ - { - 'address': { - 'chain': '', - 'app': 'sigs', - 'addr': '84A057DCE7E1DB8EBE3903FC6B2D912E63EF9BEA' - }, - 'coins': [ - { - 'denom': 'atom', - 'amount': 1 - } - ] - } - ], - 'outputs': [ - { - 'address': { - 'chain': '', - 'app': 'sigs', - 'addr': '84A057DCE7E1DB8EBE3903FC6B2D912E63EF9BEA' - }, - 'coins': [ - { - 'denom': 'atom', - 'amount': 1 - } - ] - } - ] - } - } - } - } - } - }, - 'signature': { - 'Sig': null, - 'Pubkey': null - } - } - }) - }) - - // tx history - app.get('/tx/bondings/delegator/:address', (req, res) => { - let { address } = req.params - let txs = new Array(100).fill(0) - .map(() => randomBondTx(address, true)) - txs.sort((a, b) => b.time - a.time) - res.json(txs) - }) - app.get('/tx/bondings/validator/:address', (req, res) => { - let { address } = req.params - let txs = new Array(100).fill(0) - .map(() => randomBondTx(address, false)) - txs.sort((a, b) => b.time - a.time) - res.json(txs) - }) - - // proxy everything else to light client - app.use(proxy('http://localhost:8998')) - - app.listen(port) -} diff --git a/app/src/main/relayServer.js b/app/src/main/relayServer.js new file mode 100644 index 0000000000..200ade06c7 --- /dev/null +++ b/app/src/main/relayServer.js @@ -0,0 +1,128 @@ +let express = require('express') +let proxy = require('express-http-proxy') +let randomBytes = require('crypto').pseudoRandomBytes +let casual = require('casual') + +let randomAddress = () => randomBytes(20).toString('hex') + +let randomTime = () => Date.now() - casual.integer(0, 32e7) + +let randomBondTx = (address, delegator) => ({ + tx: { + type: Math.random() > 0.5 ? 'bond' : 'unbond', + validator: delegator ? randomAddress() : address, + address: delegator ? address : randomAddress(), + shares: casual.integer(1, 1e6) + }, + time: randomTime(), + height: 1000 +}) + +module.exports = function ({port = 8999, mock = false, onReconnectReq} = {}) { + let app = express() + + // TODO this lets the server timeout until there is an external request + // log all requests + // app.use((req, res, next) => { + // console.log('REST request:', req.method, req.originalUrl, req.body) + // next() + // }) + + if (mock) { + // delegation mock API + app.post('/tx/stake/delegate/:pubkey/:amount', (req, res) => { + res.json({ + 'type': 'sigs/one', + 'data': { + 'tx': { + 'type': 'chain/tx', + 'data': { + 'chain_id': 'gaia-1', + 'expires_at': 0, + 'tx': { + 'type': 'nonce', + 'data': { + 'sequence': 1, + 'signers': [ + { + 'chain': '', + 'app': 'sigs', + 'addr': '84A057DCE7E1DB8EBE3903FC6B2D912E63EF9BEA' + } + ], + 'tx': { + 'type': 'coin/send', + 'data': { + 'inputs': [ + { + 'address': { + 'chain': '', + 'app': 'sigs', + 'addr': '84A057DCE7E1DB8EBE3903FC6B2D912E63EF9BEA' + }, + 'coins': [ + { + 'denom': 'atom', + 'amount': 1 + } + ] + } + ], + 'outputs': [ + { + 'address': { + 'chain': '', + 'app': 'sigs', + 'addr': '84A057DCE7E1DB8EBE3903FC6B2D912E63EF9BEA' + }, + 'coins': [ + { + 'denom': 'atom', + 'amount': 1 + } + ] + } + ] + } + } + } + } + } + }, + 'signature': { + 'Sig': null, + 'Pubkey': null + } + } + }) + }) + + // tx history + app.get('/tx/bondings/delegator/:address', (req, res) => { + let { address } = req.params + let txs = new Array(100).fill(0) + .map(() => randomBondTx(address, true)) + txs.sort((a, b) => b.time - a.time) + res.json(txs) + }) + app.get('/tx/bondings/validator/:address', (req, res) => { + let { address } = req.params + let txs = new Array(100).fill(0) + .map(() => randomBondTx(address, false)) + txs.sort((a, b) => b.time - a.time) + res.json(txs) + }) + } + + app.get('/reconnect', async (req, res) => { + console.log('requesting to reconnect') + let nodeIP = await onReconnectReq() + console.log('reconnected to', nodeIP) + res.send(nodeIP) + }) + + // proxy everything else to light client + app.use(proxy('http://localhost:8998')) + + app.listen(port) +} diff --git a/app/src/renderer/main.js b/app/src/renderer/main.js index 8c0b697c18..eb944cdde9 100644 --- a/app/src/renderer/main.js +++ b/app/src/renderer/main.js @@ -26,7 +26,7 @@ async function main () { return } console.log('Connecting to node:', nodeIP) - const node = await Node(nodeIP) + const node = Node(nodeIP) const router = new Router({ scrollBehavior: () => ({ y: 0 }), @@ -37,7 +37,7 @@ async function main () { let connected = await store.dispatch('checkConnection') if (connected) { - store.dispatch('updateNodeStatus') + store.dispatch('nodeSubscribe') store.dispatch('showInitialScreen') } diff --git a/app/src/renderer/node.js b/app/src/renderer/node.js index 4d4232d6da..ddc99e6d59 100644 --- a/app/src/renderer/node.js +++ b/app/src/renderer/node.js @@ -1,36 +1,85 @@ 'use strict' +const RpcClient = require('tendermint') +const RestClient = require('cosmos-sdk') -const DEV = process.env.NODE_ENV === 'development' -const MOCK = JSON.parse(process.env.MOCK || DEV) !== false +const RELAY_SERVER = 'http://localhost:8999' -module.exports = async function (nodeIP) { +module.exports = function (nodeIP) { if (JSON.parse(process.env.COSMOS_UI_ONLY || 'false')) { - return Promise.resolve({ - rpc: { - on: () => {}, - subscribe: () => {}, - validators: () => {}, - status: () => {} - }, - generateKey: () => ({ - key: { - address: 'UI_ONLY_MODE' - } - }), - queryAccount: () => {}, - queryNonce: () => {} - }) + return mockClient() } - const RestClient = require('cosmos-sdk') - const RpcClient = require('tendermint') + let node = new RestClient(RELAY_SERVER) + + Object.assign(node, { + lcdConnected: () => node.listKeys() + .then(() => true, () => false), + + // RPC + rpcConnecting: false, + rpcOpen: true, + initRPC (nodeIP) { + if (node.rpc) { + node.rpc.ws.destroy() + } + + console.log('init rpc with', nodeIP) + let newRpc = new RpcClient(`ws://${nodeIP}`) + node.rpcOpen = true + // we need to check immediately if he connection fails. later we will not be able to check this error + newRpc.on('error', err => { + console.log('rpc error', err) + if (err.code === 'ECONNREFUSED') { + node.rpcOpen = false + } + }) + + node.rpc = newRpc + }, + rpcReconnect: async (rpcConnecting = node.rpcConnecting) => { + if (rpcConnecting) return + node.rpcConnecting = true + + console.log('trying to reconnect') - let rest = RestClient(MOCK ? 'http://localhost:8999' : undefined) - let rpc = RpcClient(`ws://${nodeIP}`) - // TODO: handle disconnect, try to reconnect + let nodeIP = await fetch(RELAY_SERVER + '/reconnect').then(res => res.text()) + console.log('Reconnected to', nodeIP) + if (nodeIP) { + node.initRPC(nodeIP) + } else { + // try again in 3s + await sleep(3000) + return node.rpcReconnect(false) + } + + node.rpcConnecting = false + return nodeIP + } + }) // TODO: eventually, get all data from light-client connection instead of RPC - rest.rpc = rpc - rest.nodeIP = nodeIP - return rest + node.initRPC(nodeIP) + return node +} + +function mockClient () { + return { + rpc: { + on: () => {}, + subscribe: () => {}, + validators: () => {}, + status: () => {} + }, + generateKey: () => ({ + key: { + address: 'UI_ONLY_MODE' + } + }), + queryAccount: () => {}, + queryNonce: () => {} + } +} + +function sleep (ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/app/src/renderer/vuex/modules/node.js b/app/src/renderer/vuex/modules/node.js index f5f075d07b..a8736f9708 100644 --- a/app/src/renderer/vuex/modules/node.js +++ b/app/src/renderer/vuex/modules/node.js @@ -1,8 +1,8 @@ 'use strict' -export default function ({ node, commit, dispatch }) { - // get tendermint RPC client from basecoin client - const { rpc, nodeIP } = node +export default function ({ node }) { + // get tendermint RPC client from basecon client + const { nodeIP } = node const state = { nodeIP, @@ -14,48 +14,61 @@ export default function ({ node, commit, dispatch }) { } const mutations = { - setLastHeader (state, header) { - state.lastHeader = header - dispatch('maybeUpdateValidators', header) - }, setConnected (state, connected) { state.connected = connected } } const actions = { - async checkConnection ({ commit }) { - try { - await node.listKeys() - return true - } catch (err) { - commit('notifyError', {title: 'Critical Error', body: `Couldn't initialize blockchain connector`}) - return false - } + setLastHeader ({state, dispatch}, header) { + state.lastHeader = header + dispatch('maybeUpdateValidators', header) + }, + async reconnect ({dispatch}) { + await node.rpcReconnect() + dispatch('nodeSubscribe') }, - updateNodeStatus ({ commit }) { - rpc.status((err, res) => { + nodeSubscribe ({commit, dispatch}) { + // the rpc socket can be closed before we can even attach a listener + // so we remember if the connection is open + // we handle the reconnection here so we can attach all these listeners on reconnect + if (!node.rpcOpen) { + dispatch('reconnect') + return + } + + // TODO: get event from light-client websocket instead of RPC connection (once that exists) + node.rpc.on('error', (err) => { + if (err.message.indexOf('disconnected') !== -1) { + commit('setConnected', false) + dispatch('reconnect') + } + }) + node.rpc.status((err, res) => { if (err) return console.error(err) let status = res commit('setConnected', true) - commit('setLastHeader', { + dispatch('setLastHeader', { height: status.latest_block_height, chain_id: status.node_info.network }) }) + node.rpc.subscribe({ event: 'NewBlockHeader' }, (err, event) => { + if (err) return console.error('error subscribing to headers', err) + commit('setConnected', true) + dispatch('setLastHeader', event.data.data.header) + }) + }, + async checkConnection ({ commit }) { + try { + await node.lcdConnected() + return true + } catch (err) { + commit('notifyError', {title: 'Critical Error', body: `Couldn't initialize blockchain connector`}) + return false + } } } - // TODO: get event from light-client websocket instead of RPC connection (once that exists) - rpc.on('error', (err) => { - console.log('rpc disconnected', err) - commit('setConnected', false) - }) - rpc.subscribe({ event: 'NewBlockHeader' }, (err, event) => { - if (err) return console.error('error subscribing to headers', err) - commit('setConnected', true) - commit('setLastHeader', event.data.data.header) - }) - return { state, mutations, actions } } diff --git a/app/src/renderer/vuex/modules/validators.js b/app/src/renderer/vuex/modules/validators.js index 2e97c178c2..51f28d1b21 100644 --- a/app/src/renderer/vuex/modules/validators.js +++ b/app/src/renderer/vuex/modules/validators.js @@ -18,7 +18,7 @@ export default ({ commit, node }) => { } const actions = { - maybeUpdateValidators (state, header) { + maybeUpdateValidators ({state, commit}, header) { let validatorHash = header.validators_hash if (validatorHash === state.validatorHash) return commit('setValidatorHash', validatorHash) diff --git a/app/yarn.lock b/app/yarn.lock index 6781e3d018..153a5c5f35 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -385,9 +385,9 @@ cosmos-fundraiser@^7.14.2: secp256k1 "^3.2.5" web3 "0.18.1" -cosmos-sdk@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/cosmos-sdk/-/cosmos-sdk-1.5.0.tgz#ccda05c29c88c741a023e8e5196be21ed175d8c2" +cosmos-sdk@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/cosmos-sdk/-/cosmos-sdk-1.5.1.tgz#4f648aa7eaa781883dfa1bef9ee78b4fba299cf3" dependencies: axios "^0.16.2" old "^0.2.0" diff --git a/test/unit/helpers/node_mock.js b/test/unit/helpers/node_mock.js index 0af2589385..f03276014b 100644 --- a/test/unit/helpers/node_mock.js +++ b/test/unit/helpers/node_mock.js @@ -1,7 +1,9 @@ module.exports = { - // rest + // REST + lcdConnected: () => Promise.resolve(true), getKey: () => ({}), generateKey: () => ({key: '123'}), + listKeys: () => [], queryAccount: () => null, queryNonce: () => '123', buildSend: (args) => { @@ -15,11 +17,21 @@ module.exports = { deliver_tx: { code: 0 } }), sign: () => Promise.resolve(null), + + // RPC rpc: { on: () => {}, subscribe: () => {}, validators: () => [], block: (args, cb) => cb({}), - blockchain: (args, cb) => cb({}) - } + blockchain: (args, cb) => cb({}), + status: (cb) => cb(null, { + latest_block_height: 42, + node_info: { + network: 'test-net' + } + }) + }, + initRPC: () => {}, + rpcReconnect: () => Promise.resolve('1.1.1.1') } diff --git a/test/unit/helpers/vuex-setup.js b/test/unit/helpers/vuex-setup.js index 25abfe64f7..4d9163a583 100644 --- a/test/unit/helpers/vuex-setup.js +++ b/test/unit/helpers/vuex-setup.js @@ -8,8 +8,9 @@ const Modules = require('renderer/vuex/modules').default const Getters = require('renderer/vuex/getters') export default function vuexSetup () { + const node = require('../helpers/node_mock') const modules = Modules({ - node: require('../helpers/node_mock') + node }) const localVue = createLocalVue() @@ -29,9 +30,10 @@ export default function vuexSetup () { let router = new VueRouter({routes}) return { + node, store, router, - wrapper: testType(componentConstructor, { + wrapper: componentConstructor && testType(componentConstructor, { localVue, store, router, stubs, propsData }) } diff --git a/test/unit/specs/main.spec.js b/test/unit/specs/main.spec.js index 41c84d6136..7ad6723a5e 100644 --- a/test/unit/specs/main.spec.js +++ b/test/unit/specs/main.spec.js @@ -52,6 +52,7 @@ describe('Startup Process', () => { jest.mock(root + 'package.json', () => ({ version: '0.1.0' })) + jest.mock(appRoot + 'src/main/relayServer.js', () => () => {}) beforeAll(() => { fs.removeSync(testRoot + 'genesis.json') @@ -307,6 +308,7 @@ describe('Startup Process', () => { jest.resetModules() await require(appRoot + 'src/main/index.js') + .then(() => done.fail('Didnt fail')) .catch(err => { expect(err.message.toLowerCase()).toContain('seeds') done() diff --git a/test/unit/specs/node.spec.js b/test/unit/specs/node.spec.js new file mode 100644 index 0000000000..c5f9d2bd6b --- /dev/null +++ b/test/unit/specs/node.spec.js @@ -0,0 +1,78 @@ +const LCDConnector = require('renderer/node') + +let fetch = global.fetch + +describe('LCD Connector', () => { + beforeEach(() => { + process.env.COSMOS_UI_ONLY = 'false' + }) + beforeAll(() => { + global.fetch = () => Promise.resolve({ + text: () => '1.2.3.4' + }) + }) + afterAll(() => { + global.fetch = fetch + }) + + it('should return a mockClient if setting COSMOS_UI_ONLY', () => { + process.env.COSMOS_UI_ONLY = 'true' + let node = LCDConnector('1.1.1.1') + expect(node.generateKey().key.address).toBe('UI_ONLY_MODE') + }) + + it('should init the rpc connection on initialization', () => { + let node = LCDConnector('1.1.1.1') + expect(node.rpc).toBeDefined() + expect(node.rpcOpen).toBe(true) + }) + + it('should remember if it could not connect via rpc', () => { + jest.mock('../../../app/node_modules/tendermint', () => () => ({ + on (value, cb) { + if (value === 'error') { + cb({code: 'ECONNREFUSED'}) + } + } + })) + jest.resetModules() + let LCDConnector = require('renderer/node') + let node = LCDConnector('1.1.1.1') + expect(node.rpc).toBeDefined() + expect(node.rpcOpen).toBe(false) + }) + + it('should notify the main process to reconnect', async () => { + let node = LCDConnector('1.1.1.1') + expect(node.rpcConnecting).toBe(false) + node.initRPC = jest.fn() + let nodeIP = await node.rpcReconnect() + expect(nodeIP).toBe('1.2.3.4') + }) + + it('should try to reconnect until it gets a valid ip', async () => { + let node = LCDConnector('1.1.1.1') + let i = 0 + global.fetch = jest.fn(() => { + return Promise.resolve({ + text: () => { + if (i++ === 0) { + return null + } else { + return '1.2.3.4' + } + } + }) + }) + let nodeIP = await node.rpcReconnect() + expect(global.fetch.mock.calls.length).toBe(2) + expect(nodeIP).toBe('1.2.3.4') + }) + + it('should return if it is connected to the LCD', async () => { + let node = LCDConnector('1.1.1.1') + expect(await node.lcdConnected()).toBe(false) + node.listKeys = () => Promise.resolve(['']) + expect(await node.lcdConnected()).toBe(true) + }) +}) diff --git a/test/unit/specs/store/node.spec.js b/test/unit/specs/store/node.spec.js new file mode 100644 index 0000000000..a0b5f20140 --- /dev/null +++ b/test/unit/specs/store/node.spec.js @@ -0,0 +1,119 @@ +import setup from '../../helpers/vuex-setup' + +let instance = setup() + +describe('Module: Node', () => { + let store, node + + beforeEach(() => { + let test = instance.shallow() + store = test.store + node = test.node + + node.rpcReconnect = jest.fn(() => { + node.rpcOpen = true + return Promise.resolve() + }) + }) + + it('sets the header', () => { + store.dispatch('setLastHeader', { + height: 5, + chain_id: 'test-chain' + }) + expect(store.state.node.lastHeader.height).toBe(5) + expect(store.state.node.lastHeader.chain_id).toBe('test-chain') + }) + + it('checks for new validators', done => { + node.rpc.validators = () => done() + store.dispatch('setLastHeader', { + height: 5, + chain_id: 'test-chain', + validators_hash: '1234567890123456789012345678901234567890' + }) + }) + + it('sets connection state', () => { + expect(store.state.node.connected).toBe(false) + store.commit('setConnected', true) + expect(store.state.node.connected).toBe(true) + }) + + it('triggers a reconnect', () => { + store.dispatch('reconnect') + expect(node.rpcReconnect).toHaveBeenCalled() + }) + + it('subscribes again on reconnect', done => { + node.rpc.status = () => done() + store.dispatch('reconnect') + }) + + it('reacts to rpc disconnection with reconnect', done => { + let failed = false + node.rpc.status = () => done() + node.rpc.on = jest.fn((value, cb) => { + if (value === 'error' && !failed) { + failed = true + cb({ + message: 'disconnected' + }) + expect(store.state.node.connected).toBe(false) + } + }) + store.dispatch('nodeSubscribe') + }) + + it('should set the initial status', () => { + node.rpc.status = (cb) => cb(null, { + latest_block_height: 42, + node_info: { + network: 'test-net' + } + }) + store.dispatch('nodeSubscribe') + expect(store.state.node.connected).toBe(true) + expect(store.state.node.lastHeader.height).toBe(42) + expect(store.state.node.lastHeader.chain_id).toBe('test-net') + }) + + it('should react to status updates', () => { + node.rpc.subscribe = (type, cb) => { + if (type.event === 'NewBlockHeader') { + cb(null, { + data: { + data: { + header: { + height: 42, + chain_id: 'test-net', + validators_hash: 'abc' + } + } + } + }) + } + } + store.dispatch('nodeSubscribe') + expect(store.state.node.connected).toBe(true) + expect(store.state.node.lastHeader.height).toBe(42) + expect(store.state.node.lastHeader.chain_id).toBe('test-net') + }) + + it('should check for an existing LCD connection', async () => { + expect(await store.dispatch('checkConnection')).toBe(true) + node.lcdConnected = () => Promise.reject() + expect(await store.dispatch('checkConnection')).toBe(false) + expect(store.state.notifications[0].body).toContain(`Couldn't initialize`) + }) + + it('should trigger reconnection if it started disconnected', done => { + node.rpcOpen = false + node.rpcReconnect = () => { + done() + node.rpcOpen = true + return Promise.resolve('1.1.1.1') + } + store.dispatch('nodeSubscribe') + }) +})