Repository: electron/fiddle (ts)

Issue:
Add menu item for About panel
There is code to [set up the about panel](https://github.com/electron/fiddle/blob/v0.22.0/src/main/about-panel.ts), but there doesn't seem to be any code to show it (i.e., `app.showAboutPanel()`). We should add a menu item to the bottom of the Help Menu, "About Electron Fiddle", that shows the about panel.

Pull request description:
feat: add About to Help menu
As @malept reported in #631, we have code to set up the About panel but never use it. This PR adds a Help menuitem to do so.

The previous code  had separate handling for different platforms -- setupAboutPanel() was a no-op on Windows, and then filled in vairous AboutPanelOptions based on platform. Since this setup is cheap, this PR just sets everything unconditionally and leaves it to Electron to decide what to use.

Doing it this way seems more straightforward, but I'm wondering what that `if (process.platform === 'win32') return` was for. It was introduced in https://github.com/electron/fiddle/commit/14279283d648ea73a1853fc972c453a9b40ee725.

Fixes #631.

Patch:
diff --git a/src/main/about-panel.ts b/src/main/about-panel.ts
index 3f1793f0..b37af133 100644
--- a/src/main/about-panel.ts
+++ b/src/main/about-panel.ts
@@ -1,4 +1,6 @@
-import { AboutPanelOptionsOptions, app } from 'electron';
+import * as fs from 'fs-extra';
+import * as path from 'path';
+import { app } from 'electron';
 
 /**
  * Sets Fiddle's About panel options on Linux and macOS
@@ -6,24 +8,17 @@ import { AboutPanelOptionsOptions, app } from 'electron';
  * @returns
  */
 export function setupAboutPanel(): void {
-  if (process.platform === 'win32') return;
+  const contribFile = path.join(__dirname, '../../../static/contributors.json');
+  const iconPath = path.resolve(__dirname, '../../../assets/icons/fiddle.png');
 
-  const options: AboutPanelOptionsOptions = {
+  app.setAboutPanelOptions({
     applicationName: 'Electron Fiddle',
     applicationVersion: app.getVersion(),
-    version: process.versions.electron,
+    authors: fs.readJSONSync(contribFile).map(({ name }) => name),
     copyright: '© Electron Authors',
-  };
-
-  switch (process.platform) {
-    case 'linux':
-      options.website = 'https://electronjs.org/fiddle';
-    case 'darwin':
-      options.credits =
-        'https://github.com/electron/fiddle/graphs/contributors';
-    default:
-    // fallthrough
-  }
-
-  app.setAboutPanelOptions(options);
+    credits: 'https://github.com/electron/fiddle/graphs/contributors',
+    iconPath,
+    version: process.versions.electron,
+    website: 'https://electronjs.org/fiddle',
+  });
 }
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 189a0ef5..6ce21cd8 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -31,7 +31,9 @@ function isSubmenu(
  * @returns {Array<Electron.MenuItemConstructorOptions>}
  */
 function getHelpItems(): Array<MenuItemConstructorOptions> {
-  return [
+  const items: MenuItemConstructorOptions[] = [];
+
+  items.push(
     {
       type: 'separator',
     },
@@ -76,7 +78,25 @@ function getHelpItems(): Array<MenuItemConstructorOptions> {
         shell.openExternal('https://github.com/electron/electron/issues');
       },
     },
-  ];
+  );
+
+  // on macOS, there's already the About Electron Fiddle menu item
+  // under the first submenu set by the electron-default-menu package
+  if (process.platform !== 'darwin') {
+    items.push(
+      {
+        type: 'separator',
+      },
+      {
+        label: 'About Electron Fiddle',
+        click() {
+          app.showAboutPanel();
+        },
+      },
+    );
+  }
+
+  return items;
 }
 
 /**
diff --git a/src/main/windows.ts b/src/main/windows.ts
index 3fd38a6a..843726fb 100644
--- a/src/main/windows.ts
+++ b/src/main/windows.ts
@@ -25,7 +25,6 @@ export function getMainWindowOptions(): Electron.BrowserWindowConstructorOptions
     webPreferences: {
       webviewTag: false,
       nodeIntegration: true,
-      enableRemoteModule: true,
       nodeIntegrationInWorker: true,
       contextIsolation: false,
       preload: path.join(__dirname, '..', 'preload', 'preload'),
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 5be8256a..6114e1b5 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -16,7 +16,6 @@ import { WEBCONTENTS_READY_FOR_IPC_SIGNAL } from '../ipc-events';
 import { updateEditorLayout } from '../utils/editor-layout';
 import { getEditorValue } from '../utils/editor-value';
 import { getPackageJson, PackageJsonOptions } from '../utils/get-package';
-import { getTitle } from '../utils/get-title';
 import { isEditorBackup } from '../utils/type-checks';
 import { EMPTY_EDITOR_CONTENT, SORTED_EDITORS } from './constants';
 import { FileManager } from './file-manager';
@@ -88,8 +87,6 @@ export class App {
     await this.setEditorValues(editorValues);
     this.state.isUnsaved = false;
 
-    document.title = getTitle(this.state);
-
     return true;
   }
 
@@ -188,6 +185,7 @@ export class App {
 
     this.setupResizeListener();
     this.setupThemeListeners();
+    this.setupTitleListeners();
 
     ipcRenderer.send(WEBCONTENTS_READY_FOR_IPC_SIGNAL);
 
@@ -269,6 +267,25 @@ export class App {
   public setupResizeListener(): void {
     window.addEventListener('resize', updateEditorLayout);
   }
+
+  /**
+   * Have document.title track state.title
+   */
+  public setupTitleListeners() {
+    // the observables used for the title usually change in a batch,
+    // so when setting document title, wait a tick to avoid flicker.
+    let titleIdle: any;
+    reaction(
+      () => this.state.title,
+      (title) => {
+        clearTimeout(titleIdle);
+        titleIdle = setTimeout(() => {
+          document.title = title;
+          titleIdle = null;
+        });
+      },
+    );
+  }
 }
 
 window.ElectronFiddle = window.ElectronFiddle || {};
