Repository: twitch-apis/twitch-js (js)

Issue:
Feature: change config options after instantiating client
This isn't a bug, but a question.  Is there any possible way to change the debug setting used when creating the client after the client has been created and while the client is running?

Pull request:
Feature: allow options to be updated
Resolves #55 

Patch:
diff --git a/src/Api/constants.js b/src/Api/constants.js
index 626c1b2..6bf397e 100644
--- a/src/Api/constants.js
+++ b/src/Api/constants.js
@@ -1,1 +1,16 @@
 export const KRAKEN_URL_ROOT = 'https://api.twitch.tv/kraken'
+
+/** @typedef {string} ApiReadyState */
+/**
+ * API client ready state
+ * @readonly
+ * @enum {ApiReadyState}
+ * @property {string} 0 not ready
+ * @property {string} 1 ready
+ * @property {string} 2 initialized
+ */
+export const READY_STATES = {
+  0: 'NOT_READY',
+  1: 'READY',
+  2: 'INITIALIZED',
+}
diff --git a/src/Api/index.js b/src/Api/index.js
index 427bb16..9b93216 100644
--- a/src/Api/index.js
+++ b/src/Api/index.js
@@ -5,6 +5,8 @@ import fetchUtil from '../utils/fetch'
 import * as Errors from '../utils/fetch/Errors'
 import * as validators from './utils/validators'
 
