import _ from 'lodash' import net from 'net' import assert from 'assert-diff' import setupAPI from './setup-api' import {RippleAPI} from 'ripple-api' import ledgerClose from './fixtures/rippled/ledger-close.json' import {ignoreWebSocketDisconnect} from './utils' const utils = RippleAPI._PRIVATE.ledgerUtils const TIMEOUT = 200000 // how long before each test case times out const isBrowser = (process as any).browser function createServer() { return new Promise((resolve, reject) => { const server = net.createServer() server.on('listening', function() { resolve(server) }) server.on('error', function(error) { reject(error) }) server.listen(0, '0.0.0.0') }) } describe('Connection', function() { this.timeout(TIMEOUT) beforeEach(setupAPI.setup) afterEach(setupAPI.teardown) it('default options', function() { const connection: any = new utils.common.Connection('url') assert.strictEqual(connection._url, 'url') assert(_.isUndefined(connection._config.proxy)) assert(_.isUndefined(connection._config.authorization)) }) describe('trace', () => { const mockedRequestData = {mocked: 'request'} const mockedResponse = JSON.stringify({mocked: 'response', id: 0}) const expectedMessages = [ // We add the ID here, since it's not a part of the user-provided request. ['send', JSON.stringify({...mockedRequestData, id: 0})], ['receive', mockedResponse] ] const originalConsoleLog = console.log afterEach(() => { console.log = originalConsoleLog }) it('as false', function() { const messages = [] console.log = (id, message) => messages.push([id, message]) const connection: any = new utils.common.Connection('url', {trace: false}) connection._ws = {send: function() {}} connection.request(mockedRequestData) connection._onMessage(mockedResponse) assert.deepEqual(messages, []) }) it('as true', function() { const messages = [] console.log = (id, message) => messages.push([id, message]) const connection: any = new utils.common.Connection('url', {trace: true}) connection._ws = {send: function() {}} connection.request(mockedRequestData) connection._onMessage(mockedResponse) assert.deepEqual(messages, expectedMessages) }) it('as a function', function() { const messages = [] const connection: any = new utils.common.Connection('url', { trace: (id, message) => messages.push([id, message]) }) connection._ws = {send: function() {}} connection.request(mockedRequestData) connection._onMessage(mockedResponse) assert.deepEqual(messages, expectedMessages) }) }) it('ledger methods work as expected', async function() { assert.strictEqual(await this.api.connection.getLedgerVersion(), 8819951) assert.strictEqual( await this.api.connection.hasLedgerVersion(8819951), true ) assert.strictEqual( await this.api.connection.hasLedgerVersions(8819951, undefined), true ) // It would be nice to test a better range, but the mocked ledger only supports this single number assert.strictEqual( await this.api.connection.hasLedgerVersions(8819951, 8819951), true ) assert.strictEqual(await this.api.connection.getFeeBase(), 10) assert.strictEqual(await this.api.connection.getFeeRef(), 10) assert.strictEqual(await this.api.connection.getReserveBase(), 20000000) // 20 XRP }) it('with proxy', function(done) { if (isBrowser) { done() return } createServer().then((server: any) => { const port = server.address().port const expect = 'CONNECT localhost' server.on('connection', socket => { socket.on('data', data => { const got = data.toString('ascii', 0, expect.length) assert.strictEqual(got, expect) server.close() connection.disconnect() done() }) }) const options = { proxy: 'ws://localhost:' + port, authorization: 'authorization', trustedCertificates: ['path/to/pem'] } const connection = new utils.common.Connection( this.api.connection._url, options ) connection.connect().catch(err => { assert(err instanceof this.api.errors.NotConnectedError) }) }, done) }) it('Multiply disconnect calls', function() { this.api.disconnect() return this.api.disconnect() }) it('reconnect', function() { return this.api.connection.reconnect() }) it('NotConnectedError', function() { const connection = new utils.common.Connection('url') return connection .getLedgerVersion() .then(() => { assert(false, 'Should throw NotConnectedError') }) .catch(error => { assert(error instanceof this.api.errors.NotConnectedError) }) }) it('should throw NotConnectedError if server not responding ', function(done) { if (isBrowser) { const phantomTest = /PhantomJS/ if (phantomTest.test(navigator.userAgent)) { // inside PhantomJS this one just hangs, so skip as not very relevant done() return } } // Address where no one listens const connection = new utils.common.Connection( 'ws://testripple.circleci.com:129' ) connection.on('error', done) connection.connect().catch(error => { assert(error instanceof this.api.errors.NotConnectedError) done() }) }) it('DisconnectedError', async function() { await this.api.connection.request({ command: 'config', data: {disconnectOnServerInfo: true} }) return this.api .getServerInfo() .then(() => { assert(false, 'Should throw DisconnectedError') }) .catch(error => { assert(error instanceof this.api.errors.DisconnectedError) }) }) it('TimeoutError', function() { this.api.connection._ws.send = function(message, options, callback) { callback(null) } const request = {command: 'server_info'} return this.api.connection .request(request, 10) .then(() => { assert(false, 'Should throw TimeoutError') }) .catch(error => { assert(error instanceof this.api.errors.TimeoutError) }) }) it('DisconnectedError on send', function() { this.api.connection._ws.send = function(message, options, callback) { callback({message: 'not connected'}) } return this.api .getServerInfo() .then(() => { assert(false, 'Should throw DisconnectedError') }) .catch(error => { assert(error instanceof this.api.errors.DisconnectedError) assert.strictEqual(error.message, 'not connected') }) }) it('DisconnectedError on initial _onOpen send', async function() { // _onOpen previously could throw PromiseRejectionHandledWarning: Promise rejection was handled asynchronously // do not rely on the api.setup hook to test this as it bypasses the case, disconnect api connection first await this.api.disconnect() // stub _onOpen to only run logic relevant to test case this.api.connection._onOpen = () => { // overload websocket send on open when _ws exists this.api.connection._ws.send = function(data, options, cb) { // recent ws throws this error instead of calling back throw new Error('WebSocket is not open: readyState 0 (CONNECTING)') } const request = {command: 'subscribe', streams: ['ledger']} return this.api.connection.request(request) } try { await this.api.connect() } catch (error) { assert(error instanceof this.api.errors.DisconnectedError) assert.strictEqual( error.message, 'WebSocket is not open: readyState 0 (CONNECTING)' ) } }) it('ResponseFormatError', function() { return this.api .request('test_command', {data: {unrecognizedResponse: true}}) .then(() => { assert(false, 'Should throw ResponseFormatError') }) .catch(error => { assert(error instanceof this.api.errors.ResponseFormatError) }) }) it('reconnect on unexpected close', function(done) { this.api.connection.on('connected', () => { done() }) setTimeout(() => { this.api.connection._ws.close() }, 1) }) describe('reconnection test', function() { it('reconnect on several unexpected close', function(done) { if (isBrowser) { const phantomTest = /PhantomJS/ if (phantomTest.test(navigator.userAgent)) { // inside PhantomJS this one just hangs, so skip as not very relevant done() return } } this.timeout(70001) const self = this function breakConnection() { self.api.connection .request({ command: 'test_command', data: {disconnectIn: 10} }) .catch(ignoreWebSocketDisconnect) } let connectsCount = 0 let disconnectsCount = 0 let reconnectsCount = 0 let code = 0 this.api.connection.on('reconnecting', () => { reconnectsCount += 1 }) this.api.connection.on('disconnected', _code => { code = _code disconnectsCount += 1 }) const num = 3 this.api.connection.on('connected', () => { connectsCount += 1 if (connectsCount < num) { breakConnection() } if (connectsCount === num) { if (disconnectsCount !== num) { done( new Error( 'disconnectsCount must be equal to ' + num + '(got ' + disconnectsCount + ' instead)' ) ) } else if (reconnectsCount !== num) { done( new Error( 'reconnectsCount must be equal to ' + num + ' (got ' + reconnectsCount + ' instead)' ) ) } else if (code !== 1006) { done( new Error( 'disconnect must send code 1006 (got ' + code + ' instead)' ) ) } else { done() } } }) breakConnection() }) }) it('reconnect event on heartbeat failure', function(done) { if (isBrowser) { const phantomTest = /PhantomJS/ if (phantomTest.test(navigator.userAgent)) { // inside PhantomJS this one just hangs, so skip as not very relevant done() return } } // Set the heartbeat to less than the 1 second ping response this.api.connection._config.timeout = 500 // Drop the test runner timeout, since this should be a quick test this.timeout(5000) // Hook up a listener for the reconnect event this.api.connection.on('reconnect', () => done()) // Trigger a heartbeat this.api.connection._heartbeat().catch(error => { /* ignore - test expects heartbeat failure */ }) }) it('heartbeat failure and reconnect failure', function(done) { if (isBrowser) { const phantomTest = /PhantomJS/ if (phantomTest.test(navigator.userAgent)) { // inside PhantomJS this one just hangs, so skip as not very relevant done() return } } // Set the heartbeat to less than the 1 second ping response this.api.connection._config.timeout = 500 // Drop the test runner timeout, since this should be a quick test this.timeout(5000) // fail on reconnect/connection this.api.connection.reconnect = async () => { throw new Error('error on reconnect') } // Hook up a listener for the reconnect error event this.api.on('error', (error, message) => { if (error === 'reconnect' && message === 'error on reconnect') { return done() } return done(new Error('Expected error on reconnect')) }) // Trigger a heartbeat this.api.connection._heartbeat() }) it('should emit disconnected event with code 1000 (CLOSE_NORMAL)', function(done) { this.api.once('disconnected', code => { assert.strictEqual(code, 1000) done() }) this.api.disconnect() }) it('should emit disconnected event with code 1006 (CLOSE_ABNORMAL)', function(done) { this.api.connection.once('error', error => { done(new Error('should not throw error, got ' + String(error))) }) this.api.connection.once('disconnected', code => { assert.strictEqual(code, 1006) done() }) this.api.connection .request({ command: 'test_command', data: {disconnectIn: 10} }) .catch(ignoreWebSocketDisconnect) }) it('should emit connected event on after reconnect', function(done) { this.api.once('connected', done) this.api.connection._ws.close() }) it('Multiply connect calls', function() { return this.api.connect().then(() => { return this.api.connect() }) }) it('hasLedgerVersion', function() { return this.api.connection.hasLedgerVersion(8819951).then(result => { assert(result) }) }) it('Cannot connect because no server', function() { const connection = new utils.common.Connection(undefined as string) return connection .connect() .then(() => { assert(false, 'Should throw ConnectionError') }) .catch(error => { assert( error instanceof this.api.errors.ConnectionError, 'Should throw ConnectionError' ) }) }) it('connect multiserver error', function() { assert.throws(function() { new RippleAPI({ servers: ['wss://server1.com', 'wss://server2.com'] } as any) }, this.api.errors.RippleError) }) it('connect throws error', function(done) { this.api.once('error', (type, info) => { assert.strictEqual(type, 'type') assert.strictEqual(info, 'info') done() }) this.api.connection.emit('error', 'type', 'info') }) it('emit stream messages', function(done) { let transactionCount = 0 let pathFindCount = 0 this.api.connection.on('transaction', () => { transactionCount++ }) this.api.connection.on('path_find', () => { pathFindCount++ }) this.api.connection.on('response', message => { assert.strictEqual(message.id, 1) assert.strictEqual(transactionCount, 1) assert.strictEqual(pathFindCount, 1) done() }) this.api.connection._onMessage( JSON.stringify({ type: 'transaction' }) ) this.api.connection._onMessage( JSON.stringify({ type: 'path_find' }) ) this.api.connection._onMessage( JSON.stringify({ type: 'response', id: 1 }) ) }) it('invalid message id', function(done) { this.api.on('error', (errorCode, errorMessage, message) => { assert.strictEqual(errorCode, 'badMessage') assert.strictEqual(errorMessage, 'valid id not found in response') assert.strictEqual(message, '{"type":"response","id":"must be integer"}') done() }) this.api.connection._onMessage( JSON.stringify({ type: 'response', id: 'must be integer' }) ) }) it('propagates error message', function(done) { this.api.on('error', (errorCode, errorMessage, data) => { assert.strictEqual(errorCode, 'slowDown') assert.strictEqual(errorMessage, 'slow down') assert.deepEqual(data, {error: 'slowDown', error_message: 'slow down'}) done() }) this.api.connection._onMessage( JSON.stringify({ error: 'slowDown', error_message: 'slow down' }) ) }) it('propagates RippledError data', function(done) { this.api.request('subscribe', {streams: 'validations'}).catch(error => { assert.strictEqual(error.name, 'RippledError') assert.strictEqual(error.data.error, 'invalidParams') assert.strictEqual(error.message, 'Invalid parameters.') assert.strictEqual(error.data.error_code, 31) assert.strictEqual(error.data.error_message, 'Invalid parameters.') assert.deepEqual(error.data.request, { command: 'subscribe', id: 0, streams: 'validations' }) assert.strictEqual(error.data.status, 'error') assert.strictEqual(error.data.type, 'response') done() }) }) it('unrecognized message type', function(done) { // This enables us to automatically support any // new messages added by rippled in the future. this.api.connection.on('unknown', event => { assert.deepEqual(event, {type: 'unknown'}) done() }) this.api.connection._onMessage(JSON.stringify({type: 'unknown'})) }) it('ledger close without validated_ledgers', function(done) { const message = _.omit(ledgerClose, 'validated_ledgers') this.api.on('ledger', function(ledger) { assert.strictEqual(ledger.ledgerVersion, 8819951) done() }) this.api.connection._ws.emit('message', JSON.stringify(message)) }) it( 'should throw RippledNotInitializedError if server does not have ' + 'validated ledgers', async function() { this.timeout(3000) await this.api.connection.request({ command: 'global_config', data: {returnEmptySubscribeRequest: 1} }) const api = new RippleAPI({server: this.api.connection._url}) return api.connect().then( () => { assert(false, 'Must have thrown!') }, error => { assert( error instanceof this.api.errors.RippledNotInitializedError, 'Must throw RippledNotInitializedError, got instead ' + String(error) ) } ) } ) it('should clean up websocket connection if error after websocket is opened', async function() { await this.api.disconnect() // fail on connection this.api.connection._subscribeToLedger = async () => { throw new Error('error on _subscribeToLedger') } try { await this.api.connect() throw new Error('expected connect() to reject, but it resolved') } catch (err) { assert(err.message === 'error on _subscribeToLedger') // _ws.close event listener should have cleaned up the socket when disconnect _ws.close is run on connection error // do not fail on connection anymore this.api.connection._subscribeToLedger = async () => {} await this.api.connection.reconnect() } }) it('should try to reconnect on empty subscribe response on reconnect', function(done) { this.timeout(23000) this.api.on('error', error => { done(error || new Error('Should not emit error.')) }) let disconnectedCount = 0 this.api.on('connected', () => { done( disconnectedCount !== 1 ? new Error('Wrong number of disconnects') : undefined ) }) this.api.on('disconnected', () => { disconnectedCount++ }) this.api.connection.request({ command: 'test_command', data: {disconnectIn: 5} }) }) })