diff --git a/src/renderer/components/chrome-mac.tsx b/src/renderer/components/chrome-mac.tsx
index 2b9d20cb..ff146a32 100644
--- a/src/renderer/components/chrome-mac.tsx
+++ b/src/renderer/components/chrome-mac.tsx
@@ -2,7 +2,6 @@ import { observer } from 'mobx-react';
 import * as React from 'react';
 import { IpcEvents } from '../../ipc-events';
 
-import { getTitle } from '../../utils/get-title';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
@@ -21,7 +20,7 @@ export class ChromeMac extends React.Component<ChromeMacProps> {
 
     return (
       <div className="chrome drag" onDoubleClick={this.handleDoubleClick}>
-        <small>{getTitle(this.props.appState)}</small>
+        <small>{this.props.appState.title}</small>
       </div>
     );
   }
diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx
index 7558ff52..30a442b3 100644
--- a/src/renderer/components/editors.tsx
+++ b/src/renderer/components/editors.tsx
@@ -17,7 +17,12 @@ import {
   MosaicWindowProps,
 } from 'react-mosaic-component';
 
-import { EditorId, MosaicId, PanelId } from '../../interfaces';
+import {
+  EditorId,
+  MosaicId,
+  PanelId,
+  SetFiddleOptions,
+} from '../../interfaces';
 import { IpcEvents } from '../../ipc-events';
 import { updateEditorLayout } from '../../utils/editor-layout';
 import { getFocusedEditor } from '../../utils/focused-editor';