+import * as constants from './constants'
+
 /**
  * API client
  *
@@ -18,37 +20,38 @@ import * as validators from './utils/validators'
  * })
  */
 class Api {
-  /**
-   * API client ready state : **1** ready; **2** initialized.
-   * @type {number}
-   */
-  readyState = 1
-
-  /**
-   * API status state.
-   * @typedef {Object} ApiStatusState
-   * @property {Object} token
-   * @property {Object} token.authorization
-   * @property {Array<string>} token.authorization.scopes
-   * @property {string} token.authorization.createdAt
-   * @property {string} token.authorization.updatedAt
-   * @property {string} token.clientId
-   * @property {string} token.userId
-   * @property {string} token.userName
-   * @property {boolean} token.valid
-   */
-  /**
-   * API status.
-   * @type {ApiStatusState}
-   */
-  status = {}
-
   /**
    * API constructor.
    * @param {ApiOptions} options
    */
   constructor(maybeOptions = {}) {
     this.options = maybeOptions
+
+    /**
+     * API ready state
+     * @readonly
+     * @type {ApiReadyState}
+     */
+    this._readyState = 1
+
+    /**
+     * API status state.
+     * @typedef {Object} ApiStatusState
+     * @property {Object} token
+     * @property {Object} token.authorization
+     * @property {Array<string>} token.authorization.scopes
+     * @property {string} token.authorization.createdAt
+     * @property {string} token.authorization.updatedAt
+     * @property {string} token.clientId
+     * @property {string} token.userId
+     * @property {string} token.userName
+     * @property {boolean} token.valid
+     */
+    /**
+     * API status.
+     * @type {ApiStatusState}
+     */
+    this._status = {}
   }
 
   set options(maybeOptions) {
@@ -59,8 +62,21 @@ class Api {
     return this._options
   }
 
-  updateToken(token) {
-    this.options = { ...this.options, token }
+  set readyState(readyState) {
+    this._readyState = constants.READY_STATES[readyState] ? readyState : 0
+  }
+
+  get readyState() {
+    return this._readyState
+  }
+
+  get status() {
+    return this._status
+  }
+
+  updateOptions(options) {
+    const { clientId, token } = this.options
+    this.options = { ...options, clientId, token }
   }
 
   getHeaders() {
@@ -74,17 +90,22 @@ class Api {
 
   /**
    * Initialize API client and retrieve status.
+   * @param {ApiOptions} options
    * @returns {Promise<ApiStatusState, Object>}
    * @see https://dev.twitch.tv/docs/v5/#root-url
    */
-  initialize() {
-    if (this.readyState === 2) {
+  initialize(newOptions) {
+    if (newOptions) {
+      this.options = { ...this.options, ...newOptions }
+    }
+
+    if (!newOptions && this.readyState === 2) {
       return Promise.resolve()
     }
 
     return this.get().then(statusResponse => {
-      this.readyState = 2
-      this.status = statusResponse
+      this._readyState = 2
+      this._status = statusResponse
 
       return statusResponse
     })
@@ -158,7 +179,7 @@ function handleFetch(maybeUrl = '', options = {}) {
     if (error instanceof Errors.AuthenticationError) {
       return this.options
         .onAuthenticationFailure()
-        .then(token => this.updateToken(token))
+        .then(token => (this.options = { ...this.options, token }))
         .then(() => request())
     }
 
diff --git a/src/Chat/constants.js b/src/Chat/constants.js
index f2af178..039b887 100644
--- a/src/Chat/constants.js
+++ b/src/Chat/constants.js
@@ -36,10 +36,11 @@ export const ERROR_COMMAND_TIMED_OUT = 'ERROR: command timed out'
 export const ERROR_COMMAND_UNRECOGNIZED = 'ERROR: command unrecognized'
 export const ERROR_PART_TIMED_OUT = 'ERROR: part timed out'
 
+/** @typedef {string} ClientReadyState */
 /**
  * Chat client ready state
  * @readonly
- * @enum {number}
+ * @enum {ClientReadyState}
  * @property {string} 0 not ready
  * @property {string} 1 connecting
  * @property {string} 2 reconnecting
diff --git a/src/Chat/index.js b/src/Chat/index.js
index 6c39846..970c847 100644
--- a/src/Chat/index.js
+++ b/src/Chat/index.js
@@ -130,8 +130,9 @@ class Chat extends EventEmitter {
     this._userState = userState
   }
 
-  updateToken(token) {
-    this.options = { ...this.options, token }
+  updateOptions(options) {
+    const { token, username } = this.options
+    this.options = { ...options, token, username }
   }
 
   getChannels() {
@@ -231,7 +232,7 @@ class Chat extends EventEmitter {
    */
   reconnect(newOptions) {
     if (newOptions) {
-      this.options = newOptions
+      this.options = { ...this.options, ...newOptions }
     }
 
     this.readyState = 2
@@ -399,7 +400,7 @@ function handleConnectRetry(error) {
   if (error.event === constants.EVENTS.AUTHENTICATION_FAILED) {
     return this.options
       .onAuthenticationFailure()
-      .then(token => this.updateToken(token))
+      .then(token => (this.options = { ...this.options, token }))
       .then(() => utils.delay(this.options.connectionTimeout))
       .then(() => this.connect())
       .catch(() => {
diff --git a/src/index.js b/src/index.js
index ba83d83..9965ee5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -35,6 +35,17 @@ class TwitchJs {
     /** @type {Api} */
     this.api = new Api({ ...api, token, username, onAuthenticationFailure })
   }
+
+  /**
+   * Update TwitchJs client options.
+   * @param {Object} options
+   * @param {ChatOptions} [options.chat]
+   * @param {ApiOptions} [options.api]
+   */
+  updateOptions({ chat, api }) {
+    this.chat.updateOptions(chat)
+    this.api.updateOptions(api)
+  }
 }
 
 export { Chat, ChatConstants }


Test patch:
diff --git a/__e2e__/node/jest.config.js b/__e2e__/node/jest.config.js
index b41be03..1d6a5b1 100644
--- a/__e2e__/node/jest.config.js
+++ b/__e2e__/node/jest.config.js
@@ -1,3 +1,4 @@
 module.exports = {
+  testEnvironment: 'node',
   testMatch: ['**/?(*.)+(e2e).js'],
 }
diff --git a/__e2e__/node/test.e2e.js b/__e2e__/node/test.e2e.js
index c1e221f..142b71f 100644
--- a/__e2e__/node/test.e2e.js
+++ b/__e2e__/node/test.e2e.js
@@ -8,6 +8,8 @@ describe('Node E2E', () => {
     ? `Travis CI E2E Build #${process.env.TRAVIS_BUILD_NUMBER}`
     : `Local E2E ${new Date()}`
 
+  afterAll(() => setTimeout(() => process.exit(), 1000))
+
   describe('Chat', () => {
     test('should connect and send message', async () => {
       const { chat } = new TwitchJs({ token, username })
diff --git a/src/Api/__tests__/index.test.js b/src/Api/__tests__/index.test.js
index c196d4a..4ccced8 100644
--- a/src/Api/__tests__/index.test.js
+++ b/src/Api/__tests__/index.test.js
@@ -28,6 +28,14 @@ describe('Api', () => {
     })
   })
 
+  test('should allow options to be updated', () => {
+    const api = new Api(options)
+    api.updateOptions({ token: 'NEW_TOKEN', debug: true })
+
+    expect(api._options.token).toBe(options.token)
+    expect(api._options.debug).toBe(true)
+  })
+
   describe('initialize', () => {
     test('should set readyState and status', async () => {
       const api = new Api(options)
@@ -44,6 +52,14 @@ describe('Api', () => {
 
       expect(actual).toMatchSnapshot()
     })
+
+    test('should update client options', async () => {
+      const api = new Api(options)
+      expect(api._options.debug).toBe(false)
+      await api.initialize({ debug: true })
+
+      expect(api._options.debug).toBe(true)
+    })
   })
 
   describe('hasScope', () => {
diff --git a/src/Chat/__tests__/__snapshots__/index.test.js.snap b/src/Chat/__tests__/__snapshots__/index.test.js.snap
index 7f36c46..7b9522e 100644
--- a/src/Chat/__tests__/__snapshots__/index.test.js.snap
+++ b/src/Chat/__tests__/__snapshots__/index.test.js.snap
@@ -18,6 +18,157 @@ Object {
 }
 `;
 
+exports[`Chat reconnect should reconnect and rejoin channels 1`] = `
+Array [
+  Array [
+    "close",
+  ],
+  Array [
+    "open",
+  ],
+  Array [
+    "CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership",
+  ],
+  Array [
+    "PASS oauth:TOKEN",
+  ],
+  Array [
+    "NICK USERNAME",
+  ],
+  Array [
+    "JOIN #dallas",
+  ],
+]
+`;
+
+exports[`Chat reconnect should reconnect and rejoin channels 2`] = `
+Array [
+  Array [
+    Object {
+      "_raw": "@color=#0D4200;display-name=dallas;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=1337;user-type=admin :tmi.twitch.tv GLOBALUSERSTATE",
+      "channel": undefined,
+      "command": "GLOBALUSERSTATE",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "color": "#0D4200",
+        "displayName": "dallas",
+        "emoteSets": Array [
+          "0",
+          "33",
+          "50",
+          "237",
+          "793",
+          "2126",
+          "3517",
+          "4578",
+          "5569",
+          "9400",
+          "10337",
+          "12239",
+        ],
+        "isTurboSubscriber": false,
+        "turbo": "0",
+        "userId": "1337",
+        "userType": "admin",
+        "username": "dallas",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+  Array [
+    Object {
+      "_raw": "@color=#0D4200;display-name=dallas;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=1337;user-type=admin :tmi.twitch.tv GLOBALUSERSTATE",
+      "channel": undefined,
+      "command": "CONNECTED",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "color": "#0D4200",
+        "displayName": "dallas",
+        "emoteSets": "0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239",
+        "turbo": "0",
+        "userId": "1337",
+        "userType": "admin",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+  Array [
+    Object {
+      "_raw": ":ronni!ronni@ronni.tmi.twitch.tv JOIN #dallas",
+      "channel": "#dallas",
+      "command": "JOIN",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {},
+      "timestamp": 2018-01-01T00:00:00.000Z,
+      "username": "ronni",
+    },
+  ],
+  Array [
+    Object {
+      "_raw": "@broadcaster-lang=en;r9k=0;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #dallas",
+      "channel": "#dallas",
+      "command": "ROOMSTATE",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "broadcasterLang": "en",
+        "broadcasterLanguage": "en",
+        "isEmoteOnly": false,
+        "isFollowersOnly": false,
+        "isR9kEnabled": false,
+        "isSubscribersOnly": false,
+        "r9k": "0",
+        "slow": "0",
+        "slowDelay": 0,
+        "subsOnly": "0",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+  Array [
+    Object {
+      "_raw": "@color=#0D4200;display-name=ronni;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;mod=1;subscriber=1;turbo=1;user-type=staff :tmi.twitch.tv USERSTATE #dallas",
+      "channel": "#dallas",
+      "command": "USERSTATE",
+      "isSelf": true,
+      "message": undefined,
+      "tags": Object {
+        "badges": Object {},
+        "bits": undefined,
+        "color": "#0D4200",
+        "displayName": "ronni",
+        "emoteSets": Array [
+          "0",
+          "33",
+          "50",
+          "237",
+          "793",
+          "2126",
+          "3517",
+          "4578",
+          "5569",
+          "9400",
+          "10337",
+          "12239",
+        ],
+        "emotes": Object {},
+        "isModerator": true,
+        "isSubscriber": true,
+        "isTurboSubscriber": true,
+        "mod": "1",
+        "subscriber": "1",
+        "turbo": "1",
+        "userType": "staff",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+]
+`;
+
 exports[`Chat should handle messages CLEARCHAT 1`] = `
 Object {
   "_raw": ":tmi.twitch.tv CLEARCHAT #dallas",
@@ -1017,154 +1168,3 @@ Object {
   },
 }
 `;
-
-exports[`Chat should reconnect and rejoin channels 1`] = `
-Array [
-  Array [
-    "close",
-  ],
-  Array [
-    "open",
-  ],
-  Array [
-    "CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership",
-  ],
-  Array [
-    "PASS oauth:TOKEN",
-  ],
-  Array [
-    "NICK USERNAME",
-  ],
-  Array [
-    "JOIN #dallas",
-  ],
-]
-`;
-
-exports[`Chat should reconnect and rejoin channels 2`] = `
-Array [
-  Array [
-    Object {
-      "_raw": "@color=#0D4200;display-name=dallas;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=1337;user-type=admin :tmi.twitch.tv GLOBALUSERSTATE",
-      "channel": undefined,
-      "command": "GLOBALUSERSTATE",
-      "isSelf": false,
-      "message": undefined,
-      "tags": Object {
-        "color": "#0D4200",
-        "displayName": "dallas",
-        "emoteSets": Array [
-          "0",
-          "33",
-          "50",
-          "237",
-          "793",
-          "2126",
-          "3517",
-          "4578",
-          "5569",
-          "9400",
-          "10337",
-          "12239",
-        ],
-        "isTurboSubscriber": false,
-        "turbo": "0",
-        "userId": "1337",
-        "userType": "admin",
-        "username": "dallas",
-      },
-      "timestamp": 2018-01-01T00:00:00.000Z,
-    },
-  ],
-  Array [
-    Object {
-      "_raw": "@color=#0D4200;display-name=dallas;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=1337;user-type=admin :tmi.twitch.tv GLOBALUSERSTATE",
-      "channel": undefined,
-      "command": "CONNECTED",
-      "isSelf": false,
-      "message": undefined,
-      "tags": Object {
-        "color": "#0D4200",
-        "displayName": "dallas",
-        "emoteSets": "0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239",
-        "turbo": "0",
-        "userId": "1337",
-        "userType": "admin",
-      },
-      "timestamp": 2018-01-01T00:00:00.000Z,
-    },
-  ],
-  Array [
-    Object {
-      "_raw": ":ronni!ronni@ronni.tmi.twitch.tv JOIN #dallas",
-      "channel": "#dallas",
-      "command": "JOIN",
-      "isSelf": false,
-      "message": undefined,
-      "tags": Object {},
-      "timestamp": 2018-01-01T00:00:00.000Z,
-      "username": "ronni",
-    },
-  ],
-  Array [
-    Object {
-      "_raw": "@broadcaster-lang=en;r9k=0;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #dallas",
-      "channel": "#dallas",
-      "command": "ROOMSTATE",
-      "isSelf": false,
-      "message": undefined,
-      "tags": Object {
-        "broadcasterLang": "en",
-        "broadcasterLanguage": "en",
-        "isEmoteOnly": false,
-        "isFollowersOnly": false,
-        "isR9kEnabled": false,
-        "isSubscribersOnly": false,
-        "r9k": "0",
-        "slow": "0",
-        "slowDelay": 0,
-        "subsOnly": "0",
-      },
-      "timestamp": 2018-01-01T00:00:00.000Z,
-    },
-  ],
-  Array [
-    Object {
-      "_raw": "@color=#0D4200;display-name=ronni;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;mod=1;subscriber=1;turbo=1;user-type=staff :tmi.twitch.tv USERSTATE #dallas",
-      "channel": "#dallas",
-      "command": "USERSTATE",
-      "isSelf": true,
-      "message": undefined,
-      "tags": Object {
-        "badges": Object {},
-        "bits": undefined,
-        "color": "#0D4200",
-        "displayName": "ronni",
-        "emoteSets": Array [
-          "0",
-          "33",
-          "50",
-          "237",
-          "793",
-          "2126",
-          "3517",
-          "4578",
-          "5569",
-          "9400",
-          "10337",
-          "12239",
-        ],
-        "emotes": Object {},
-        "isModerator": true,
-        "isSubscriber": true,
-        "isTurboSubscriber": true,
-        "mod": "1",
-        "subscriber": "1",
-        "turbo": "1",
-        "userType": "staff",
-      },
-      "timestamp": 2018-01-01T00:00:00.000Z,
-    },
-  ],
-]
-`;
diff --git a/src/Chat/__tests__/index.test.js b/src/Chat/__tests__/index.test.js
index 1d982f5..34d279b 100644
--- a/src/Chat/__tests__/index.test.js
+++ b/src/Chat/__tests__/index.test.js
@@ -77,6 +77,15 @@ describe('Chat', () => {
     })
   })
 
+  test('should allow options to be updated', () => {
+    const chat = new Chat(options)
+    expect(chat._options.debug).toBe(false)
+    chat.updateOptions({ token: 'NEW_TOKEN', debug: true })
+
+    expect(chat._options.token).toBe(options.token)
+    expect(chat._options.debug).toBe(true)
+  })
+
   test('should join channel', async () => {
     const chat = new Chat(options)
     await chat.connect()
@@ -123,35 +132,47 @@ describe('Chat', () => {
     chat.disconnect()
   })
 
-  test('should reconnect and rejoin channels', async () => {
-    const chat = new Chat(options)
-    await chat.connect()
-    await chat.join('#dallas')
+  describe('reconnect', () => {
+    test('should reconnect and rejoin channels', async () => {
+      const chat = new Chat(options)
+      await chat.connect()
+      await chat.join('#dallas')
 
-    const serverListener = jest.fn()
-    const chatListener = jest.fn()
-    server.on('close', () => serverListener('close'))
-    server.on('open', () => serverListener('open'))
-    server.on('message', serverListener)
-    chat.on('*', chatListener)
+      const serverListener = jest.fn()
+      const chatListener = jest.fn()
+      server.on('close', () => serverListener('close'))
+      server.on('open', () => serverListener('open'))
+      server.on('message', serverListener)
+      chat.on('*', chatListener)
 
-    await chat.reconnect()
+      await chat.reconnect()
 
-    expect(serverListener.mock.calls).toMatchSnapshot()
-    expect(chatListener.mock.calls).toMatchSnapshot()
+      expect(serverListener.mock.calls).toMatchSnapshot()
+      expect(chatListener.mock.calls).toMatchSnapshot()
 
-    server.removeListener('close')
-    server.removeListener('open')
-    server.removeListener('message')
-  })
+      server.removeListener('close')
+      server.removeListener('open')
+      server.removeListener('message')
+    })
 
-  test('should reconnect on RECONNECT event', async done => {
-    const chat = new Chat(options)
-    await chat.connect()
+    test('should reconnect on RECONNECT event', async done => {
+      const chat = new Chat(options)
+      await chat.connect()
+
+      chat.once('CONNECTED', () => done())
+
+      chat._client.emit('RECONNECT')
+    })
 
-    chat.once('CONNECTED', () => done())
+    test('should update client options', async () => {
+      const chat = new Chat(options)
+      await chat.connect()
 
-    chat._client.emit('RECONNECT')
+      await chat.reconnect({ token: 'NEW_TOKEN', debug: true })
+
+      expect(chat._options.token).toBe('NEW_TOKEN')
+      expect(chat._options.debug).toBe(true)
+    })
   })
 
   describe('should handle messages', () => {
diff --git a/src/__tests__/index.test.js b/src/__tests__/index.test.js
index 62358ae..a4e3641 100644
--- a/src/__tests__/index.test.js
+++ b/src/__tests__/index.test.js
@@ -18,4 +18,15 @@ describe('TwitchJs', () => {
 
     expect(twitchJs.api).toBeInstanceOf(Api)
   })
+
+  test('should allow client options to be updated', () => {
+    const twitchJs = new TwitchJs({ token, username })
+    const chatSpy = jest.spyOn(twitchJs.chat, 'updateOptions')
+    const apiSpy = jest.spyOn(twitchJs.api, 'updateOptions')
+
+    twitchJs.updateOptions({ chat: {}, api: {} })
+
+    expect(chatSpy).toHaveBeenCalled()
+    expect(apiSpy).toHaveBeenCalled()
+  })
 })