Repository: manusa/electronim (js)

Issue:
New version available notification (check for updates)
## Description

Users of the application should be notified whenever a new version is available to download. This is especially useful for those who run a standalone version of the application that is not managed by a package manager.

Pull request description:
✨ menu button displays up arrow when new version available
Fixes #97 

ElectronIM will check for updates every 30 minutes


Patch:
diff --git a/package.json b/package.json
index 741b2b4..60a1daf 100644
--- a/package.json
+++ b/package.json
@@ -146,7 +146,8 @@
   },
   "jest": {
     "setupFilesAfterEnv": [
-      "jest-extended/all"
+      "jest-extended/all",
+      "<rootDir>/src/__tests__/setup-jest.js"
     ],
     "testEnvironment": "jsdom",
     "testEnvironmentOptions": {
diff --git a/src/chrome-tabs/check-for-updates.js b/src/chrome-tabs/check-for-updates.js
new file mode 100644
index 0000000..1d470ce
--- /dev/null
+++ b/src/chrome-tabs/check-for-updates.js
@@ -0,0 +1,46 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+const axios = require('axios');
+const {ELECTRONIM_VERSION} = require('../constants');
+const GITHUB_RELEASES = 'https://github.com/manusa/electronim/releases';
+const GITHUB_RELEASES_LATEST = `${GITHUB_RELEASES}/latest`;
+const TAG_MATCHER = new RegExp(`${GITHUB_RELEASES}/tag/(.+)`);
+
+/**
+ * Checks if there is a new version of Electronim available and compares it with the current version.
+ *
+ * @returns {Promise<{matchesCurrent: boolean, version: string}>}
+ */
+const getLatestRelease = async () => {
+  const response = await axios.get(GITHUB_RELEASES_LATEST, {
+    headers: {Accept: '*/*'},
+    maxRedirects: 0,
+    validateStatus: null
+  });
+  if (response.status !== 302) {
+    throw new Error('Unexpected response from GitHub');
+  }
+  let version = TAG_MATCHER.exec(response.headers.location)[1];
+  if (version.startsWith('v')) {
+    version = version.substring(1);
+  }
+  return ({
+    version,
+    matchesCurrent: ELECTRONIM_VERSION === version
+  });
+};
+
+module.exports = {getLatestRelease};
diff --git a/src/chrome-tabs/chrome-tabs.browser.mjs b/src/chrome-tabs/chrome-tabs.browser.mjs
index c8965f9..c575cd1 100644
--- a/src/chrome-tabs/chrome-tabs.browser.mjs
+++ b/src/chrome-tabs/chrome-tabs.browser.mjs
@@ -14,7 +14,13 @@
    limitations under the License.
  */
 /* eslint-disable no-undef */
-import {APP_EVENTS, html, render, useLayoutEffect, useReducer, useState, Icon} from '../components/index.mjs';
+import {
+  APP_EVENTS, html, render, useLayoutEffect, useReducer, useState, Icon
+} from '../components/index.mjs';
+import {
+  initialState, reducer, activateTab, addTabs, moveTab, setNewVersionAvailable, setTabFavicon, setTabTitle,
+  sendActivateTab
+} from './chrome-tabs.reducer.browser.mjs';
 
 const shouldUseDarkColors = () => window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
 const getTabContainer = () => document.querySelector('.tab-container');
@@ -25,9 +31,6 @@ const openMenu = event => {
   ipcRenderer.send(APP_EVENTS.appMenuOpen);
 };
 const sendTabsReady = () => ipcRenderer.send(APP_EVENTS.tabsReady, {});
-const sendActivateTab = id => ipcRenderer.send(APP_EVENTS.activateTab, {id});
-const sendReorderTabs = tabs =>
-  ipcRenderer.send(APP_EVENTS.tabReorder, {tabIds: tabs.map(({id}) => id)});
 
 const TRANSPARENT_GIF = new Image();
 TRANSPARENT_GIF.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
@@ -35,77 +38,6 @@ const TAB_CONTENT_MARGIN = 9;
 const TAB_CONTENT_OVERLAP_DISTANCE = 1;
 const TAB_CONTENT_MAX_WIDTH = 240;
 
-const ACTIONS = {
-  ACTIVATE_TAB: 'ACTIVATE_TAB',
-  MOVE_TAB: 'MOVE_TAB',
-  SET_TAB_PROPERTY: 'SET_TAB_PROPERTY',
-  SET_TABS: 'SET_TABS'
-};
-
-const initialState = {
-  tabs: []
-};
-
-const reducer = (state, action) => {
-  switch (action.type) {
-    case ACTIONS.ACTIVATE_TAB: {
-      return {...state,
-        tabs: state.tabs.map(tab => ({...tab, active: tab.id === action.payload}))
-      };
-    }
-    case ACTIONS.MOVE_TAB: {
-      const {id, idx, offsetX} = action.payload;
-      const fromIdx = state.tabs.findIndex(t => t.id === id);
-      const ret = {...state, tabs: [...state.tabs]};
-      ret.tabs.forEach(t => (t.offsetX = 0));
-      const tab = ret.tabs[fromIdx];
-      tab.offsetX = offsetX;
-      ret.tabs.splice(fromIdx, 1);
-      ret.tabs.splice(idx, 0, tab);
-      sendReorderTabs(ret.tabs);
-      return ret;
-    }
-    case ACTIONS.SET_TAB_PROPERTY: {
-      const {id, property, value} = action.payload;
-      return {...state, tabs: state.tabs.map(tab => {
-        if (tab.id === id) {
-          const ret = {...tab};
-          ret[property] = value;
-          return ret;
-        }
-        return tab;
-      })};
-    }
-    case ACTIONS.SET_TABS: {
-      return {...state, tabs: [...action.payload]};
-    }
-    default: return state;
-  }
-};
-
-const dispatchActivateTab = dispatch => id => dispatch({type: ACTIONS.ACTIVATE_TAB, payload: id});
-
-const dispatchAddTabs = dispatch => (_event, tabs) => {
-  dispatch({type: ACTIONS.SET_TABS, payload: tabs});
-  const activeTabMeta = tabs.find(({active}) => active === true);
-  if (tabs.length > 0 && activeTabMeta) {
-    sendActivateTab(activeTabMeta.id);
-  }
-};
-
-const dispatchSetTabTitle = dispatch => (_event, {id, title}) => {
-  dispatch({type: ACTIONS.SET_TAB_PROPERTY, payload: {
-    id, property: 'title', value: title
-  }});
-};
-
-const dispatchSetTabFavicon = dispatch => (_event, {id, favicon}) => {
-  dispatch({type: ACTIONS.SET_TAB_PROPERTY, payload: {
-    id, property: 'favicon', value: favicon
-  }});
-};
-
-
 const calculateTabWidth = numberOfTabs => {
   const availableWidth = (getChromeTabs() || getTabContainer()).clientWidth;
   const tabsCumulativeOverlappedWidth = (numberOfTabs - 1) * TAB_CONTENT_OVERLAP_DISTANCE;
@@ -163,12 +95,11 @@ const BackgroundSvg = () => html`
     </svg>
 `;
 
-
 const Tab = ({dispatch, numberOfTabs, idx, id, active, offsetX = 0, title, url, width, ...rest}) => {
   const tabClick = () => {
     if (active !== true) {
       sendActivateTab(id);
-      dispatchActivateTab(dispatch)(id);
+      activateTab({dispatch})(null, {tabId: id});
     }
   };
   const [draggedId, setDraggedId] = useState(id);
@@ -180,11 +111,11 @@ const Tab = ({dispatch, numberOfTabs, idx, id, active, offsetX = 0, title, url,
     if (isInVisibleArea(event) && event.type === 'drag') {
       currentOffsetX = (event.clientX - originX) + (width * (idx - newIdx));
     }
-    dispatch({type: ACTIONS.MOVE_TAB, payload: {
+    moveTab({dispatch})({
       id: draggedId,
       idx: isInVisibleArea(event) ? newIdx : idx,
       offsetX: currentOffsetX
-    }});
+    });
   };
   const props = {
     'data-tab-id': id,
@@ -242,30 +173,34 @@ const ChromeTabs = ({dispatch, state: {tabs}}) => {
   `;
 };
 
-const Menu = () => html`
- <div class="menu">
-     <button
-        class="menu__button button"
-        onClick=${openMenu}
-      >
-        <${Icon} icon='fas fa-bars'></${Icon}>
-     </button>
- </div>
-`;
+const Menu = ({state: {newVersionAvailable}}) => {
+  const iconClass = `fas ${newVersionAvailable ? 'fa-arrow-alt-circle-up' : 'fa-bars'}`;
+  return html`
+   <div class="menu">
+       <button
+          class="menu__button button"
+          onClick=${openMenu}
+          title=${newVersionAvailable ? 'New ElectronIM version is available' : ''}
+        >
+          <${Icon} icon=${iconClass}></${Icon}>
+       </button>
+   </div>
+  `;
+};
 
 const TabContainer = () => {
   const [state, dispatch] = useReducer(reducer, initialState);
   useLayoutEffect(() => {
-    ipcRenderer.on(APP_EVENTS.addTabs, dispatchAddTabs(dispatch));
-    ipcRenderer.on(APP_EVENTS.activateTabInContainer,
-      (_event, {tabId}) => dispatchActivateTab(dispatch)(tabId));
-    ipcRenderer.on(APP_EVENTS.setTabTitle, dispatchSetTabTitle(dispatch));
-    ipcRenderer.on(APP_EVENTS.setTabFavicon, dispatchSetTabFavicon(dispatch));
+    ipcRenderer.on(APP_EVENTS.addTabs, addTabs({dispatch}));
+    ipcRenderer.on(APP_EVENTS.activateTabInContainer, activateTab({dispatch}));
+    ipcRenderer.on(APP_EVENTS.electronimNewVersionAvailable, setNewVersionAvailable({dispatch}));
+    ipcRenderer.on(APP_EVENTS.setTabFavicon, setTabFavicon({dispatch}));
+    ipcRenderer.on(APP_EVENTS.setTabTitle, setTabTitle({dispatch}));
     sendTabsReady();
   }, []);
   return html`
     <${ChromeTabs} state=${state} dispatch=${dispatch}/>
-    <${Menu}/>
+    <${Menu} state=${state}/>
   `;
 };
 
diff --git a/src/chrome-tabs/chrome-tabs.reducer.browser.mjs b/src/chrome-tabs/chrome-tabs.reducer.browser.mjs
new file mode 100644
index 0000000..4f3b7ed
--- /dev/null
+++ b/src/chrome-tabs/chrome-tabs.reducer.browser.mjs
@@ -0,0 +1,99 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+/* eslint-disable no-undef */
+import {APP_EVENTS} from '../components/index.mjs';
+export const sendActivateTab = id => ipcRenderer.send(APP_EVENTS.activateTab, {id});
+const sendReorderTabs = tabs =>
+  ipcRenderer.send(APP_EVENTS.tabReorder, {tabIds: tabs.map(({id}) => id)});
+
+export const initialState = {
+  tabs: [],
+  newVersionAvailable: false
+};
+
+const ACTIONS = {
+  ACTIVATE_TAB: 'ACTIVATE_TAB',
+  MOVE_TAB: 'MOVE_TAB',
+  SET_NEW_VERSION_AVAILABLE: 'SET_NEW_VERSION_AVAILABLE',
+  SET_TAB_PROPERTY: 'SET_TAB_PROPERTY',
+  SET_TABS: 'SET_TABS'
+};
+
+export const reducer = (state, action) => {
+  switch (action.type) {
+    case ACTIONS.ACTIVATE_TAB: {
+      return {...state,
+        tabs: state.tabs.map(tab => ({...tab, active: tab.id === action.payload}))
+      };
+    }
+    case ACTIONS.MOVE_TAB: {
+      const {id, idx, offsetX} = action.payload;
+      const fromIdx = state.tabs.findIndex(t => t.id === id);
+      const ret = {...state, tabs: [...state.tabs]};
+      ret.tabs.forEach(t => (t.offsetX = 0));
+      const tab = ret.tabs[fromIdx];
+      tab.offsetX = offsetX;
+      ret.tabs.splice(fromIdx, 1);
+      ret.tabs.splice(idx, 0, tab);
+      sendReorderTabs(ret.tabs);
+      return ret;
+    }
+    case ACTIONS.SET_NEW_VERSION_AVAILABLE: {
+      return {...state, newVersionAvailable: action.payload};
+    }
+    case ACTIONS.SET_TAB_PROPERTY: {
+      const {id, property, value} = action.payload;
+      return {...state, tabs: state.tabs.map(tab => {
+        if (tab.id === id) {
+          const ret = {...tab};
+          ret[property] = value;
+          return ret;
+        }
+        return tab;
+      })};
+    }
+    case ACTIONS.SET_TABS: {
+      return {...state, tabs: [...action.payload]};
+    }
+    default: return state;
+  }
+};
+
+// Action creators
+export const activateTab = ({dispatch}) => (_event, {tabId}) =>
+  dispatch({type: ACTIONS.ACTIVATE_TAB, payload: tabId});
+export const addTabs = ({dispatch}) => (_event, tabs) => {
+  dispatch({type: ACTIONS.SET_TABS, payload: tabs});
+  const activeTabMeta = tabs.find(({active}) => active === true);
+  if (tabs.length > 0 && activeTabMeta) {
+    sendActivateTab(activeTabMeta.id);
+  }
+};
+export const moveTab = ({dispatch}) => ({id, idx, offsetX}) =>
+  dispatch({type: ACTIONS.MOVE_TAB, payload: {id, idx, offsetX}});
+export const setNewVersionAvailable = ({dispatch}) => (_event, newVersionAvailable) => {
+  dispatch({type: ACTIONS.SET_NEW_VERSION_AVAILABLE, payload: newVersionAvailable});
+};
+export const setTabFavicon = ({dispatch}) => (_event, {id, favicon}) => {
+  dispatch({type: ACTIONS.SET_TAB_PROPERTY, payload: {
+    id, property: 'favicon', value: favicon
+  }});
+};
+export const setTabTitle = ({dispatch}) => (_event, {id, title}) => {
+  dispatch({type: ACTIONS.SET_TAB_PROPERTY, payload: {
+    id, property: 'title', value: title
+  }});
+};
diff --git a/src/chrome-tabs/index.js b/src/chrome-tabs/index.js
index b06c2c5..4e39e1d 100644
--- a/src/chrome-tabs/index.js
+++ b/src/chrome-tabs/index.js
@@ -15,6 +15,8 @@
  */
 const {BrowserView, BrowserWindow, Menu, MenuItem} = require('electron');
 const path = require('path');
+const {APP_EVENTS} = require('../constants');
+const {getLatestRelease} = require('./check-for-updates');
 const {openHelpDialog} = require('../help');
 const {openSettingsDialog} = require('../settings');
 
@@ -28,6 +30,15 @@ const webPreferences = {
   partition: 'persist:electronim'
 };
 
+const checkForUpdates = webContents => {
+  getLatestRelease()
+    .then(release => {
+      webContents.send(APP_EVENTS.electronimNewVersionAvailable, !release.matchesCurrent);
+    })
+    // eslint-disable-next-line no-console
+    .catch(e => console.debug('Error checking for updates', e));
+};
+
 const handleContextMenu = (event, params) => {
   const webContents = event.sender;
   const mainWindow = BrowserWindow.fromWebContents(webContents);
@@ -53,6 +64,8 @@ const newTabContainer = () => {
   tabContainer.webContents.loadURL(`file://${__dirname}/index.html`,
     {extraHeaders: 'pragma: no-cache\nCache-control: no-cache'});
   tabContainer.webContents.on('context-menu', handleContextMenu);
+  checkForUpdates(tabContainer.webContents);
+  setInterval(() => checkForUpdates(tabContainer.webContents), 1000 * 60 * 30).unref();
   return tabContainer;
 };
 
diff --git a/src/constants/index.js b/src/constants/index.js
index fe725fc..8b90615 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -36,6 +36,7 @@ const APP_EVENTS = {
   dictionaryGetAvailableNative: 'dictionaryGetAvailableNative',
   dictionaryGetEnabled: 'dictionaryGetEnabled',
   dictionaryGetMisspelled: 'dictionaryGetMisspelled',
+  electronimNewVersionAvailable: 'electronimNewVersionAvailable',
   helpOpenDialog: 'helpOpenDialog',
   notificationClick: 'notificationClick',
   reload: 'reload',


Test patch:
diff --git a/src/__tests__/electron.js b/src/__tests__/electron.js
index 08d035a..3436b55 100644
--- a/src/__tests__/electron.js
+++ b/src/__tests__/electron.js
@@ -51,12 +51,18 @@ const mockBrowserWindowInstance = () => {
 
 const mockElectronInstance = ({...overriddenProps} = {}) => {
   const browserViewInstance = mockBrowserWindowInstance();
+  const browserWindowInstance = mockBrowserWindowInstance();
   return {
     BrowserView: jest.fn(() => browserViewInstance),
     browserViewInstance,
+    BrowserWindow: jest.fn(() => browserWindowInstance),
+    browserWindowInstance,
     Menu: jest.fn(),
     MenuItem: jest.fn(),
-    app: {},
+    app: {
+      getPath: jest.fn(),
+      setPath: jest.fn()
+    },
     session: {
       fromPartition: jest.fn(() => ({
         userAgentInterceptor: true
diff --git a/src/__tests__/setup-jest.js b/src/__tests__/setup-jest.js
new file mode 100644
index 0000000..981918b
--- /dev/null
+++ b/src/__tests__/setup-jest.js
@@ -0,0 +1,18 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+afterEach(() => {
+  jest.useRealTimers();
+});
diff --git a/src/chrome-tabs/__tests__/check-for-updates.test.js b/src/chrome-tabs/__tests__/check-for-updates.test.js
new file mode 100644
index 0000000..c209154
--- /dev/null
+++ b/src/chrome-tabs/__tests__/check-for-updates.test.js
@@ -0,0 +1,82 @@
+/**
+ * @jest-environment node
+ */
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+describe('Check For Updates module test suite', () => {
+  beforeEach(() => {
+    jest.resetModules();
+  });
+  describe('getLatestRelease', () => {
+    describe('with real call', () => {
+      test('retrieves the latest released version from GitHub', async () => {
+        // When
+        const {version} = await require('../check-for-updates').getLatestRelease();
+        // Then
+        expect(version).toMatch(/^\d+\.\d+\.\d+$/);
+      });
+    });
+    describe('with mocked axios', () => {
+      let axios;
+      let getLatestRelease;
+      beforeEach(() => {
+        jest.mock('axios');
+        axios = require('axios');
+        getLatestRelease = require('../check-for-updates').getLatestRelease;
+      });
+      test('with unexpected status code throws error', async () => {
+        axios.get.mockImplementationOnce(async () => ({status: 200}));
+        await expect(getLatestRelease).rejects.toThrow('Unexpected response from GitHub');
+      });
+      test.each([
+        ['v1.33.7', '1.33.7'],
+        ['1.33.7', '1.33.7'],
+        ['v1.33.7-rc.1', '1.33.7-rc.1'],
+        ['1.33.7-beta.1', '1.33.7-beta.1']
+      ])('version: transforms %s tag_name to %s', async (tag_name, expected) => {
+        // Given
+        axios.get.mockImplementationOnce(async () => ({
+          status: 302,
+          headers: {
+            location: `https://github.com/manusa/electronim/releases/tag/${tag_name}`
+          }
+        }));
+        // When
+        const {version} = await getLatestRelease();
+        // Then
+        expect(version).toBe(expected);
+      });
+      test.each([
+        ['v1.33.7', false],
+        ['1.33.7', false],
+        ['v0.0.0', true],
+        ['0.0.0', true]
+      ])('matchesCurrent: compares %s with 0.0.0', async (tag_name, expected) => {
+        // Given
+        axios.get.mockImplementationOnce(async () => ({
+          status: 302,
+          headers: {
+            location: `https://github.com/manusa/electronim/releases/tag/${tag_name}`
+          }
+        }));
+        // When
+        const {matchesCurrent} = await getLatestRelease();
+        // Then
+        expect(matchesCurrent).toBe(expected);
+      });
+    });
+  });
+});
diff --git a/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs b/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs
index 8cfe82d..ff8a3ce 100644
--- a/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs
+++ b/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs
@@ -73,18 +73,20 @@ describe('ChromeTabs in Browser test suite', () => {
       await waitFor(() =>
         expect($chromeTabs.querySelector('.chrome-tab[data-tab-id="313373"]').hasAttribute('active')).toBe(true));
     });
-    test('setTabTitle, should change title of specified tab', async () => {
-      // Given
-      mockIpcRenderer.events.addTabs({}, tabs);
-      // When
-      mockIpcRenderer.events.setTabTitle({}, {id: 313373, title: 'replaced'});
-      // Then
-      await waitFor(() => expect(
-        $chromeTabs.querySelector('.chrome-tab[data-tab-id="313373"] .chrome-tab-title').innerHTML)
-        .toBe('replaced'));
-      expect(
-        $chromeTabs.querySelector('.chrome-tab[data-tab-id="1337"] .chrome-tab-title').innerHTML)
-        .toBe('https://1337.com');
+    describe('electronimNewVersionAvailable is true', () => {
+      beforeEach(() => {
+        mockIpcRenderer.events.electronimNewVersionAvailable({}, true);
+      });
+      test('Main button shows arrow up', async () => {
+        await waitFor(() =>
+          expect(document.querySelector('.menu__button i').getAttribute('class'))
+            .toBe('fas fa-arrow-alt-circle-up'));
+      });
+      test('Main button has tooltip informing about new updates', async () => {
+        await waitFor(() =>
+          expect(document.querySelector('.menu__button').getAttribute('title'))
+            .toBe('New ElectronIM version is available'));
+      });
     });
     test('setTabFavicon, should change favicon of specified tab', async () => {
       // Given
@@ -99,6 +101,19 @@ describe('ChromeTabs in Browser test suite', () => {
         $chromeTabs.querySelector('.chrome-tab[data-tab-id="13373"] .chrome-tab-favicon').style.backgroundImage)
         .toBe('url(https://13373.png)');
     });
+    test('setTabTitle, should change title of specified tab', async () => {
+      // Given
+      mockIpcRenderer.events.addTabs({}, tabs);
+      // When
+      mockIpcRenderer.events.setTabTitle({}, {id: 313373, title: 'replaced'});
+      // Then
+      await waitFor(() => expect(
+        $chromeTabs.querySelector('.chrome-tab[data-tab-id="313373"] .chrome-tab-title').innerHTML)
+        .toBe('replaced'));
+      expect(
+        $chromeTabs.querySelector('.chrome-tab[data-tab-id="1337"] .chrome-tab-title').innerHTML)
+        .toBe('https://1337.com');
+    });
   });
   describe('Tab events', () => {
     let tabs;
@@ -190,6 +205,10 @@ describe('ChromeTabs in Browser test suite', () => {
     });
   });
   describe('Main button events', () => {
+    test('shows 3 bars when no updates available', () => {
+      const buttonClass = document.querySelector('.menu__button i').getAttribute('class');
+      expect(buttonClass).toBe('fas fa-bars');
+    });
     test('menuButton click, should dispatch APP_EVENTS.appMenuOpen', () => {
       // When
       fireEvent.click(document.querySelector('.menu__button'));
diff --git a/src/chrome-tabs/__tests__/index.test.js b/src/chrome-tabs/__tests__/index.test.js
index 8b3cd68..7e11e3b 100644
--- a/src/chrome-tabs/__tests__/index.test.js
+++ b/src/chrome-tabs/__tests__/index.test.js
@@ -1,3 +1,6 @@
+/**
+ * @jest-environment node
+ */
 /*
    Copyright 2022 Marc Nuri San Felix
 
@@ -14,24 +17,19 @@
    limitations under the License.
  */
 describe('Chrome Tabs Module module test suite', () => {
-  let mockBrowserView;
-  let mainWindow;
   let chromeTabs;
   beforeEach(() => {
     jest.resetModules();
-    mockBrowserView = require('../../__tests__').mockBrowserWindowInstance();
-    jest.mock('electron', () => ({
-      BrowserView: jest.fn(() => mockBrowserView)
+    jest.mock('../check-for-updates', () => ({
+      getLatestRelease: () => Promise.resolve({})
     }));
-    mainWindow = {
-      addBrowserView: jest.fn()
-    };
+    jest.mock('electron', () => require('../../__tests__').mockElectronInstance());
     chromeTabs = require('../');
   });
   describe('newTabContainer', () => {
     test('webPreferences is sandboxed and has no node integration', () => {
       // When
-      chromeTabs.newTabContainer(mainWindow);
+      chromeTabs.newTabContainer();
       // Then
       const BrowserView = require('electron').BrowserView;
       expect(BrowserView).toHaveBeenCalledTimes(1);
@@ -39,5 +37,29 @@ describe('Chrome Tabs Module module test suite', () => {
         webPreferences: expect.objectContaining({sandbox: true, nodeIntegration: false})
       });
     });
+    test('checks for updates', async () => {
+      // Given
+      const {browserViewInstance} = require('electron');
+      let resolveSend;
+      const isSent = new Promise(resolve => {
+        resolveSend = resolve;
+      });
+      browserViewInstance.webContents.send = jest.fn(() => resolveSend(true));
+      // When
+      chromeTabs.newTabContainer();
+      // Then
+      await expect(isSent).resolves.toBe(true);
+      expect(browserViewInstance.webContents.send).toHaveBeenCalledWith('electronimNewVersionAvailable', true);
+    });
+    test('sets interval to check for updates with unref', () => {
+      // Given
+      const unref = jest.fn();
+      global.setInterval = jest.fn(() => ({unref}));
+      // When
+      chromeTabs.newTabContainer();
+      // Then
+      expect(setInterval).toHaveBeenCalled();
+      expect(unref).toHaveBeenCalled();
+    });
   });
 });
diff --git a/src/main/__tests__/index.test.js b/src/main/__tests__/index.test.js
index f35d9a6..6b47038 100644
--- a/src/main/__tests__/index.test.js
+++ b/src/main/__tests__/index.test.js
@@ -1,3 +1,6 @@
+/**
+ * @jest-environment node
+ */
 /*
    Copyright 2019 Marc Nuri San Felix
 
@@ -14,9 +17,10 @@
    limitations under the License.
  */
 describe('Main module test suite', () => {
-  let mockBrowserWindow;
   let mockNotification;
-  let mockApp;
+  let electron;
+  let mockBrowserView;
+  let mockBrowserWindow;
   let mockDesktopCapturer;
   let mockIpc;
   let mockNativeTheme;
@@ -29,13 +33,8 @@ describe('Main module test suite', () => {
   let main;
   beforeEach(() => {
     jest.resetModules();
-    jest.useFakeTimers();
-    mockBrowserWindow = require('../../__tests__').mockBrowserWindowInstance();
+    jest.useFakeTimers({doNotFake: ['setInterval']});
     mockNotification = jest.fn();
-    mockApp = {
-      getPath: jest.fn(),
-      setPath: jest.fn()
-    };
     mockDesktopCapturer = {};
     mockIpc = {
       handle: jest.fn(),
@@ -46,15 +45,15 @@ describe('Main module test suite', () => {
     };
     mockNativeTheme = {};
     mockSettings = {};
-    jest.mock('electron', () => ({
-      BrowserView: jest.fn(() => mockBrowserWindow),
-      BrowserWindow: jest.fn(() => mockBrowserWindow),
+    jest.mock('electron', () => require('../../__tests__').mockElectronInstance({
       Notification: jest.fn(() => mockNotification),
-      app: mockApp,
       desktopCapturer: mockDesktopCapturer,
       ipcMain: mockIpc,
       nativeTheme: mockNativeTheme
     }));
+    electron = require('electron');
+    mockBrowserView = electron.browserViewInstance;
+    mockBrowserWindow = electron.browserWindowInstance;
     appMenuModule = require('../../app-menu');
     jest.spyOn(appMenuModule, 'newAppMenu');
     settingsModule = require('../../settings');
@@ -72,11 +71,15 @@ describe('Main module test suite', () => {
     }}));
     main = require('../');
   });
+  afterEach(() => {
+    jest.useRealTimers();
+  });
   describe('init - environment preparation', () => {
     describe('theme', () => {
       test('fallbacks to system', () => {
         // When
         main.init();
+        jest.runAllTimers();
         // Then
         expect(mockNativeTheme.themeSource).toBe('system');
       });
@@ -85,6 +88,7 @@ describe('Main module test suite', () => {
         mockSettings.theme = 'dark';
         // When
         main.init();
+        jest.runAllTimers();
         // Then
         expect(mockNativeTheme.themeSource).toBe('dark');
       });
@@ -93,7 +97,7 @@ describe('Main module test suite', () => {
       // When
       main.init();
       // Then
-      expect(mockApp.userAgentFallback).toBe('UserAgent String');
+      expect(electron.app.userAgentFallback).toBe('UserAgent String');
     });
     test('initBrowserVersions, throws error, should be set to defaultUserAgent and show Notification', () => {
       // Given
@@ -102,15 +106,15 @@ describe('Main module test suite', () => {
       // When
       main.init();
       // Then
-      expect(mockApp.userAgentFallback).toBe('UserAgent String');
+      expect(electron.app.userAgentFallback).toBe('UserAgent String');
       expect(mockNotification.show).toHaveBeenCalledTimes(1);
     });
     test('fixUserDataLocation, should set a location in lower-case (Electron <14 compatible)', () => {
       // Given
-      mockApp.getPath.mockImplementation(() => 'ImMixed-Case/WithSome\\Separator$');
+      electron.app.getPath.mockImplementation(() => 'ImMixed-Case/WithSome\\Separator$');
       // When
       main.init();
-      expect(mockApp.setPath).toHaveBeenCalledWith('userData', 'immixed-case/withsome\\separator$');
+      expect(electron.app.setPath).toHaveBeenCalledWith('userData', 'immixed-case/withsome\\separator$');
     });
     test('initDesktopCapturerHandler, should register desktopCapturer', () => {
       // Given
@@ -250,7 +254,7 @@ describe('Main module test suite', () => {
         // Then
         expect(tabManagerModule.addTabs).not.toHaveBeenCalled();
         expect(addTabsNested).not.toHaveBeenCalled();
-        expect(mockBrowserWindow.webContents.loadURL)
+        expect(mockBrowserView.webContents.loadURL)
           .toHaveBeenCalledWith(expect.stringMatching(/settings\/index.html$/));
       });
       test('Previous saved tabs in loaded settings, should add tabs to manager and activate them as they are added', () => {
@@ -311,8 +315,8 @@ describe('Main module test suite', () => {
         // When
         mockIpc.listeners.activateTab({}, {id: 'validId'});
         // Then
-        expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledBefore(mockBrowserWindow.setBounds);
-        expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledBefore(mockBrowserWindow.setBounds);
+        expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledBefore(mockBrowserView.setBounds);
+        expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledBefore(mockBrowserView.setBounds);
         expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledBefore(activeTab.setBounds);
         expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledBefore(activeTab.setBounds);
       });
@@ -337,7 +341,7 @@ describe('Main module test suite', () => {
       // When
       mockIpc.listeners.notificationClick({}, {tabId: 'validId'});
       // Then
-      expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('activateTabInContainer', {tabId: 'validId'});
+      expect(mockBrowserView.webContents.send).toHaveBeenCalledWith('activateTabInContainer', {tabId: 'validId'});
       expect(mockBrowserWindow.restore).toHaveBeenCalledTimes(1);
       expect(mockBrowserWindow.show).toHaveBeenCalledTimes(1);
       expect(tabManagerModule.getTab).toHaveBeenCalledWith('validId');
@@ -469,7 +473,7 @@ describe('Main module test suite', () => {
         expect(mockBrowserWindow.removeBrowserView).toHaveBeenCalledWith(settingsView);
         expect(tabManagerModule.removeAll).toHaveBeenCalledTimes(1);
         expect(settingsView.webContents.destroy).toHaveBeenCalledTimes(1);
-        expect(mockBrowserWindow.webContents.destroy).toHaveBeenCalledTimes(1);
+        expect(mockBrowserView.webContents.destroy).toHaveBeenCalledTimes(1);
       });
       test('should set saved theme', () => {
         // When
@@ -524,7 +528,7 @@ describe('Main module test suite', () => {
       // When
       mockIpc.listeners.settingsOpenDialog();
       // Then
-      expect(mockBrowserWindow.webContents.loadURL)
+      expect(mockBrowserView.webContents.loadURL)
         .toHaveBeenCalledWith(expect.stringMatching(/settings\/index.html$/));
     });
     test('helpOpenDialog, should open help dialog', () => {
@@ -533,7 +537,7 @@ describe('Main module test suite', () => {
       // When
       mockIpc.listeners.helpOpenDialog();
       // Then
-      expect(mockBrowserWindow.webContents.loadURL)
+      expect(mockBrowserView.webContents.loadURL)
         .toHaveBeenCalledWith(expect.stringMatching(/help\/index.html$/));
     });
   });