@@ -101,12 +106,14 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
     ipcRendererManager.on(IpcEvents.FS_NEW_FIDDLE, async (_event) => {
       const { version } = this.props.appState;
       const fiddle = await getTemplate(version);
-      await window.ElectronFiddle.app.replaceFiddle(fiddle, {});
+      const options: SetFiddleOptions = { templateName: version };
+      await window.ElectronFiddle.app.replaceFiddle(fiddle, options);
     });
 
     ipcRendererManager.on(IpcEvents.FS_NEW_TEST, async (_event) => {
       const fiddle = await getTestTemplate();
-      await window.ElectronFiddle.app.replaceFiddle(fiddle, {});
+      const options: SetFiddleOptions = { templateName: 'Test' };
+      await window.ElectronFiddle.app.replaceFiddle(fiddle, options);
     });
 
     ipcRendererManager.on(
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index b79484d3..09cfac5a 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -16,18 +16,18 @@ import {
   OutputEntry,
   OutputOptions,
   RunnableVersion,
+  SetFiddleOptions,
   Version,
   VersionSource,
   VersionState,
 } from '../interfaces';
 import { IpcEvents } from '../ipc-events';
-import { arrayToStringMap } from '../utils/array-to-stringmap';
 import { EditorBackup, getEditorBackup } from '../utils/editor-backup';
 import {
   createMosaicArrangement,
   getVisibleMosaics,
 } from '../utils/editors-mosaic-arrangement';
-import { getName } from '../utils/get-title';
+import { getName } from '../utils/get-name';
 import { normalizeVersion } from '../utils/normalize-version';
 import { isEditorBackup, isEditorId, isPanelId } from '../utils/type-checks';
 import {
@@ -124,7 +124,7 @@ export class AppState {
   @observable public versions: Record<
     string,
     RunnableVersion
-  > = arrayToStringMap(knownVersions);
+  > = Object.fromEntries(knownVersions.map((ver) => [ver.version, ver]));
   @observable public output: Array<OutputEntry> = [];
   @observable public localPath: string | undefined;
   @observable public genericDialogOptions: GenericDialogOptions = {
@@ -298,6 +298,33 @@ export class AppState {
     ]);
   }
 
+  /**
+   * @returns {string} the title, e.g. appname, fiddle name, state
+   */
+  @computed get title(): string {
+    const { gistId, isUnsaved, localPath, templateName } = this;
+    const tokens = [];
+
+    if (localPath) {
+      tokens.push(localPath);
+    } else if (templateName) {
+      tokens.push(templateName);
+    } else if (gistId) {
+      tokens.push(`gist.github.com/${gistId}`);
+    }
+
+    if (isUnsaved) {
+      tokens.push('Unsaved');
+    }
+
+    if (tokens.length > 0) {
+      tokens.unshift('-');
+    }
+
+    tokens.unshift('Electron Fiddle');
+    return tokens.join(' ');
+  }
+
   /**
    * Returns the current RunnableVersion or the first
    * one that can be found.
@@ -347,9 +374,7 @@ export class AppState {
     this.isUpdatingElectronVersions = true;
 
     try {
-      const versions = await getUpdatedElectronVersions();
-      this.versions = arrayToStringMap(versions);
-      await this.updateDownloadedVersionState();
+      this.setVersions(await getUpdatedElectronVersions());
     } catch (error) {
       console.warn(`State: Could not update Electron versions`, error);
     }
@@ -357,6 +382,11 @@ export class AppState {
     this.isUpdatingElectronVersions = false;
   }
 
+  public async getName() {
+    this.name ||= await getName(this);
+    return this.name;
+  }
+
   @action public hideChannels(channels: Array<ElectronReleaseChannel>) {
     this.channelsToShow = this.channelsToShow.filter(
       (ch) => !channels.includes(ch),
@@ -371,14 +401,6 @@ export class AppState {
     this.channelsToShow = [...s.values()];
   }
 
-  @action public async getName() {
-    if (!this.name) {
-      this.name = await getName(this);
-    }
-
-    return this.name;
-  }
-
   @action public toggleConsole() {
     this.isConsoleShowing = !this.isConsoleShowing;
   }
@@ -457,8 +479,13 @@ export class AppState {
 
   @action public addLocalVersion(input: Version) {
     addLocalVersion(input);
+    this.setVersions(getElectronVersions());
+  }
 
-    this.versions = arrayToStringMap(getElectronVersions());
+  @action private setVersions(versions: RunnableVersion[]) {
+    this.versions = Object.fromEntries(
+      versions.map((ver) => [ver.version, ver]),
+    );
     this.updateDownloadedVersionState();
   }
 
@@ -497,8 +524,7 @@ export class AppState {
       updatedVersions[version].state = VersionState.unknown;
     }
 
-    this.versions = updatedVersions;
-    this.updateDownloadedVersionState();
+    this.setVersions(Object.values(updatedVersions));
   }
 
   /**
@@ -561,7 +587,8 @@ export class AppState {
     // Should we update the editor?
     if (await isContentUnchanged(EditorId.main, this.version)) {
       const editorValues = await getTemplate(version);
-      await window.ElectronFiddle.app.replaceFiddle(editorValues, {});
+      const options: SetFiddleOptions = { templateName: version };
+      await window.ElectronFiddle.app.replaceFiddle(editorValues, options);
     }
 
     this.version = version;
diff --git a/src/renderer/versions.ts b/src/renderer/versions.ts
index 1b687ecb..8279c99f 100644
--- a/src/renderer/versions.ts
+++ b/src/renderer/versions.ts
@@ -133,6 +133,11 @@ function saveVersions(key: VersionKeys, versions: Array<Version>) {
   window.localStorage.setItem(key, stringified);
 }
 
+function sanitizeVersion(ver: RunnableVersion): RunnableVersion {
+  ver.version = normalizeVersion(ver.version);
+  return ver;
+}
+
 /**
  * Return both known as well as local versions.
  *
@@ -155,7 +160,7 @@ export function getElectronVersions(): Array<RunnableVersion> {
     };
   });
 
-  return [...known, ...local];
+  return [...known, ...local].map(sanitizeVersion);
 }
 
 /**
diff --git a/src/utils/array-to-stringmap.ts b/src/utils/array-to-stringmap.ts
deleted file mode 100644
index f8f53e1b..00000000
--- a/src/utils/array-to-stringmap.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { RunnableVersion } from '../interfaces';
-import { normalizeVersion } from './normalize-version';
-
-/**
- * Takes an array of GitHub releases and returns a StringMap of
- * Electron releases.
- *
- * @param {Array<RunnableVersion>} input
- * @returns {Record<string, RunnableVersion>}
- */
-export function arrayToStringMap(
-  input: Array<RunnableVersion>,
-): Record<string, RunnableVersion> {
-  const output = {};
-
-  input.forEach((version) => {
-    const versionNumber = normalizeVersion(version.version);
-    output[versionNumber] = version;
-  });
-
-  return output;
-}
diff --git a/src/utils/get-name.ts b/src/utils/get-name.ts
new file mode 100644
index 00000000..b16a1456
--- /dev/null
+++ b/src/utils/get-name.ts
@@ -0,0 +1,17 @@
+import { AppState } from '../renderer/state';
+
+/**
+ * Returns a name for this project
+ *
+ * @param {AppState} appState
+ * @returns {Promise<string>}
+ */
+export async function getName(appState: AppState): Promise<string> {
+  if (appState.localPath) {
+    const path = await import('path');
+    return path.basename(appState.localPath);
+  } else {
+    const namor = await import('namor');
+    return namor.generate({ words: 3, numbers: 0 });
+  }
+}
diff --git a/src/utils/get-title.ts b/src/utils/get-title.ts
deleted file mode 100644
index 5b959347..00000000
--- a/src/utils/get-title.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { AppState } from '../renderer/state';
-
-/**
- * Returns the window title
- *
- * @param {AppState} appState
- * @returns {string}
- */
-export function getTitle(appState: AppState): string {
-  const { gistId, localPath } = appState;
-  const gist = gistId ? `gist.github.com/${appState.gistId}` : '';
-  const local = localPath ? localPath : '';
-  const separator = gist && local ? ' ' : '';
-  const unsaved = !gist && !local ? 'Unsaved' : '';
-
-  return `Electron Fiddle - ${gist}${separator}${local}${unsaved}`;
-}
-
-/**
- * Returns a name for this project
- *
- * @param {AppState} appState
- * @returns {Promise<string>}
- */
-export async function getName(appState: AppState): Promise<string> {
-  if (appState.localPath) {
-    const path = await import('path');
-    return path.basename(appState.localPath);
-  } else {
-    const namor = await import('namor');
-    return namor.generate({ words: 3, numbers: 0 });
-  }
-}
diff --git a/tsconfig.json b/tsconfig.json
index 8c132529..a91e3ecd 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,7 @@
     "preserveConstEnums": true,
     "sourceMap": true,
     "lib": [
-      "es2017",
+      "es2019",
       "dom",
       "WebWorker"
     ],
@@ -26,7 +26,7 @@
     "module": "commonjs",
     "moduleResolution": "node",
     "pretty": true,
-    "target": "es2017",
+    "target": "es2019",
     "jsx": "react",
     "typeRoots": [
       "./node_modules/@types"


Test patch:
diff --git a/tests/main/windows-spec.ts b/tests/main/windows-spec.ts
index 85136566..43ef6cae 100644
--- a/tests/main/windows-spec.ts
+++ b/tests/main/windows-spec.ts
@@ -37,7 +37,6 @@ describe('windows', () => {
       titleBarStyle: undefined,
       webPreferences: {
         webviewTag: false,
-        enableRemoteModule: true,
         nodeIntegration: true,
         nodeIntegrationInWorker: true,
         contextIsolation: false,
diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts
index 15e13285..a23733ed 100644
--- a/tests/mocks/electron-fiddle.ts
+++ b/tests/mocks/electron-fiddle.ts
@@ -1,12 +1,11 @@
 import { AppMock } from './app';
 import { EditorsMock } from './editors';
-import * as electron from './electron';
 
 export class ElectronFiddleMock {
   public app = new AppMock();
   public editors = new EditorsMock();
   public appPaths = {
-    userData: (electron as any).remote.app.getPath('userData'),
+    userData: '/fake/path',
     home: `~`,
   };
 }
diff --git a/tests/mocks/electron-versions.ts b/tests/mocks/electron-versions.ts
index 649861de..b86dee30 100644
--- a/tests/mocks/electron-versions.ts
+++ b/tests/mocks/electron-versions.ts
@@ -3,36 +3,33 @@ import {
   VersionSource,
   VersionState,
 } from '../../src/interfaces';
-import { arrayToStringMap } from '../../src/utils/array-to-stringmap';
 
-export const mockVersionsArray = [
-  {
+export const mockVersions: Record<string, RunnableVersion> = {
+  '2.0.3': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.3',
-    source: VersionSource.remote,
   },
-  {
+  '2.0.2': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.2',
-    source: VersionSource.remote,
   },
-  {
+  '2.0.1': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.1',
-    source: VersionSource.remote,
   },
-  {
+  '2.0.0': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.0',
-    source: VersionSource.remote,
   },
-  {
+  '1.8.7': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '1.8.7',
-    source: VersionSource.remote,
   },
-];
+};
 
-export const mockVersions: Record<string, RunnableVersion> = arrayToStringMap(
-  mockVersionsArray,
-);
+export const mockVersionsArray = Object.values(mockVersions);
diff --git a/tests/mocks/electron.ts b/tests/mocks/electron.ts
index a0281fad..7be97bea 100644
--- a/tests/mocks/electron.ts
+++ b/tests/mocks/electron.ts
@@ -209,23 +209,6 @@ const electronMock = {
     interceptHttpProtocol: jest.fn(),
     uninterceptProtocol: jest.fn(),
   },
-  remote: {
-    app,
-    session,
-    BrowserWindow: MockBrowserWindow,
-    getCurrentWindow: jest.fn(),
-    getGlobal: jest.fn(),
-    Menu: MockMenu,
-    MenuItem: MockMenuItem,
-    process: {
-      argv: [
-        '/Applications/Electron Fiddle.app/Contents/MacOS/electron-fiddle',
-      ],
-    },
-    shell,
-    require: jest.fn(),
-    systemPreferences,
-  },
   require: jest.fn(),
   screen: new Screen(),
   session,
@@ -238,6 +221,5 @@ electronMock.BrowserWindow.getAllWindows.mockReturnValue([]);
 electronMock.BrowserWindow.fromId.mockReturnValue(mainWindowStub);
 electronMock.BrowserWindow.fromWebContents.mockReturnValue(mainWindowStub);
 electronMock.BrowserWindow.getFocusedWindow.mockReturnValue(focusedWindowStub);
-electronMock.remote.getCurrentWindow.mockReturnValue(mainWindowStub);
 
 module.exports = electronMock;
diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts
index 947e2f03..3aa0f646 100644
--- a/tests/mocks/state.ts
+++ b/tests/mocks/state.ts
@@ -6,6 +6,7 @@ export class MockState {
   @observable public isConsoleShowing = true;
   @observable public isGenericDialogShowing = false;
   @observable public isUsingSystemTheme = true;
+  @observable public title = 'Electron Fiddle';
   @observable public output = [];
   @observable public versionsToShow = [];
 
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index 6f089e3d..376d8ac0 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -1,6 +1,7 @@
 import { App } from '../../src/renderer/app';
 import { AppState } from '../../src/renderer/state';
 import { EditorBackup } from '../../src/utils/editor-backup';
+import { waitFor } from '../../src/utils/wait-for';
 import { ElectronFiddleMock } from '../mocks/electron-fiddle';
 import { MockState } from '../mocks/state';
 import { EditorId } from '../../src/interfaces';
@@ -551,4 +552,21 @@ describe('App component', () => {
       });
     });
   });
+
+  describe('setupTitleListeners()', () => {
+    let app: App;
+
+    beforeEach(() => {
+      app = new App();
+      (app.state as Partial<AppState>) = new MockState();
+    });
+
+    it('updates the document title when state.title changes', async () => {
+      const title = 'Hello, World!';
+      app.setupTitleListeners();
+      (app.state.title as any) = title;
+      await waitFor(() => document.title?.length > 0);
+      expect(document.title).toMatch(title);
+    });
+  });
 });
diff --git a/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap b/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap
index fc0b5065..f0897fa3 100644
--- a/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap
@@ -6,7 +6,7 @@ exports[`Chrome-Mac component renders 1`] = `
   onDoubleClick={[Function]}
 >
   <small>
-    Electron Fiddle - Unsaved
+    Electron Fiddle
   </small>
 </div>
 `;
diff --git a/tests/renderer/components/chrome-mac-spec.tsx b/tests/renderer/components/chrome-mac-spec.tsx
index 14e11c89..80fd3f46 100644
--- a/tests/renderer/components/chrome-mac-spec.tsx
+++ b/tests/renderer/components/chrome-mac-spec.tsx
@@ -1,4 +1,5 @@
 import { mount, shallow } from 'enzyme';
+import { MockState } from '../../mocks/state';
 import * as React from 'react';
 import { IpcEvents } from '../../../src/ipc-events';
 
@@ -7,7 +8,7 @@ import { ipcRendererManager } from '../../../src/renderer/ipc';
 import { overridePlatform, resetPlatform } from '../../utils';
 
 describe('Chrome-Mac component', () => {
-  const store: any = {};
+  const store: any = new MockState();
 
   afterEach(() => resetPlatform());
 
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 585f6c20..3e15d524 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -31,7 +31,7 @@ import {
 } from '../../src/renderer/versions';
 import { createMosaicArrangement } from '../../src/utils/editors-mosaic-arrangement';
 import { waitFor } from '../../src/utils/wait-for';
-import { getName } from '../../src/utils/get-title';
+import { getName } from '../../src/utils/get-name';
 import { mockVersions, mockVersionsArray } from '../mocks/electron-versions';
 import { overridePlatform, resetPlatform } from '../utils';
 
@@ -42,8 +42,8 @@ jest.mock('../../src/renderer/content', () => ({
 jest.mock('../../src/renderer/binary', () => ({
   removeBinary: jest.fn(),
   setupBinary: jest.fn(),
-  getDownloadedVersions: jest.fn(),
-  getDownloadingVersions: jest.fn(),
+  getDownloadedVersions: jest.fn().mockResolvedValue([]),
+  getDownloadingVersions: jest.fn().mockReturnValue([]),
 }));
 jest.mock('../../src/renderer/fetch-types', () => ({
   updateEditorTypeDefinitions: jest.fn(),
@@ -65,17 +65,22 @@ jest.mock('../../src/renderer/versions', () => {
     getReleaseChannel,
   };
 });
-jest.mock('../../src/utils/get-title', () => ({
+jest.mock('../../src/utils/get-name', () => ({
   getName: jest.fn(),
 }));
 jest.mock('../../src/renderer/ipc');
 
 describe('AppState', () => {
-  let appState = new AppState();
+  let appState: AppState;
 
   beforeEach(() => {
+    (getDownloadedVersions as jest.Mock).mockResolvedValue([]);
+    (getDownloadingVersions as jest.Mock).mockReturnValue([]);
+    (getUpdatedElectronVersions as jest.Mock).mockResolvedValue(
+      mockVersionsArray,
+    );
+
     appState = new AppState();
-    appState.updateDownloadedVersionState = jest.fn();
     ipcRendererManager.removeAllListeners();
   });
 
@@ -436,13 +441,14 @@ describe('AppState', () => {
   });
 
   describe('hasVersion()', () => {
-    const UNKNOWN_VERSION = 'v999.99.99';
-    const KNOWN_VERSION = Object.keys(appState.versions).pop();
-
     it('returns false if state does not have that version', () => {
+      const UNKNOWN_VERSION = 'v999.99.99';
+
       expect(appState.hasVersion(UNKNOWN_VERSION)).toEqual(false);
     });
     it('returns true if state has that version', () => {
+      const KNOWN_VERSION = Object.keys(appState.versions).pop();
+
       expect(appState.hasVersion(KNOWN_VERSION!)).toBe(true);
     });
   });
@@ -524,22 +530,17 @@ describe('AppState', () => {
   });
 
   describe('updateDownloadedVersionState()', () => {
-    beforeEach(() => {
-      appState = new AppState();
-      ipcRendererManager.removeAllListeners();
-      (getDownloadingVersions as jest.Mock).mockReturnValue(['2.0.1']);
-      (getDownloadedVersions as jest.Mock).mockReturnValue(
-        Promise.resolve(['2.0.2']),
-      );
-    });
-
     it('downloads a version if necessary', async () => {
+      (getDownloadedVersions as jest.Mock).mockResolvedValue(['2.0.2']);
+
       await appState.updateDownloadedVersionState();
 
       expect(appState.versions['2.0.2'].state).toBe(VersionState.ready);
     });
 
     it('keeps downloading state intact', async () => {
+      (getDownloadingVersions as jest.Mock).mockReturnValue(['2.0.1']);
+
       await appState.updateDownloadedVersionState();
 
       expect(appState.versions['2.0.1'].state).toBe(VersionState.downloading);
@@ -717,4 +718,55 @@ describe('AppState', () => {
       expect(appState.acceleratorsToBlock).toEqual([]);
     });
   });
+
+  describe('title', () => {
+    const APPNAME = 'Electron Fiddle';
+
+    it('defaults to the appname', () => {
+      const expected = APPNAME;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('shows the name of local fiddles', () => {
+      const localPath = 'path/to/fiddle';
+      const expected = `${APPNAME} - ${localPath}`;
+      appState.localPath = localPath;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('shows the name of gist fiddles', () => {
+      const gistId = 'abcdef';
+      const expected = `${APPNAME} - gist.github.com/${gistId}`;
+      appState.gistId = gistId;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('prefers to display localPath', () => {
+      const gistId = 'abcdef';
+      const templateName = 'BrowserWindow';
+      const localPath = 'path/to/fiddle';
+      const expected = `${APPNAME} - ${localPath}`;
+      Object.assign(appState, { gistId, localPath, templateName });
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('shows the name of template fiddles', () => {
+      const templateName = 'BrowserWindow';
+      const expected = `${APPNAME} - ${templateName}`;
+      appState.templateName = templateName;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('flags unsaved fiddles', () => {
+      const expected = `${APPNAME} - Unsaved`;
+      appState.isUnsaved = true;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+  });
 });
diff --git a/tests/utils/array-to-stringmap-spec.ts b/tests/utils/array-to-stringmap-spec.ts
deleted file mode 100644
index 4907cebc..00000000
--- a/tests/utils/array-to-stringmap-spec.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Version } from '../../src/interfaces';
-import { arrayToStringMap } from '../../src/utils/array-to-stringmap';
-
-describe('array-to-stringmap', () => {
-  it('correctly turns an array of versions into a stringmap', () => {
-    const input: Array<Partial<Version>> = [
-      {
-        version: 'v1.0.0',
-      },
-      {
-        version: 'v2.0.0',
-      },
-      {
-        version: '3.0.0',
-      },
-    ];
-    const output = arrayToStringMap(input as any);
-
-    expect(output).toEqual({
-      '1.0.0': {
-        version: 'v1.0.0',
-      },
-      '2.0.0': {
-        version: 'v2.0.0',
-      },
-      '3.0.0': {
-        version: '3.0.0',
-      },
-    });
-  });
-});
diff --git a/tests/utils/get-name-spec.ts b/tests/utils/get-name-spec.ts
new file mode 100644
index 00000000..cdfc3a4b
--- /dev/null
+++ b/tests/utils/get-name-spec.ts
@@ -0,0 +1,15 @@
+import { getName } from '../../src/utils/get-name';
+
+describe('get-name', () => {
+  describe('getName()', () => {
+    it('returns a random name', async () => {
+      const result = await getName({} as any);
+      expect(result).toBeTruthy();
+    });
+
+    it('returns a name from the local path if saved', async () => {
+      const result = await getName({ localPath: 'a/b/myFiddle' } as any);
+      expect(result).toBe('myFiddle');
+    });
+  });
+});
diff --git a/tests/utils/get-title-spec.ts b/tests/utils/get-title-spec.ts
deleted file mode 100644
index a615448b..00000000
--- a/tests/utils/get-title-spec.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { getName, getTitle } from '../../src/utils/get-title';
-
-describe('get-title', () => {
-  describe('getTitle()', () => {
-    it('returns a solid default title', () => {
-      const result = getTitle({} as any);
-      expect(result).toBe('Electron Fiddle - Unsaved');
-    });
-
-    it('returns a title for a Gist', () => {
-      const result = getTitle({ gistId: '123' } as any);
-      expect(result).toBe('Electron Fiddle - gist.github.com/123');
-    });
-
-    it('returns a title for a local fiddle', () => {
-      const result = getTitle({ localPath: 'a/b/fiddle' } as any);
-      expect(result).toBe('Electron Fiddle - a/b/fiddle');
-    });
-
-    it('returns a title for a local & gist fiddle', () => {
-      const result = getTitle({
-        localPath: 'a/b/fiddle',
-        gistId: '123',
-      } as any);
-      expect(result).toBe('Electron Fiddle - gist.github.com/123 a/b/fiddle');
-    });
-  });
-
-  describe('getName()', () => {
-    it('returns a random name', async () => {
-      const result = await getName({} as any);
-      expect(result).toBeTruthy();
-    });
-
-    it('returns a name from the local path if saved', async () => {
-      const result = await getName({ localPath: 'a/b/myFiddle' } as any);
-      expect(result).toBe('myFiddle');
-    });
-  });
-});