Repository: electron/fiddle
Pull request #618 (2021-03-25 08:45:57)

feat: autobisect cli
Implementation and tests for the CLI of autobisect / test.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/38239916/badge)](https://coveralls.io/builds/38239916)

Coverage decreased (-0.05%) to 93.263% when pulling **b293adc8412ef79a7c26d809f4777c408434b72c on feat/autobisect-cli** into **34017cd4be99cf3f26ea2b644d0c9d16c5f266ed on master**.


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 292e5e60..2423ea9f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -69,6 +69,11 @@ yarn start
 yarn test
 ```
 
+Note that console calls, e.g. console.log(), are mocked out during tests
+to avoid noise in the test reports. If you want this verbosity, e.g. if
+you're debugging or writing new tests, it can be enabled by setting the
+environment variable `FIDDLE_VERBOSE_TESTS` when running.
+
 ## Release Process
 
 First, bump the version number and create a new `git` tag:
diff --git a/package.json b/package.json
index b945d2c9..64b83457 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
     "parcel:build": "node ./tools/parcel-build.js",
     "parcel:watch": "node ./tools/parcel-watch.js",
     "publish": "electron-forge publish",
-    "start": "rimraf ./dist && electron-forge start",
+    "start": "rimraf ./dist && electron-forge start --",
     "test": "jest --config=jest.json",
     "test:ci": "jest --config=jest.json --coverage --runInBand",
     "test:report": "jest --config=jest.json --json --bail=false --outputFile=report.json | true",
@@ -43,6 +43,7 @@
     "@octokit/rest": "^16.43.1",
     "@sentry/electron": "^2.3.0",
     "classnames": "^2.2.6",
+    "commander": "^7.1.0",
     "decomment": "^0.9.3",
     "decompress": "^4.0.1",
     "electron-default-menu": "^1.0.2",
@@ -80,6 +81,7 @@
     "@types/enzyme": "^3.10.8",
     "@types/fs-extra": "^9.0.7",
     "@types/jest": "^26.0.20",
+    "@types/json-stable-stringify": "^1.0.32",
     "@types/log-symbols": "^3.0.0",
     "@types/node": "^12.18.0",
     "@types/react": "^16.14.0",
@@ -101,6 +103,7 @@
     "fetch-mock-jest": "^1.5.1",
     "husky": "^5.1.1",
     "jest": "^26.6.3",
+    "json-stable-stringify": "^1.0.1",
     "less": "^4.1.1",
     "lint-staged": "^10.5.4",
     "log-symbols": "^4.0.0",
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 2c572c58..41d8ebdf 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -1,5 +1,3 @@
-export type WindowNames = 'main';
-
 export type Files = Map<string, string>;
 
 export type FileTransform = (files: Files) => Promise<Files>;
@@ -56,12 +54,36 @@ export interface RunnableVersion extends Version {
   downloadProgress?: number;
 }
 
+export const enum ElectronReleaseChannel {
+  stable = 'Stable',
+  beta = 'Beta',
+  nightly = 'Nightly',
+  unsupported = 'Unsupported',
+}
+
 export interface SetFiddleOptions {
   filePath?: string;
   templateName?: string;
   gistId?: string;
 }
 
+export interface SetupRequest {
+  fiddle?: SetFiddleOptions;
+  version?: string;
+  showChannels: ElectronReleaseChannel[];
+  hideChannels: ElectronReleaseChannel[];
+}
+
+export interface BisectRequest {
+  setup: SetupRequest;
+  goodVersion: string;
+  badVersion: string;
+}
+
+export interface TestRequest {
+  setup: SetupRequest;
+}
+
 export interface OutputEntry {
   text: string;
   timestamp: number;
diff --git a/src/ipc-events.ts b/src/ipc-events.ts
index 0932a403..60284cec 100644
--- a/src/ipc-events.ts
+++ b/src/ipc-events.ts
@@ -31,6 +31,10 @@ export enum IpcEvents {
   SELECT_ALL_IN_EDITOR = 'SELECT_ALL_IN_EDITOR',
   BLOCK_ACCELERATORS = 'BLOCK_ACCELERATORS',
   CLICK_TITLEBAR_MAC = 'CLICK_TITLEBAR_MAC',
+  TASK_BISECT = 'TASK_BISECT',
+  TASK_TEST = 'TASK_TEST',
+  TASK_DONE = 'TASK_DONE',
+  OUTPUT_ENTRY = 'OUTPUT_ENTRY',
 }
 
 export const ipcMainEvents = [
@@ -43,6 +47,8 @@ export const ipcMainEvents = [
   IpcEvents.CONFIRM_QUIT,
   IpcEvents.BLOCK_ACCELERATORS,
   IpcEvents.CLICK_TITLEBAR_MAC,
+  IpcEvents.OUTPUT_ENTRY,
+  IpcEvents.TASK_DONE,
 ];
 
 export const ipcRendererEvents = [
@@ -69,6 +75,8 @@ export const ipcRendererEvents = [
   IpcEvents.BEFORE_QUIT,
   IpcEvents.GET_APP_PATHS,
   IpcEvents.SELECT_ALL_IN_EDITOR,
+  IpcEvents.TASK_BISECT,
+  IpcEvents.TASK_TEST,
 ];
 
 export const WEBCONTENTS_READY_FOR_IPC_SIGNAL =
diff --git a/src/main/command-line.ts b/src/main/command-line.ts
new file mode 100644
index 00000000..de2a24c0
--- /dev/null
+++ b/src/main/command-line.ts
@@ -0,0 +1,126 @@
+import * as commander from 'commander';
+import * as fs from 'fs-extra';
+
+import { app } from 'electron';
+import {
+  ElectronReleaseChannel,
+  OutputEntry,
+  RunResult,
+  SetupRequest,
+} from '../interfaces';
+import { IpcEvents } from '../ipc-events';
+import { getGistId } from '../utils/gist';
+import { ipcMainManager } from './ipc';
+
+function getSetup(opts: commander.OptionValues): SetupRequest {
+  const config: SetupRequest = {
+    showChannels: [],
+    hideChannels: [],
+  };
+
+  const { fiddle, version, betas, nightlies } = opts;
+
+  if (fs.existsSync(fiddle)) {
+    config.fiddle = { filePath: fiddle };
+  } else {
+    const gistId = getGistId(fiddle);
+    if (gistId) {
+      config.fiddle = { gistId };
+    }
+  }
+  if (!config.fiddle) {
+    throw `Unrecognized Fiddle "${fiddle}"`;
+  }
+
+  if (version) {
+    config.version = version;
+  }
+
+  if (betas) {
+    config.showChannels.push(ElectronReleaseChannel.beta);
+  } else if (betas === false) {
+    config.hideChannels.push(ElectronReleaseChannel.beta);
+  }
+
+  if (nightlies) {
+    config.showChannels.push(ElectronReleaseChannel.nightly);
+  } else if (nightlies === false) {
+    config.hideChannels.push(ElectronReleaseChannel.nightly);
+  }
+
+  return config;
+}
+
+async function sendTask(type: IpcEvents, task: any) {
+  const onOutputEntry = (_: any, msg: OutputEntry) => {
+    console.log(
+      `[${new Date(msg.timestamp).toLocaleTimeString()}] ${msg.text}`,
+    );
+  };
+  const exitCodes = Object.freeze({
+    [RunResult.SUCCESS]: 0,
+    [RunResult.FAILURE]: 1,
+    [RunResult.INVALID]: 2,
+  });
+  const onTaskDone = (_: any, r: RunResult) => app.exit(exitCodes[r]);
+  ipcMainManager.on(IpcEvents.OUTPUT_ENTRY, onOutputEntry);
+  ipcMainManager.once(IpcEvents.TASK_DONE, onTaskDone);
+  ipcMainManager.send(type, [task]);
+}
+
+async function bisect(good: string, bad: string, opts: commander.OptionValues) {
+  sendTask(IpcEvents.TASK_BISECT, {
+    setup: getSetup(opts),
+    goodVersion: good,
+    badVersion: bad,
+  });
+}
+
+async function test(opts: commander.OptionValues) {
+  try {
+    sendTask(IpcEvents.TASK_TEST, {
+      setup: getSetup(opts),
+    });
+  } catch (err) {
+    console.error(err);
+  }
+}
+
+export async function processCommandLine(argv: string[]) {
+  const program = new commander.Command();
+
+  program
+    .command('bisect <goodVersion> <badVersion>')
+    .description('Find where regressions were introduced')
+    .option('--fiddle <dir|gist>', 'Open a fiddle', process.cwd())
+    .option('--nightlies', 'Include nightly releases')
+    .option('--no-nightlies', 'Omit nightly releases')
+    .option('--betas', 'Include beta releases')
+    .option('--no-betas', 'Omit beta releases')
+    .action(bisect);
+
+  program
+    .command('test')
+    .description('Test a fiddle')
+    .option('--version <version>', 'Use Electron version')
+    .option('--fiddle <dir|gist>', 'Open a fiddle', process.cwd())
+    .action(test);
+
+  program.addHelpText(
+    'after',
+    `
+
+Example calls:
+  $ electron-fiddle bisect 10.0.0 11.2.0 --fiddle /path/to/fiddle
+  $ electron-fiddle bisect 10.0.0 11.2.0 --fiddle /path/to/fiddle --betas --nightlies
+  $ electron-fiddle test --version 11.2.0 --fiddle /path/to/fiddle
+  $ electron-fiddle test --version 11.2.0 --fiddle 8c5fc0c6a5153d49b5a4a56d3ed9da8f
+  $ electron-fiddle test --version 11.2.0 --fiddle https://gist.github.com/ckerr/8c5fc0c6a5153d49b5a4a56d3ed9da8f/
+`,
+  );
+
+  // do nothing if argv holds no commands/options
+  if (argv.length > (process.defaultApp ? 2 : 1)) {
+    program.parse(argv, { from: 'electron' });
+  }
+}
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 9b94c633..270013ba 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -16,7 +16,7 @@ import { getOrCreateMainWindow } from './windows';
  * @class IpcManager
  * @extends {EventEmitter}
  */
-export class IpcMainManager extends EventEmitter {
+class IpcMainManager extends EventEmitter {
   public readyWebContents = new WeakSet<Electron.WebContents>();
   private messageQueue = new WeakMap<
     Electron.WebContents,
diff --git a/src/main/main.ts b/src/main/main.ts
index dd80cedc..38c95c83 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -9,6 +9,7 @@ import { setupAboutPanel } from './about-panel';
 import { setupDevTools } from './devtools';
 import { setupDialogs } from './dialogs';
 import { onFirstRunMaybe } from './first-run';
+import { processCommandLine } from './command-line';
 import { ipcMainManager } from './ipc';
 import { listenForProtocolHandler, setupProtocolHandler } from './protocol';
 import { shouldQuit } from './squirrel';
@@ -16,6 +17,8 @@ import { setupUpdates } from './update';
 import { getOrCreateMainWindow } from './windows';
 import { IpcMainEvent } from 'electron/main';
 
+let argv: string[] = [];
+
 /**
  * Handle the app's "ready" event. This is essentially
  * the method that takes care of booting the application.
@@ -38,6 +41,8 @@ export async function onReady() {
   setupDialogs();
   setupDevTools();
   setupTitleBarClickMac();
+
+  processCommandLine(argv);
 }
 
 /**
@@ -105,7 +110,9 @@ export function onWindowsAllClosed() {
  *
  * Exported for testing purposes.
  */
-export function main() {
+export function main(argv_in: string[]) {
+  argv = argv_in;
+
   // Handle creating/removing shortcuts on Windows when
   // installing/uninstalling.
   if (shouldQuit()) {
@@ -126,4 +133,7 @@ export function main() {
   app.on('activate', getOrCreateMainWindow);
 }
 
-main();
+// only call main() if this is the main module
+if (typeof module !== 'undefined' && !module.parent) {
+  main(process.argv);
+}
diff --git a/src/main/protocol.ts b/src/main/protocol.ts
index 01c985a1..fe8de429 100644
--- a/src/main/protocol.ts
+++ b/src/main/protocol.ts
@@ -68,7 +68,7 @@ const handlePotentialProtocolLaunch = (url: string) => {
   }
 };
 
-export const scanArgv = (argv: Array<string>) => {
+const scanArgv = (argv: Array<string>) => {
   const protocolArg = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
   if (protocolArg) {
     console.info('Found protocol arg in argv:', protocolArg);
@@ -76,7 +76,7 @@ export const scanArgv = (argv: Array<string>) => {
   }
 };
 
-export const scanNpmArgv = (argv: string) => {
+const scanNpmArgv = (argv: string) => {
   const parsedArgv = JSON.parse(argv);
   const { original: args } = parsedArgv;
   scanArgv(args);
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index 81bf9ccd..41244dd2 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -2,7 +2,7 @@
 import { IpcEvents } from '../ipc-events';
 import { ipcRendererManager } from '../renderer/ipc';
 
-export async function preload() {
+async function preload() {
   setupGlobalWindow();
   await setupPaths();
 }
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 3b6b39cd..5be8256a 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -18,10 +18,11 @@ 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 } from './constants';
+import { EMPTY_EDITOR_CONTENT, SORTED_EDITORS } from './constants';
 import { FileManager } from './file-manager';
 import { RemoteLoader } from './remote-loader';
 import { Runner } from './runner';
+import { TaskRunner } from './task-runner';
 import { appState } from './state';
 import { getTheme } from './themes';
 import { defaultDark, defaultLight } from './themes-defaults';
@@ -39,10 +40,13 @@ export class App {
   public fileManager = new FileManager(appState);
   public remoteLoader = new RemoteLoader(appState);
   public runner = new Runner(appState);
+  public readonly taskRunner: TaskRunner;
 
   constructor() {
     this.getEditorValues = this.getEditorValues.bind(this);
     this.setEditorValues = this.setEditorValues.bind(this);
+
+    this.taskRunner = new TaskRunner(this);
   }
 
   public async replaceFiddle(
@@ -64,33 +68,16 @@ export class App {
       }
     }
 
-    // display all editors that have content
-    const visibleEditors = [];
-
-    // ensure consistent sorting of mosaics
-    const sortedEditors = Object.keys(editorValues).sort((a, b) => {
-      const order: Array<string> = [
-        'main',
-        'renderer',
-        'html',
-        'preload',
-        'css',
-      ];
-
-      return order.indexOf(a) - order.indexOf(b);
-    });
-
-    for (const id of sortedEditors) {
-      const content = editorValues[id];
-
-      // if the gist content is empty or matches the empty file output, don't show it
-      if (
-        content.length > 0 &&
-        !Object.values(EMPTY_EDITOR_CONTENT).includes(content)
-      ) {
-        visibleEditors.push(id as EditorId);
-      }
-    }
+    // if the gist content is empty or matches the empty file output, don't show it
+    const EMPTIES = Object.values(EMPTY_EDITOR_CONTENT);
+    const shouldShowContent = (content?: string) =>
+      content && content.length > 0 && !EMPTIES.includes(content);
+
+    // sort and display all editors that have content
+    const visibleEditors: EditorId[] = Object.entries(editorValues)
+      .filter(([_id, content]) => shouldShowContent(content))
+      .map(([id]) => id as EditorId)
+      .sort((a, b) => SORTED_EDITORS.indexOf(a) - SORTED_EDITORS.indexOf(b));
 
     this.state.gistId = gistId || '';
     this.state.localPath = filePath;
@@ -239,6 +226,20 @@ export class App {
     }
   }
 
+  /**
+   * Opens a fiddle from the specified location.
+   *
+   * @param {SetFiddleOptions} the fiddle to open
+   */
+  public async openFiddle(fiddle: SetFiddleOptions) {
+    const { filePath, gistId } = fiddle;
+    if (filePath) {
+      await this.fileManager.openFiddle(filePath);
+    } else if (gistId) {
+      await this.remoteLoader.fetchGistAndLoad(gistId);
+    }
+  }
+
   /**
    * Loads theme CSS into the HTML document.
    *
@@ -271,7 +272,6 @@ export class App {
 }
 
 window.ElectronFiddle = window.ElectronFiddle || {};
-window.ElectronFiddle.contentChangeListeners =
-  window.ElectronFiddle.contentChangeListeners || [];
-window.ElectronFiddle.app = window.ElectronFiddle.app || new App();
+window.ElectronFiddle.contentChangeListeners ||= [];
+window.ElectronFiddle.app ||= new App();
 window.ElectronFiddle.app.setup();
diff --git a/src/renderer/components/chrome-mac.tsx b/src/renderer/components/chrome-mac.tsx
index 70237e0e..2b9d20cb 100644
--- a/src/renderer/components/chrome-mac.tsx
+++ b/src/renderer/components/chrome-mac.tsx
@@ -6,7 +6,7 @@ import { getTitle } from '../../utils/get-title';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
-export interface ChromeMacProps {
+interface ChromeMacProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx
index ac91ab57..9935d3f9 100644
--- a/src/renderer/components/commands-action-button.tsx
+++ b/src/renderer/components/commands-action-button.tsx
@@ -26,7 +26,7 @@ import { EMPTY_EDITOR_CONTENT } from '../constants';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
-export interface GistActionButtonProps {
+interface GistActionButtonProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/commands-address-bar.tsx b/src/renderer/components/commands-address-bar.tsx
index 452ae6d9..898be51e 100644
--- a/src/renderer/components/commands-address-bar.tsx
+++ b/src/renderer/components/commands-address-bar.tsx
@@ -10,11 +10,11 @@ import { idFromUrl, urlFromId } from '../../utils/gist';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
-export interface AddressBarProps {
+interface AddressBarProps {
   appState: AppState;
 }
 
-export interface AddressBarState {
+interface AddressBarState {
   value: string;
   loaders: {
     gist: any;
diff --git a/src/renderer/components/commands-editors.tsx b/src/renderer/components/commands-editors.tsx
index 3aa1b78f..b89e673f 100644
--- a/src/renderer/components/commands-editors.tsx
+++ b/src/renderer/components/commands-editors.tsx
@@ -14,11 +14,11 @@ import { getVisibleMosaics } from '../../utils/editors-mosaic-arrangement';
 import { AppState } from '../state';
 import { TITLE_MAP } from './editors';
 
-export interface EditorDropdownState {
+interface EditorDropdownState {
   value: string;
 }
 
-export interface EditorDropdownProps {
+interface EditorDropdownProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/commands-runner.tsx b/src/renderer/components/commands-runner.tsx
index dd0e1759..6b8bb608 100644
--- a/src/renderer/components/commands-runner.tsx
+++ b/src/renderer/components/commands-runner.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
 import { VersionState } from '../../interfaces';
 import { AppState } from '../state';
 
-export interface RunnerProps {
+interface RunnerProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/commands-version-chooser.tsx b/src/renderer/components/commands-version-chooser.tsx
index 27d11f4c..4f7c049b 100644
--- a/src/renderer/components/commands-version-chooser.tsx
+++ b/src/renderer/components/commands-version-chooser.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
 import { AppState } from '../state';
 import { VersionSelect } from './version-select';
 
-export interface VersionChooserProps {
+interface VersionChooserProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/commands.tsx b/src/renderer/components/commands.tsx
index c0461c81..96aa6f08 100644
--- a/src/renderer/components/commands.tsx
+++ b/src/renderer/components/commands.tsx
@@ -10,7 +10,7 @@ import { GistActionButton } from './commands-action-button';
 import { Runner } from './commands-runner';
 import { VersionChooser } from './commands-version-chooser';
 
-export interface CommandsProps {
+interface CommandsProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/dialog-add-theme.tsx b/src/renderer/components/dialog-add-theme.tsx
index 4024709e..9a6032d5 100644
--- a/src/renderer/components/dialog-add-theme.tsx
+++ b/src/renderer/components/dialog-add-theme.tsx
@@ -11,11 +11,11 @@ import { GenericDialogType } from '../../../src/interfaces';
 import { AppState } from '../state';
 import { defaultDark, LoadedFiddleTheme } from '../themes-defaults';
 
-export interface AddThemeDialogProps {
+interface AddThemeDialogProps {
   appState: AppState;
 }
 
-export interface AddThemeDialogState {
+interface AddThemeDialogState {
   file?: File;
 }
 
diff --git a/src/renderer/components/dialog-add-version.tsx b/src/renderer/components/dialog-add-version.tsx
index 3f5776ae..755485df 100644
--- a/src/renderer/components/dialog-add-version.tsx
+++ b/src/renderer/components/dialog-add-version.tsx
@@ -18,11 +18,11 @@ import { getIsDownloaded } from '../binary';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
-export interface AddVersionDialogProps {
+interface AddVersionDialogProps {
   appState: AppState;
 }
 
-export interface AddVersionDialogState {
+interface AddVersionDialogState {
   isValidElectron: boolean;
   isValidVersion: boolean;
   folderPath?: string;
diff --git a/src/renderer/components/dialog-bisect.tsx b/src/renderer/components/dialog-bisect.tsx
index 12647cc4..89ab95ad 100644
--- a/src/renderer/components/dialog-bisect.tsx
+++ b/src/renderer/components/dialog-bisect.tsx
@@ -7,11 +7,11 @@ import { Bisector } from '../bisect';
 import { AppState } from '../state';
 import { VersionSelect } from './version-select';
 
-export interface BisectDialogProps {
+interface BisectDialogProps {
   appState: AppState;
 }
 
-export interface BisectDialogState {
+interface BisectDialogState {
   startIndex: number;
   endIndex: number;
   allVersions: Array<RunnableVersion>;
diff --git a/src/renderer/components/dialog-generic.tsx b/src/renderer/components/dialog-generic.tsx
index 0c0427a8..226c15c1 100644
--- a/src/renderer/components/dialog-generic.tsx
+++ b/src/renderer/components/dialog-generic.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
 import { GenericDialogType } from '../../../src/interfaces';
 import { AppState } from '../state';
 
-export interface GenericDialogProps {
+interface GenericDialogProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/dialog-token.tsx b/src/renderer/components/dialog-token.tsx
index 12575924..549dc9fe 100644
--- a/src/renderer/components/dialog-token.tsx
+++ b/src/renderer/components/dialog-token.tsx
@@ -6,11 +6,11 @@ import * as React from 'react';
 import { getOctokit } from '../../utils/octokit';
 import { AppState } from '../state';
 
-export interface TokenDialogProps {
+interface TokenDialogProps {
   appState: AppState;
 }
 
-export interface TokenDialogState {
+interface TokenDialogState {
   tokenInput: string;
   verifying: boolean;
   error: boolean;
diff --git a/src/renderer/components/dialogs.tsx b/src/renderer/components/dialogs.tsx
index 8062f845..dfb525b9 100644
--- a/src/renderer/components/dialogs.tsx
+++ b/src/renderer/components/dialogs.tsx
@@ -9,7 +9,7 @@ import { GenericDialog } from './dialog-generic';
 import { TokenDialog } from './dialog-token';
 import { Settings } from './settings';
 
-export interface DialogsProps {
+interface DialogsProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/editor.tsx b/src/renderer/components/editor.tsx
index 51eebda6..7238f005 100644
--- a/src/renderer/components/editor.tsx
+++ b/src/renderer/components/editor.tsx
@@ -8,7 +8,7 @@ import { EditorId } from '../../interfaces';
 import { getContent } from '../content';
 import { AppState } from '../state';
 
-export interface EditorProps {
+interface EditorProps {
   appState: AppState;
   monaco: typeof MonacoType;
   monacoOptions: MonacoType.editor.IEditorOptions;
diff --git a/src/renderer/components/editors-toolbar-button.tsx b/src/renderer/components/editors-toolbar-button.tsx
index a8720396..d0f9c415 100644
--- a/src/renderer/components/editors-toolbar-button.tsx
+++ b/src/renderer/components/editors-toolbar-button.tsx
@@ -10,12 +10,12 @@ import {
 import { DocsDemoPage, MosaicId } from '../../interfaces';
 import { AppState } from '../state';
 
-export interface ToolbarButtonProps {
+interface ToolbarButtonProps {
   appState: AppState;
   id: MosaicId;
 }
 
-export abstract class ToolbarButton extends React.PureComponent<ToolbarButtonProps> {
+abstract class ToolbarButton extends React.PureComponent<ToolbarButtonProps> {
   public static contextType = MosaicWindowContext;
   public context: MosaicWindowContext;
 
diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx
index 32c99f1e..7558ff52 100644
--- a/src/renderer/components/editors.tsx
+++ b/src/renderer/components/editors.tsx
@@ -52,11 +52,11 @@ export const TITLE_MAP: Record<MosaicId, string> = {
   [PanelId.docsDemo]: 'Docs & Demos',
 };
 
-export interface EditorsProps {
+interface EditorsProps {
   appState: AppState;
 }
 
-export interface EditorsState {
+interface EditorsState {
   monaco?: typeof MonacoType;
   isMounted?: boolean;
   monacoOptions: MonacoType.editor.IEditorOptions;
diff --git a/src/renderer/components/header.tsx b/src/renderer/components/header.tsx
index 14cae1c6..93101d12 100644
--- a/src/renderer/components/header.tsx
+++ b/src/renderer/components/header.tsx
@@ -5,7 +5,7 @@ import { ChromeMac } from './chrome-mac';
 import { Commands } from './commands';
 import { WelcomeTour } from './tour-welcome';
 
-export interface HeaderProps {
+interface HeaderProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/output-editors-wrapper.tsx b/src/renderer/components/output-editors-wrapper.tsx
index bfa0a0f1..34d4fd5a 100644
--- a/src/renderer/components/output-editors-wrapper.tsx
+++ b/src/renderer/components/output-editors-wrapper.tsx
@@ -5,11 +5,11 @@ import { AppState } from '../state';
 import { Editors } from './editors';
 import { Output } from './output';
 
-export interface WrapperProps {
+interface WrapperProps {
   appState: AppState;
 }
 
-export interface WrapperState {
+interface WrapperState {
   mosaicArrangement: MosaicNode<WrapperMosaicId>;
 }
 
diff --git a/src/renderer/components/output.tsx b/src/renderer/components/output.tsx
index 5ad4f6ab..87dbd00a 100644
--- a/src/renderer/components/output.tsx
+++ b/src/renderer/components/output.tsx
@@ -8,7 +8,7 @@ import { OutputEntry } from '../../interfaces';
 import { AppState } from '../state';
 import { WrapperMosaicId } from './output-editors-wrapper';
 
-export interface CommandsProps {
+interface CommandsProps {
   appState: AppState;
   // Used to keep testing conform
   renderTimestamp?: (ts: number) => string;
diff --git a/src/renderer/components/settings-credits.tsx b/src/renderer/components/settings-credits.tsx
index 37c3bc59..3c72c461 100644
--- a/src/renderer/components/settings-credits.tsx
+++ b/src/renderer/components/settings-credits.tsx
@@ -6,15 +6,15 @@ import * as React from 'react';
 
 import { AppState } from '../state';
 
-export interface CreditsSettingsProps {
+interface CreditsSettingsProps {
   appState: AppState;
 }
 
-export interface CreditsSettingsState {
+interface CreditsSettingsState {
   contributors: Array<Contributor>;
 }
 
-export interface Contributor {
+interface Contributor {
   url: string;
   api: string;
   login: string;
diff --git a/src/renderer/components/settings-electron.tsx b/src/renderer/components/settings-electron.tsx
index 1eaf63f8..167dce4a 100644
--- a/src/renderer/components/settings-electron.tsx
+++ b/src/renderer/components/settings-electron.tsx
@@ -13,17 +13,22 @@ import {
 import { observer } from 'mobx-react';
 import * as React from 'react';
 
-import { RunnableVersion, VersionSource, VersionState } from '../../interfaces';
+import {
+  ElectronReleaseChannel,
+  RunnableVersion,
+  VersionSource,
+  VersionState,
+} from '../../interfaces';
 import { normalizeVersion } from '../../utils/normalize-version';
 import { sortedElectronMap } from '../../utils/sorted-electron-map';
 import { AppState } from '../state';
-import { ElectronReleaseChannel, getReleaseChannel } from '../versions';
+import { getReleaseChannel } from '../versions';
 
-export interface ElectronSettingsProps {
+interface ElectronSettingsProps {
   appState: AppState;
 }
 
-export interface ElectronSettingsState {
+interface ElectronSettingsState {
   isDownloadingAll: boolean;
   isDeletingAll: boolean;
 }
@@ -85,9 +90,9 @@ export class ElectronSettings extends React.Component<
     const { appState } = this.props;
 
     if (!checked) {
-      appState.channelsToShow = appState.channelsToShow.filter((c) => c !== id);
+      appState.hideChannels([id as ElectronReleaseChannel]);
     } else {
-      appState.channelsToShow.push(id as ElectronReleaseChannel);
+      appState.showChannels([id as ElectronReleaseChannel]);
     }
   }
 
diff --git a/src/renderer/components/settings-execution.tsx b/src/renderer/components/settings-execution.tsx
index 67ff9420..f805cd4f 100644
--- a/src/renderer/components/settings-execution.tsx
+++ b/src/renderer/components/settings-execution.tsx
@@ -12,7 +12,7 @@ import * as React from 'react';
 import { IPackageManager } from '../npm';
 import { AppState } from '../state';
 
-export interface ExecutionSettingsProps {
+interface ExecutionSettingsProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/settings-general-appearance.tsx b/src/renderer/components/settings-general-appearance.tsx
index 12b1f524..4f029263 100644
--- a/src/renderer/components/settings-general-appearance.tsx
+++ b/src/renderer/components/settings-general-appearance.tsx
@@ -64,12 +64,12 @@ export const renderItem: ItemRenderer<LoadedFiddleTheme> = (
   );
 };
 
-export interface AppearanceSettingsProps {
+interface AppearanceSettingsProps {
   appState: AppState;
   toggleHasPopoverOpen: () => void;
 }
 
-export interface AppearanceSettingsState {
+interface AppearanceSettingsState {
   themes: Array<LoadedFiddleTheme>;
   selectedTheme?: LoadedFiddleTheme;
 }
diff --git a/src/renderer/components/settings-general-block-accelerators.tsx b/src/renderer/components/settings-general-block-accelerators.tsx
index 8f306e00..803ac851 100644
--- a/src/renderer/components/settings-general-block-accelerators.tsx
+++ b/src/renderer/components/settings-general-block-accelerators.tsx
@@ -5,7 +5,7 @@ import { BlockableAccelerator } from '../../interfaces';
 
 import { AppState } from '../state';
 
-export interface BlockAcceleratorsSettingsProps {
+interface BlockAcceleratorsSettingsProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/settings-general-console.tsx b/src/renderer/components/settings-general-console.tsx
index a68b3a36..dea4fdce 100644
--- a/src/renderer/components/settings-general-console.tsx
+++ b/src/renderer/components/settings-general-console.tsx
@@ -4,7 +4,7 @@ import * as React from 'react';
 
 import { AppState } from '../state';
 
-export interface ConsoleSettingsProps {
+interface ConsoleSettingsProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/settings-general-github.tsx b/src/renderer/components/settings-general-github.tsx
index 09c36c2b..aeb6955d 100644
--- a/src/renderer/components/settings-general-github.tsx
+++ b/src/renderer/components/settings-general-github.tsx
@@ -4,7 +4,7 @@ import * as React from 'react';
 
 import { AppState } from '../state';
 
-export interface GitHubSettingsProps {
+interface GitHubSettingsProps {
   appState: AppState;
 }
 
diff --git a/src/renderer/components/settings-general.tsx b/src/renderer/components/settings-general.tsx
index a6cf5781..3687e95f 100644
--- a/src/renderer/components/settings-general.tsx
+++ b/src/renderer/components/settings-general.tsx
@@ -8,7 +8,7 @@ import { BlockAcceleratorsSettings } from './settings-general-block-accelerators
 import { ConsoleSettings } from './settings-general-console';
 import { GitHubSettings } from './settings-general-github';
 
-export interface GeneralSettingsProps {
+interface GeneralSettingsProps {
   appState: AppState;
   toggleHasPopoverOpen: () => void;
 }
diff --git a/src/renderer/components/settings.tsx b/src/renderer/components/settings.tsx
index 06d35054..47e37424 100644
--- a/src/renderer/components/settings.tsx
+++ b/src/renderer/components/settings.tsx
@@ -22,11 +22,11 @@ const settingsSections = [
   SettingsSections.Credits,
 ];
 
-export interface SettingsProps {
+interface SettingsProps {
   appState: AppState;
 }
 
-export interface SettingsState {
+interface SettingsState {
   section: SettingsSections;
   hasPopoverOpen: boolean;
 }
diff --git a/src/renderer/components/tour-welcome.tsx b/src/renderer/components/tour-welcome.tsx
index d6b46c0b..dbc4da13 100644
--- a/src/renderer/components/tour-welcome.tsx
+++ b/src/renderer/components/tour-welcome.tsx
@@ -5,11 +5,11 @@ import * as React from 'react';
 import { AppState } from '../state';
 import { Tour, TourScriptStep, TourStepGetButtonParams } from './tour';
 
-export interface WelcomeTourProps {
+interface WelcomeTourProps {
   appState: AppState;
 }
 
-export interface WelcomeTourState {
+interface WelcomeTourState {
   isTourStarted: boolean;
 }
 
diff --git a/src/renderer/components/tour.tsx b/src/renderer/components/tour.tsx
index 19e59c84..54556929 100644
--- a/src/renderer/components/tour.tsx
+++ b/src/renderer/components/tour.tsx
@@ -11,12 +11,12 @@ export interface TourScriptStep {
   getButtons?: (handlers: TourStepGetButtonParams) => Array<JSX.Element>;
 }
 
-export interface TourProps {
+interface TourProps {
   tour: Set<TourScriptStep>;
   onStop: () => void;
 }
 
-export interface TourState {
+interface TourState {
   tour: IterableIterator<Array<TourScriptStep>>;
   step: TourScriptStep | null;
   i: number;
diff --git a/src/renderer/components/version-select.tsx b/src/renderer/components/version-select.tsx
index 7b056937..b74b1dff 100644
--- a/src/renderer/components/version-select.tsx
+++ b/src/renderer/components/version-select.tsx
@@ -98,11 +98,11 @@ export const renderItem: ItemRenderer<RunnableVersion> = (
   );
 };
 
-export interface VersionSelectState {
+interface VersionSelectState {
   value: string;
 }
 
-export interface VersionSelectProps {
+interface VersionSelectProps {
   appState: AppState;
   disabled?: boolean;
   currentVersion: RunnableVersion;
diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts
index e21488f8..fb5aa4e6 100644
--- a/src/renderer/constants.ts
+++ b/src/renderer/constants.ts
@@ -10,17 +10,25 @@ export const CONFIG_PATH = path.join(
   '.electron-fiddle',
 );
 
+export const SORTED_EDITORS = Object.freeze([
+  EditorId.main,
+  EditorId.renderer,
+  EditorId.html,
+  EditorId.preload,
+  EditorId.css,
+]);
+
 export const DEFAULT_MOSAIC_ARRANGEMENT: MosaicNode<MosaicId> = {
   direction: 'row',
   first: {
     direction: 'column',
-    first: EditorId.main,
-    second: EditorId.renderer,
+    first: SORTED_EDITORS[0],
+    second: SORTED_EDITORS[1],
   },
   second: {
     direction: 'column',
-    first: EditorId.preload,
-    second: EditorId.html,
+    first: SORTED_EDITORS[2],
+    second: SORTED_EDITORS[3],
   },
 };
 
diff --git a/src/renderer/ipc.ts b/src/renderer/ipc.ts
index 67c70c4b..ac3802f5 100644
--- a/src/renderer/ipc.ts
+++ b/src/renderer/ipc.ts
@@ -10,7 +10,7 @@ import { IpcEvents, ipcRendererEvents } from '../ipc-events';
  * @class IpcManager
  * @extends {EventEmitter}
  */
-export class IpcRendererManager extends EventEmitter {
+class IpcRendererManager extends EventEmitter {
   constructor() {
     super();
 
diff --git a/src/renderer/npm.ts b/src/renderer/npm.ts
index e71d4bae..49d9c58b 100644
--- a/src/renderer/npm.ts
+++ b/src/renderer/npm.ts
@@ -11,8 +11,8 @@ export interface PMOperationOptions {
   packageManager: IPackageManager;
 }
 
-export let isNpmInstalled: boolean | null = null;
-export let isYarnInstalled: boolean | null = null;
+let isNpmInstalled: boolean | null = null;
+let isYarnInstalled: boolean | null = null;
 
 /* add other modules to automatically ignore here */
 /* perhaps we can expose this to the settings module?*/
@@ -162,7 +162,7 @@ export function packageRun(
   return exec(dir, `${packageManager} run ${command}`);
 }
 
-export function decommentWithWorker(input: string): Promise<string> {
+function decommentWithWorker(input: string): Promise<string> {
   return new Promise((resolve, reject) => {
     const worker = new Worker('../utils/decomment.ts');
     worker.onmessage = function (event: MessageEvent<string>) {
diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts
index 78300f64..fbf81802 100644
--- a/src/renderer/remote-loader.ts
+++ b/src/renderer/remote-loader.ts
@@ -1,13 +1,17 @@
 import { Octokit } from '@octokit/rest';
 import { when } from 'mobx';
-import { EditorValues, GenericDialogType } from '../interfaces';
+import {
+  EditorValues,
+  ElectronReleaseChannel,
+  GenericDialogType,
+} from '../interfaces';
 import { FILENAME_KEYS } from '../shared-constants';
 import { getOctokit } from '../utils/octokit';
 import { sortedElectronMap } from '../utils/sorted-electron-map';
 import { ELECTRON_ORG, ELECTRON_REPO } from './constants';
 import { getTemplate } from './content';
 import { AppState } from './state';
-import { ElectronReleaseChannel, getReleaseChannel } from './versions';
+import { getReleaseChannel } from './versions';
 
 export class RemoteLoader {
   constructor(private readonly appState: AppState) {
diff --git a/src/renderer/runner.ts b/src/renderer/runner.ts
index f8ad6ea6..b60b3d56 100644
--- a/src/renderer/runner.ts
+++ b/src/renderer/runner.ts
@@ -77,13 +77,12 @@ export class Runner {
   public async autobisectImpl(
     versions: Array<RunnableVersion>,
   ): Promise<RunResult> {
+    const prefix = `Runner: autobisect`;
     const { appState } = this;
 
     // precondition: can't bisect unless we have >= 2 versions
     if (versions.length < 2) {
-      appState.pushOutput(
-        'Runner: autobisect needs at least two Electron versions',
-      );
+      appState.pushOutput(`${prefix} needs at least two Electron versions`);
       return RunResult.INVALID;
     }
 
@@ -92,11 +91,13 @@ export class Runner {
     const runVersion = async (version: string) => {
       let result = results.get(version);
       if (result === undefined) {
+        const pre = `${prefix} Electron ${version} -`;
+        appState.pushOutput(`${pre} setting version`);
         await appState.setVersion(version);
+        appState.pushOutput(`${pre} starting test`);
         result = await this.run();
         results.set(version, result);
-        const msg = `Runner: autobisect Electron ${version} - ${resultString[result]}`;
-        appState.pushOutput(msg);
+        appState.pushOutput(`${pre} finished test ${resultString[result]}`);
       }
       return result;
     };
@@ -106,7 +107,6 @@ export class Runner {
     let next;
     while (true) {
       const { version } = targetVersion;
-      appState.pushOutput(`Testing ${version}`, { isNotPre: true });
 
       const result = await runVersion(version);
       if (result === RunResult.INVALID) {
@@ -126,16 +126,16 @@ export class Runner {
     const resultBad = await runVersion(bad);
     if (resultGood === resultBad) {
       appState.pushOutput(
-        `Runner: autobisect 'good' ${good} and 'bad' ${bad} both returned ${resultString[resultGood]}`,
+        `${prefix} 'good' ${good} and 'bad' ${bad} both returned ${resultString[resultGood]}`,
       );
       return RunResult.INVALID;
     }
 
     const msgs = [
-      'Runner: autobisect complete',
-      `${good} ${resultString[RunResult.SUCCESS]}`,
-      `${bad} ${resultString[RunResult.FAILURE]}`,
-      'Commits between versions:',
+      `${prefix} complete`,
+      `${prefix} ${resultString[RunResult.SUCCESS]} ${good}`,
+      `${prefix} ${resultString[RunResult.FAILURE]} ${bad}`,
+      `${prefix} Commits between versions:`,
       `https://github.com/electron/electron/compare/v${good}...v${bad}`,
     ];
     msgs.forEach((msg) => appState.pushOutput(msg));
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index e8a15fd4..b79484d3 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -8,6 +8,7 @@ import {
   BlockableAccelerator,
   DocsDemoPage,
   EditorId,
+  ElectronReleaseChannel,
   GenericDialogOptions,
   GenericDialogType,
   GistActionState,
@@ -50,7 +51,6 @@ import { sortedElectronMap } from '../utils/sorted-electron-map';
 import { IPackageManager } from './npm';
 import {
   addLocalVersion,
-  ElectronReleaseChannel,
   getDefaultVersion,
   getElectronVersions,
   getReleaseChannel,
@@ -177,6 +177,8 @@ export class AppState {
     this.downloadVersion = this.downloadVersion.bind(this);
     this.pushError = this.pushError.bind(this);
     this.pushOutput = this.pushOutput.bind(this);
+    this.getVersion = this.getVersion.bind(this);
+    this.hasVersion = this.hasVersion.bind(this);
     this.removeVersion = this.removeVersion.bind(this);
     this.setVersion = this.setVersion.bind(this);
     this.showTour = this.showTour.bind(this);
@@ -192,6 +194,8 @@ export class AppState {
     this.setIsQuitting = this.setIsQuitting.bind(this);
     this.addAcceleratorToBlock = this.addAcceleratorToBlock.bind(this);
     this.removeAcceleratorToBlock = this.removeAcceleratorToBlock.bind(this);
+    this.hideChannels = this.hideChannels.bind(this);
+    this.showChannels = this.showChannels.bind(this);
 
     ipcRendererManager.removeAllListeners(IpcEvents.OPEN_SETTINGS);
     ipcRendererManager.removeAllListeners(IpcEvents.SHOW_WELCOME_TOUR);
@@ -353,6 +357,20 @@ export class AppState {
     this.isUpdatingElectronVersions = false;
   }
 
+  @action public hideChannels(channels: Array<ElectronReleaseChannel>) {
+    this.channelsToShow = this.channelsToShow.filter(
+      (ch) => !channels.includes(ch),
+    );
+  }
+
+  @action public showChannels(channels: Array<ElectronReleaseChannel>) {
+    const s = new Set<ElectronReleaseChannel>([
+      ...this.channelsToShow,
+      ...channels,
+    ]);
+    this.channelsToShow = [...s.values()];
+  }
+
   @action public async getName() {
     if (!this.name) {
       this.name = await getName(this);
@@ -513,6 +531,14 @@ export class AppState {
     }
   }
 
+  public hasVersion(input: string): boolean {
+    return !!this.getVersion(input);
+  }
+
+  public getVersion(input: string): RunnableVersion | null {
+    return this.versions[input];
+  }
+
   /**
    * Select a version of Electron (and download it if necessary).
    *
@@ -522,12 +548,11 @@ export class AppState {
   @action public async setVersion(input: string) {
     const version = normalizeVersion(input);
 
-    if (!this.versions[version]) {
+    if (!this.hasVersion(input)) {
       console.warn(
         `State: Called setVersion() with ${version}, which does not exist.`,
       );
-      this.setVersion(knownVersions[0].version);
-
+      await this.setVersion(knownVersions[0].version);
       return;
     }
 
@@ -649,11 +674,13 @@ export class AppState {
     if (strData.startsWith('Debugger listening on ws://')) return;
     if (strData === 'For help see https://nodejs.org/en/docs/inspector') return;
 
-    this.output.push({
+    const entry: OutputEntry = {
       timestamp: Date.now(),
       text: strData.trim(),
       isNotPre,
-    });
+    };
+    ipcRendererManager.send(IpcEvents.OUTPUT_ENTRY, entry);
+    this.output.push(entry);
   }
 
   /**
diff --git a/src/renderer/task-runner.ts b/src/renderer/task-runner.ts
new file mode 100644
index 00000000..2bcc290b
--- /dev/null
+++ b/src/renderer/task-runner.ts
@@ -0,0 +1,128 @@
+import {
+  BisectRequest,
+  ElectronReleaseChannel,
+  RunResult,
+  RunnableVersion,
+  SetFiddleOptions,
+  SetupRequest,
+  TestRequest,
+} from '../interfaces';
+import { IpcEvents } from '../ipc-events';
+
+import { App } from './app';
+import { AppState } from './state';
+
+import { getVersionRange } from '../utils/get-version-range';
+import { normalizeVersion } from '../utils/normalize-version';
+
+import { ipcRendererManager } from './ipc';
+
+export class TaskRunner {
+  private readonly appState: AppState;
+  private readonly autobisect: (v: RunnableVersion[]) => Promise<RunResult>;
+  private readonly done: (r: RunResult) => void;
+  private readonly hide: (channels: ElectronReleaseChannel[]) => Promise<void>;
+  private readonly log: (message: string) => void;
+  private readonly open: (o: SetFiddleOptions) => Promise<void>;
+  private readonly run: () => Promise<RunResult>;
+  private readonly setVersion: (ver: string) => Promise<void>;
+  private readonly show: (channels: ElectronReleaseChannel[]) => Promise<void>;
+
+  constructor(app: App) {
+    const { runner, state } = app;
+    const ipc = ipcRendererManager;
+
+    this.appState = state;
+    this.autobisect = runner.autobisect.bind(runner);
+    this.done = (r: RunResult) => ipc.send(IpcEvents.TASK_DONE, r);
+    this.hide = state.hideChannels.bind(state);
+    this.log = state.pushOutput.bind(state);
+    this.open = app.openFiddle.bind(app);
+    this.run = runner.run.bind(runner);
+    this.setVersion = (ver) => {
+      console.log('setVersion', ver);
+      if (state.hasVersion(ver)) {
+        console.log('calling appState.setVersion', ver);
+        return state.setVersion(ver);
+      } else {
+        console.log('throwing not found');
+        throw new Error(`Version "${ver}" not found`);
+      }
+    };
+    this.show = state.showChannels.bind(state);
+
+    this.bisect = this.bisect.bind(this);
+    let event = IpcEvents.TASK_BISECT;
+    ipc.removeAllListeners(event);
+    ipc.on(event, (_, r: BisectRequest) => {
+      this.bisect(r);
+    });
+
+    this.test = this.test.bind(this);
+    event = IpcEvents.TASK_TEST;
+    ipc.removeAllListeners(event);
+    ipc.on(event, (_, r: TestRequest) => this.test(r));
+  }
+
+  private async bisect(req: BisectRequest) {
+    const prefix = 'Task: Bisect ';
+    const { appState, log } = this;
+    let result = RunResult.INVALID;
+
+    try {
+      await this.setup(req.setup);
+
+      const good = normalizeVersion(req.goodVersion);
+      const bad = normalizeVersion(req.badVersion);
+      const range = getVersionRange(good, bad, appState.versionsToShow);
+      log(`${prefix} [${range.map((ver) => ver.version).join(', ')}]`);
+
+      result = await this.autobisect(range);
+      log(`${prefix} ${result}`);
+    } catch (err) {
+      log(err);
+    }
+
+    this.done(result);
+  }
+
+  private async test(req: TestRequest) {
+    let result = RunResult.INVALID;
+    const { log } = this;
+
+    try {
+      await this.setup(req.setup);
+      result = await this.run();
+    } catch (err) {
+      log(err);
+    }
+
+    this.done(result);
+  }
+
+  private async setup(req: SetupRequest) {
+    const { log } = this;
+    const { fiddle, hideChannels, showChannels, version } = req;
+
+    if (hideChannels.length > 0) {
+      log(`Task: Hide channels ${hideChannels.join(', ')}`);
+      this.hide(hideChannels);
+    }
+
+    if (showChannels.length > 0) {
+      log(`Task: Show channels ${showChannels.join(', ')}`);
+      this.show(showChannels);
+    }
+
+    const normVersion = normalizeVersion(version);
+    if (normVersion) {
+      log(`Task: Use Electron version ${normVersion}`);
+      await this.setVersion(normVersion);
+    }
+
+    if (fiddle) {
+      log(`Task: Open fiddle \"${JSON.stringify(fiddle)}"`);
+      await this.open(fiddle);
+    }
+  }
+}
diff --git a/src/renderer/themes.ts b/src/renderer/themes.ts
index 56298327..2a3bd543 100644
--- a/src/renderer/themes.ts
+++ b/src/renderer/themes.ts
@@ -117,9 +117,7 @@ export async function getTheme(
  * @param {FiddleTheme} theme
  * @returns {string}
  */
-export async function getCssStringForTheme(
-  theme: FiddleTheme,
-): Promise<string> {
+async function getCssStringForTheme(theme: FiddleTheme): Promise<string> {
   let cssContent = '';
 
   Object.keys(theme.common).forEach((key) => {
diff --git a/src/renderer/versions.ts b/src/renderer/versions.ts
index 493d0cf7..1b687ecb 100644
--- a/src/renderer/versions.ts
+++ b/src/renderer/versions.ts
@@ -1,5 +1,6 @@
 import semver from 'semver';
 import {
+  ElectronReleaseChannel,
   RunnableVersion,
   Version,
   VersionSource,
@@ -7,13 +8,6 @@ import {
 } from '../interfaces';
 import { normalizeVersion } from '../utils/normalize-version';
 
-export const enum ElectronReleaseChannel {
-  stable = 'Stable',
-  beta = 'Beta',
-  nightly = 'Nightly',
-  unsupported = 'Unsupported',
-}
-
 /**
  * Returns a sensible default version string.
  *
@@ -227,7 +221,7 @@ export function getKnownVersions(): Array<Version> {
  *
  * @param {Array<Version>} versions
  */
-export function saveKnownVersions(versions: Array<Version>) {
+function saveKnownVersions(versions: Array<Version>) {
   return saveVersions(VersionKeys.known, versions);
 }
 
@@ -283,7 +277,7 @@ export async function fetchVersions() {
  * @param {Array<any>} input
  * @returns {boolean}
  */
-export function isExpectedFormat(input: Array<any>): boolean {
+function isExpectedFormat(input: Array<any>): boolean {
   return input.every((entry) => !!entry.version);
 }
 
@@ -293,7 +287,7 @@ export function isExpectedFormat(input: Array<any>): boolean {
  * @param {Array<any>} input
  * @returns {Array<Version>}
  */
-export function migrateVersions(input: Array<any> = []): Array<Version> {
+function migrateVersions(input: Array<any> = []): Array<Version> {
   return input
     .filter((item) => !!item)
     .map((item) => {
@@ -310,7 +304,7 @@ export function migrateVersions(input: Array<any> = []): Array<Version> {
     .filter((item) => !!item) as Array<Version>;
 }
 
-export function isElectronVersion(
+function isElectronVersion(
   input: Version | RunnableVersion,
 ): input is RunnableVersion {
   return (input as RunnableVersion).source !== undefined;
diff --git a/src/shared-constants.ts b/src/shared-constants.ts
index 2181d0f0..3bbc7fe7 100644
--- a/src/shared-constants.ts
+++ b/src/shared-constants.ts
@@ -1,3 +1,5 @@
+import { EditorId } from './interfaces';
+
 export const INDEX_HTML_NAME = 'index.html';
 export const MAIN_JS_NAME = 'main.js';
 export const RENDERER_JS_NAME = 'renderer.js';
@@ -6,9 +8,9 @@ export const STYLES_CSS_NAME = 'styles.css';
 export const PACKAGE_NAME = 'package.json';
 
 export const FILENAME_KEYS = Object.freeze({
-  [INDEX_HTML_NAME]: 'html',
-  [MAIN_JS_NAME]: 'main',
-  [PRELOAD_JS_NAME]: 'preload',
-  [RENDERER_JS_NAME]: 'renderer',
-  [STYLES_CSS_NAME]: 'css',
+  [INDEX_HTML_NAME]: EditorId.html,
+  [MAIN_JS_NAME]: EditorId.main,
+  [PRELOAD_JS_NAME]: EditorId.preload,
+  [RENDERER_JS_NAME]: EditorId.renderer,
+  [STYLES_CSS_NAME]: EditorId.css,
 });
diff --git a/src/utils/get-version-range.ts b/src/utils/get-version-range.ts
new file mode 100644
index 00000000..ccbb3436
--- /dev/null
+++ b/src/utils/get-version-range.ts
@@ -0,0 +1,46 @@
+import * as semver from 'semver';
+
+import { RunnableVersion } from '../interfaces';
+
+/**
+ * An subset of `versions` sorted from oldest to newest and bounded in the range of [oldVersion..newVersion]
+ *
+ * @export
+ * @param {string} oldVersion - first version to keep
+ * @param {string} newVersion - last version to keep
+ * @param {RunnableVersion[]} versions - the versions to make a subset of
+ * @returns {RunnableVersion[]}
+ */
+export function getVersionRange(
+  oldVersion: string,
+  newVersion: string,
+  versions: RunnableVersion[],
+): RunnableVersion[] {
+  // ensure that oldVersion is old than newVersion
+  if (semver.compare(oldVersion, newVersion) > 0) {
+    [oldVersion, newVersion] = [newVersion, oldVersion];
+  }
+
+  const oldIdx = versions.findIndex((v) => v.version === oldVersion);
+  if (oldIdx === -1) {
+    console.warn(`getVersionRange: Version not found: ${oldVersion}`);
+    return [];
+  }
+
+  const newIdx = versions.findIndex((v) => v.version === newVersion);
+  if (newIdx === -1) {
+    console.warn(`getVersionRange: Version not found: ${newVersion}`);
+    return [];
+  }
+
+  versions = versions.slice(
+    Math.min(oldIdx, newIdx),
+    Math.max(oldIdx, newIdx) + 1,
+  );
+
+  if (oldIdx > newIdx) {
+    versions.reverse();
+  }
+
+  return versions;
+}
diff --git a/src/utils/gist.ts b/src/utils/gist.ts
index 6a192311..a7847e30 100644
--- a/src/utils/gist.ts
+++ b/src/utils/gist.ts
@@ -1,3 +1,31 @@
+/**
+ * Get the gist ID from a string.
+ *
+ * Understands these formats:
+ *-  8C5FC0C6A5153D49B5A4A56D3ED9DA8F
+ *-  8c5fc0c6a5153d49b5a4a56d3ed9da8f
+ *-  https://gist.github.com/8c5fc0c6a5153d49b5a4a56d3ed9da8f
+ *-  https://gist.github.com/8c5fc0c6a5153d49b5a4a56d3ed9da8f/
+ *-  https://gist.github.com/ckerr/8c5fc0c6a5153d49b5a4a56d3ed9da8f
+ *-  https://gist.github.com/ckerr/8c5fc0c6a5153d49b5a4a56d3ed9da8f/
+ *
+ * @pararm {string} input
+ * returns {(string | null)}
+ */
+export function getGistId(input: string): string | null {
+  let id: string | undefined = input;
+  if (input.startsWith('https://gist.github.com')) {
+    if (input.endsWith('/')) {
+      input = input.slice(0, -1);
+    }
+    id = input.split('/').pop();
+  }
+  if (id && id.match(/[0-9A-Fa-f]{32}/)) {
+    return id;
+  }
+  return null;
+}
+
 /**
  * Get the id of a gist from a url
  *
@@ -5,14 +33,7 @@
  * @returns {(string | null)}
  */
 export function idFromUrl(input: string): string | null {
-  let gistMatch = input.match(/https:\/\/gist\.github\.com\/([^\/]+)$/);
-
-  if (!gistMatch || !gistMatch[1]) {
-    gistMatch = input.match(/https:\/\/gist\.github\.com\/[^\/]+\/([^\/]+)$/);
-    if (!gistMatch || !gistMatch[1]) return null;
-  }
-
-  return gistMatch[1];
+  return getGistId(input);
 }
 
 /**
diff --git a/src/utils/nice-greeting.ts b/src/utils/nice-greeting.ts
deleted file mode 100644
index 1149303a..00000000
--- a/src/utils/nice-greeting.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { getRandomNumber } from './random';
-
-/**
- * Get a random nice greeting
- *
- * @export
- * @returns
- */
-export function getNiceGreeting() {
-  const greetings = [
-    '👋 Go build a great app',
-    '💖 You got this',
-    `💖 We think you're great`,
-    '🤘 Your app will rock, we know it',
-    '🙇‍♀️ Thanks for trying out Electron & Fiddle',
-    '🐕 Go pet a dog today',
-    '🐈 Go pet a cat today',
-    '💧 Stay hydrated',
-  ];
-
-  const min = 0;
-  const max = greetings.length;
-
-  return greetings[getRandomNumber(min, max)];
-}
diff --git a/src/utils/position-for-rect.ts b/src/utils/position-for-rect.ts
index 76c157b8..706d8670 100644
--- a/src/utils/position-for-rect.ts
+++ b/src/utils/position-for-rect.ts
@@ -1,6 +1,6 @@
 import { ArrowPosition } from '../interfaces';
 
-export interface PositionResult {
+interface PositionResult {
   top: number;
   left: number;
   type: ArrowPosition;
diff --git a/src/utils/random.ts b/src/utils/random.ts
deleted file mode 100644
index 604f418d..00000000
--- a/src/utils/random.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Dumb helper: Get a random number between two numbers
- *
- * @param {number} [min=0]
- * @param {number} [max=1]
- * @returns {number}
- */
-export function getRandomNumber(min = 0, max = 1): number {
-  return Math.floor(Math.random() * max) + min;
-}
diff --git a/yarn.lock b/yarn.lock
index 925240d6..c2c2fc23 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2228,6 +2228,11 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
   integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
 
+"@types/json-stable-stringify@^1.0.32":
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e"
+  integrity sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw==
+
 "@types/json5@^0.0.29":
   version "0.0.29"
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@@ -3739,6 +3744,11 @@ commander@^6.2.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
   integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
 
+commander@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
 compare-version@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/compare-version/-/compare-version-0.1.2.tgz#0162ec2d9351f5ddd59a9202cba935366a725080"
@@ -7750,6 +7760,13 @@ json-stable-stringify-without-jsonify@^1.0.1:
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
+json-stable-stringify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
+  integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=
+  dependencies:
+    jsonify "~0.0.0"
+
 json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@@ -7785,6 +7802,11 @@ jsonfile@^6.0.1:
   optionalDependencies:
     graceful-fs "^4.1.6"
 
+jsonify@~0.0.0:
+  version "0.0.0"
+  resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
+  integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=
+
 jsprim@^1.2.2:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"


Test patch:
diff --git a/tests/main/command-line-spec.ts b/tests/main/command-line-spec.ts
new file mode 100644
index 00000000..e4447d4f
--- /dev/null
+++ b/tests/main/command-line-spec.ts
@@ -0,0 +1,176 @@
+// use a stable-sorting stringify for comparing expected & actual payloads
+import stringify from 'json-stable-stringify';
+import { app } from 'electron';
+
+import {
+  ElectronReleaseChannel,
+  OutputEntry,
+  RunResult,
+} from '../../src/interfaces';
+import { IpcEvents } from '../../src/ipc-events';
+
+import { ipcMainManager } from '../../src/main/ipc';
+import { processCommandLine } from '../../src/main/command-line';
+
+jest.unmock('fs-extra');
+
+describe('processCommandLine()', () => {
+  // when no fiddle specified, cwd is the default
+  const DEFAULT_FIDDLE = `{"filePath":"${process.cwd()}"}`;
+  const ARGV_PREFIX = process.defaultApp
+    ? ['/path/to/electron', 'main.ts']
+    : ['main.ts'];
+
+  beforeEach(() => {
+    ipcMainManager.removeAllListeners();
+    ipcMainManager.send = jest.fn();
+  });
+
+  it('does nothing when passed no arguments', async () => {
+    await processCommandLine(ARGV_PREFIX);
+    expect(ipcMainManager.send).not.toHaveBeenCalled();
+  });
+
+  function expectSendCalledOnceWith(event: IpcEvents, payload: string) {
+    const send = ipcMainManager.send as jest.Mock;
+    expect(send).toHaveBeenCalledTimes(1);
+    const [call] = send.mock.calls;
+    expect(call.length).toEqual(2);
+    const [ev, params] = call;
+    expect(ev).toBe(event);
+    expect(params.length).toBe(1);
+    const [request] = params;
+    expect(stringify(request)).toBe(payload);
+  }
+
+  describe('test', () => {
+    const ARGV = [...ARGV_PREFIX, 'test'];
+
+    function expectTestCalledOnceWith(payload: string) {
+      expectSendCalledOnceWith(IpcEvents.TASK_TEST, payload);
+    }
+
+    it('uses cwd as the default fiddle location', async () => {
+      const argv = ARGV;
+      const expected = `{"setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":[],"showChannels":[]}}`;
+      await processCommandLine(argv);
+      expectTestCalledOnceWith(expected);
+    });
+
+    it('handles a --fiddle that is a hex gist id', async () => {
+      const GIST_ID = 'af3e1a018f5dcce4a2ff40004ef5bab5';
+      const argv = [...ARGV, '--fiddle', GIST_ID];
+      const expected = `{"setup":{"fiddle":{"gistId":"${GIST_ID}"},"hideChannels":[],"showChannels":[]}}`;
+      await processCommandLine(argv);
+      expectTestCalledOnceWith(expected);
+    });
+
+    it('handles a --fiddle option that is unrecognizable', async () => {
+      const FIDDLE = '✨🤪💎';
+      const argv = [...ARGV, '--fiddle', FIDDLE];
+      const expected = `Unrecognized Fiddle "${FIDDLE}"`;
+      const spy = jest.spyOn(console, 'error').mockImplementation();
+      await processCommandLine(argv);
+      expect(ipcMainManager.send).not.toHaveBeenCalled();
+      expect(spy).toHaveBeenCalledWith(expected);
+      spy.mockReset();
+    });
+
+    it('handles a --version option', async () => {
+      const VERSION = '12.0.0';
+      const argv = [...ARGV, '--version', VERSION];
+      const expected = `{"setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":[],"showChannels":[],"version":"${VERSION}"}}`;
+      await processCommandLine(argv);
+      expectTestCalledOnceWith(expected);
+    });
+  });
+
+  describe('bisect', () => {
+    const ARGV = [...ARGV_PREFIX, 'bisect'];
+    const GOOD = '10.0.0';
+    const BAD = '11.2.0';
+
+    function expectBisectCalledOnceWith(payload: string) {
+      expectSendCalledOnceWith(IpcEvents.TASK_BISECT, payload);
+    }
+
+    it('sends a bisect request', async () => {
+      const argv = [...ARGV, GOOD, BAD];
+      const expected = `{"badVersion":"${BAD}","goodVersion":"${GOOD}","setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":[],"showChannels":[]}}`;
+      await processCommandLine(argv);
+      expectBisectCalledOnceWith(expected);
+    });
+
+    it('handles a --nightlies option', async () => {
+      const argv = [...ARGV, GOOD, BAD, '--nightlies'];
+      const expected = `{"badVersion":"${BAD}","goodVersion":"${GOOD}","setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":[],"showChannels":["${ElectronReleaseChannel.nightly}"]}}`;
+      await processCommandLine(argv);
+      expectBisectCalledOnceWith(expected);
+    });
+
+    it('handles a --no-nightlies option', async () => {
+      const argv = [...ARGV, GOOD, BAD, '--no-nightlies'];
+      const expected = `{"badVersion":"${BAD}","goodVersion":"${GOOD}","setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":["${ElectronReleaseChannel.nightly}"],"showChannels":[]}}`;
+      await processCommandLine(argv);
+      expectBisectCalledOnceWith(expected);
+    });
+
+    it('handles a --betas option', async () => {
+      const argv = [...ARGV, GOOD, BAD, '--betas'];
+      const expected = `{"badVersion":"${BAD}","goodVersion":"${GOOD}","setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":[],"showChannels":["${ElectronReleaseChannel.beta}"]}}`;
+      await processCommandLine(argv);
+      expectBisectCalledOnceWith(expected);
+    });
+
+    it('handles a --no-betas option', async () => {
+      const argv = [...ARGV, GOOD, BAD, '--no-betas'];
+      const expected = `{"badVersion":"${BAD}","goodVersion":"${GOOD}","setup":{"fiddle":${DEFAULT_FIDDLE},"hideChannels":["${ElectronReleaseChannel.beta}"],"showChannels":[]}}`;
+      await processCommandLine(argv);
+      expectBisectCalledOnceWith(expected);
+    });
+
+    describe(`watches for ${IpcEvents.TASK_DONE} events`, () => {
+      async function expectDoneCausesExit(result: RunResult, exitCode: number) {
+        const argv = [...ARGV, GOOD, BAD];
+        (ipcMainManager.send as jest.Mock).mockImplementationOnce(() => {
+          const fakeEvent = {};
+          ipcMainManager.emit(IpcEvents.TASK_DONE, fakeEvent, result);
+        });
+        await processCommandLine(argv);
+        expect(app.exit).toHaveBeenCalledWith(exitCode);
+      }
+
+      it(`exits with 0 on ${RunResult.SUCCESS}`, async () => {
+        await expectDoneCausesExit(RunResult.SUCCESS, 0);
+      });
+
+      it(`exits with 1 on ${RunResult.FAILURE}`, async () => {
+        await expectDoneCausesExit(RunResult.FAILURE, 1);
+      });
+
+      it(`exits with 2 on ${RunResult.INVALID}`, async () => {
+        await expectDoneCausesExit(RunResult.INVALID, 2);
+      });
+
+      it('sends output messages to the console', async () => {
+        const now = Date.now();
+        const text = 'asieoniezi';
+        const expected = `[${new Date(now).toLocaleTimeString()}] ${text}`;
+        const spy = jest.spyOn(console, 'log').mockReturnValue();
+
+        const fakeEvent = {};
+        const entry: OutputEntry = { text, timestamp: now };
+        (ipcMainManager.send as jest.Mock).mockImplementationOnce(() => {
+          ipcMainManager.emit(IpcEvents.OUTPUT_ENTRY, fakeEvent, entry);
+        });
+
+        const argv = [...ARGV, GOOD, BAD];
+        await processCommandLine(argv);
+
+        expect(spy).toHaveBeenCalledWith(expected);
+
+        spy.mockRestore();
+      });
+    });
+  });
+});
diff --git a/tests/main/main-spec.ts b/tests/main/main-spec.ts
index 5304c607..dc25f3fc 100644
--- a/tests/main/main-spec.ts
+++ b/tests/main/main-spec.ts
@@ -68,12 +68,12 @@ describe('main', () => {
     it('quits during Squirrel events', () => {
       (shouldQuit as jest.Mock).mockReturnValueOnce(true);
 
-      main();
+      main([]);
       expect(app.quit).toHaveBeenCalledTimes(1);
     });
 
     it('listens to core events', () => {
-      main();
+      main([]);
       expect(app.on).toHaveBeenCalledTimes(4);
     });
   });
diff --git a/tests/mocks/app.ts b/tests/mocks/app.ts
index 7e85adbc..6eb151f3 100644
--- a/tests/mocks/app.ts
+++ b/tests/mocks/app.ts
@@ -1,10 +1,10 @@
 import { FileManager } from './file-manager';
+import { MockState } from './state';
 import { RemoteLoader } from './remote-loader';
 import { RunnerMock } from './runner';
 
 export class AppMock {
   public setup = jest.fn();
-  public setupUnsavedOnChangeListener = jest.fn();
   public replaceFiddle = jest.fn();
   public setEditorValues = jest.fn();
   public getEditorValues = jest.fn().mockResolvedValue({
@@ -16,14 +16,17 @@ export class AppMock {
   });
 
   public loadTheme = jest.fn();
+  public openFiddle = jest.fn();
 
   public typeDefDisposable = {
     dispose: jest.fn(),
   };
 
   public fileManager = new FileManager();
-  public runner = new RunnerMock();
   public remoteLoader = new RemoteLoader();
+  public runner = new RunnerMock();
+  public state = new MockState();
+  public taskRunner = {};
 
   public monaco = {
     editor: {
diff --git a/tests/mocks/exec.ts b/tests/mocks/exec.ts
deleted file mode 100644
index 57466b29..00000000
--- a/tests/mocks/exec.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-export const execMock = jest.fn();
diff --git a/tests/mocks/file-manager.ts b/tests/mocks/file-manager.ts
index 3c471f37..c37ae6a5 100644
--- a/tests/mocks/file-manager.ts
+++ b/tests/mocks/file-manager.ts
@@ -1,4 +1,5 @@
 export class FileManager {
-  public saveToTemp = jest.fn(() => '/mock/temp/dir');
   public cleanup = jest.fn();
+  public openFiddle = jest.fn();
+  public saveToTemp = jest.fn(() => '/mock/temp/dir');
 }
diff --git a/tests/mocks/remote-loader.ts b/tests/mocks/remote-loader.ts
index e886bc0f..f18e9fe7 100644
--- a/tests/mocks/remote-loader.ts
+++ b/tests/mocks/remote-loader.ts
@@ -1,5 +1,5 @@
 export class RemoteLoader {
-  public loadFiddleFromGist = jest.fn();
-  public loadFiddleFromElectronExample = jest.fn();
   public fetchGistAndLoad = jest.fn();
+  public loadFiddleFromElectronExample = jest.fn();
+  public loadFiddleFromGist = jest.fn();
 }
diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts
index 9ea027d1..947e2f03 100644
--- a/tests/mocks/state.ts
+++ b/tests/mocks/state.ts
@@ -7,4 +7,11 @@ export class MockState {
   @observable public isGenericDialogShowing = false;
   @observable public isUsingSystemTheme = true;
   @observable public output = [];
+  @observable public versionsToShow = [];
+
+  public hasVersion = jest.fn();
+  public hideChannels = jest.fn();
+  public pushOutput = jest.fn();
+  public setVersion = jest.fn();
+  public showChannels = jest.fn();
 }
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index 66d89345..6f089e3d 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -11,9 +11,13 @@ jest.mock('../../src/renderer/file-manager', () =>
 );
 jest.mock('../../src/renderer/state', () => ({
   appState: {
-    theme: 'defaultDark',
-    getName: () => 'Test',
     closedPanels: {},
+    getName: () => 'Test',
+    hideChannels: jest.fn(),
+    pushOutput: jest.fn(),
+    setVersion: jest.fn(),
+    showChannels: jest.fn(),
+    theme: 'defaultDark',
   },
 }));
 jest.mock('../../src/renderer/components/header', () => ({
@@ -49,6 +53,34 @@ describe('App component', () => {
     });
   });
 
+  describe('openFiddle()', () => {
+    it('understands gists', async () => {
+      const gistId = '8c5fc0c6a5153d49b5a4a56d3ed9da8f';
+      const app = new App();
+
+      const spy = jest.spyOn(app.remoteLoader, 'fetchGistAndLoad');
+      spy.mockResolvedValue(true);
+
+      await app.openFiddle({ gistId });
+
+      expect(spy).toHaveBeenCalledWith(gistId);
+
+      spy.mockRestore();
+    });
+
+    it('understands files', async () => {
+      const filePath = '/fake/path';
+      const app = new App();
+
+      const openFiddle = app.fileManager.openFiddle as jest.Mock;
+      openFiddle.mockImplementationOnce(() => Promise.resolve());
+
+      await app.openFiddle({ filePath });
+
+      expect(openFiddle).toHaveBeenCalledWith(filePath);
+    });
+  });
+
   describe('getEditorValues()', () => {
     it('gets values', async () => {
       const app = new App();
@@ -77,9 +109,9 @@ describe('App component', () => {
     });
 
     it('throws if the Fiddle object is not present', async () => {
-      (window as any).ElectronFiddle = null;
-
       const app = new App();
+
+      (window as any).ElectronFiddle = null;
       let threw = false;
       try {
         await app.getEditorValues({});
@@ -339,9 +371,9 @@ describe('App component', () => {
     });
 
     it('throws if the Fiddle object is not present', async () => {
-      (window as any).ElectronFiddle = null;
-
       const app = new App();
+
+      (window as any).ElectronFiddle = null;
       let threw = false;
       try {
         await app.setEditorValues({ html: '', main: '', renderer: '' });
diff --git a/tests/renderer/components/commands-version-chooser-spec.tsx b/tests/renderer/components/commands-version-chooser-spec.tsx
index 37c3dfc1..a5e8a85b 100644
--- a/tests/renderer/components/commands-version-chooser-spec.tsx
+++ b/tests/renderer/components/commands-version-chooser-spec.tsx
@@ -1,9 +1,12 @@
 import { mount, shallow } from 'enzyme';
 import * as React from 'react';
 
-import { VersionSource, VersionState } from '../../../src/interfaces';
+import {
+  ElectronReleaseChannel,
+  VersionSource,
+  VersionState,
+} from '../../../src/interfaces';
 import { VersionChooser } from '../../../src/renderer/components/commands-version-chooser';
-import { ElectronReleaseChannel } from '../../../src/renderer/versions';
 import { mockVersions } from '../../mocks/electron-versions';
 
 const { unknown } = VersionState;
diff --git a/tests/renderer/components/dialog-bisect-spec.tsx b/tests/renderer/components/dialog-bisect-spec.tsx
index f1994de1..dd564b4d 100644
--- a/tests/renderer/components/dialog-bisect-spec.tsx
+++ b/tests/renderer/components/dialog-bisect-spec.tsx
@@ -1,13 +1,13 @@
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import {
+  ElectronReleaseChannel,
   RunResult,
   VersionSource,
   VersionState,
 } from '../../../src/interfaces';
 import { Bisector } from '../../../src/renderer/bisect';
 import { BisectDialog } from '../../../src/renderer/components/dialog-bisect';
-import { ElectronReleaseChannel } from '../../../src/renderer/versions';
 
 jest.mock('../../../src/renderer/bisect');
 
diff --git a/tests/renderer/components/settings-electron-spec.tsx b/tests/renderer/components/settings-electron-spec.tsx
index 40087d20..b845f528 100644
--- a/tests/renderer/components/settings-electron-spec.tsx
+++ b/tests/renderer/components/settings-electron-spec.tsx
@@ -1,9 +1,12 @@
 import { mount, shallow } from 'enzyme';
 import * as React from 'react';
 
-import { VersionSource, VersionState } from '../../../src/interfaces';
+import {
+  ElectronReleaseChannel,
+  VersionSource,
+  VersionState,
+} from '../../../src/interfaces';
 import { ElectronSettings } from '../../../src/renderer/components/settings-electron';
-import { ElectronReleaseChannel } from '../../../src/renderer/versions';
 import { mockVersions, mockVersionsArray } from '../../mocks/electron-versions';
 
 describe('ElectronSettings component', () => {
@@ -22,6 +25,8 @@ describe('ElectronSettings component', () => {
       removeVersion: jest.fn(),
       updateElectronVersions: jest.fn(),
       toggleAddVersionDialog: jest.fn(),
+      showChannels: jest.fn(),
+      hideChannels: jest.fn(),
     };
 
     // Render all the states
@@ -158,6 +163,15 @@ describe('ElectronSettings component', () => {
   describe('handleChannelChange()', () => {
     it('handles a new selection', async () => {
       const wrapper = shallow(<ElectronSettings appState={store} />);
+      store.showChannels.mockImplementation((ids: ElectronReleaseChannel[]) =>
+        store.channelsToShow.push(...ids),
+      );
+      store.hideChannels.mockImplementation(
+        (ids: ElectronReleaseChannel[]) =>
+          (store.channelsToShow = store.channelsToShow.filter(
+            (id: ElectronReleaseChannel) => !ids.includes(id),
+          )),
+      );
       const instance = wrapper.instance() as any;
       await instance.handleChannelChange({
         currentTarget: {
diff --git a/tests/renderer/components/version-select-spec.tsx b/tests/renderer/components/version-select-spec.tsx
index 394ba10e..e53c2ffb 100644
--- a/tests/renderer/components/version-select-spec.tsx
+++ b/tests/renderer/components/version-select-spec.tsx
@@ -2,6 +2,7 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 
 import {
+  ElectronReleaseChannel,
   RunnableVersion,
   VersionSource,
   VersionState,
@@ -13,7 +14,6 @@ import {
   renderItem,
   VersionSelect,
 } from '../../../src/renderer/components/version-select';
-import { ElectronReleaseChannel } from '../../../src/renderer/versions';
 import { mockVersions } from '../../mocks/electron-versions';
 
 const { downloading, ready, unknown, unzipping } = VersionState;
diff --git a/tests/renderer/remote-loader-spec.ts b/tests/renderer/remote-loader-spec.ts
index 35715481..3757719f 100644
--- a/tests/renderer/remote-loader-spec.ts
+++ b/tests/renderer/remote-loader-spec.ts
@@ -1,8 +1,10 @@
 import { observable } from 'mobx';
-import { GenericDialogType } from '../../src/interfaces';
+import {
+  ElectronReleaseChannel,
+  GenericDialogType,
+} from '../../src/interfaces';
 import { ipcRendererManager } from '../../src/renderer/ipc';
 import { RemoteLoader } from '../../src/renderer/remote-loader';
-import { ElectronReleaseChannel } from '../../src/renderer/versions';
 import {
   INDEX_HTML_NAME,
   MAIN_JS_NAME,
diff --git a/tests/renderer/runner-spec.tsx b/tests/renderer/runner-spec.tsx
index 4e655007..3ef5b6b6 100644
--- a/tests/renderer/runner-spec.tsx
+++ b/tests/renderer/runner-spec.tsx
@@ -341,7 +341,7 @@ describe('Runner component', () => {
 
       expect(result).toBe(RunResult.INVALID);
       expect(store.pushOutput).toHaveBeenLastCalledWith(
-        'Runner: autobisect Electron 2.0.1 - ❓ invalid',
+        'Runner: autobisect Electron 2.0.1 - finished test ❓ invalid',
       );
 
       spy.mockRestore();
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 79791803..585f6c20 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -4,6 +4,7 @@ import {
   ALL_MOSAICS,
   BlockableAccelerator,
   EditorId,
+  ElectronReleaseChannel,
   GenericDialogType,
   PanelId,
   VersionSource,
@@ -17,7 +18,10 @@ import {
   setupBinary,
 } from '../../src/renderer/binary';
 import { Bisector } from '../../src/renderer/bisect';
-import { DEFAULT_MOSAIC_ARRANGEMENT } from '../../src/renderer/constants';
+import {
+  DEFAULT_MOSAIC_ARRANGEMENT,
+  SORTED_EDITORS,
+} from '../../src/renderer/constants';
 import { getTemplate, isContentUnchanged } from '../../src/renderer/content';
 import { ipcRendererManager } from '../../src/renderer/ipc';
 import { AppState } from '../../src/renderer/state';
@@ -56,10 +60,6 @@ jest.mock('../../src/renderer/versions', () => {
     getElectronVersions: () =>
       require('../mocks/electron-versions').mockVersionsArray,
     getDefaultVersion: () => '2.0.2',
-    ElectronReleaseChannel: {
-      stable: 'Stable',
-      beta: 'Beta',
-    },
     addLocalVersion: jest.fn(),
     saveLocalVersions: jest.fn(),
     getReleaseChannel,
@@ -340,6 +340,43 @@ describe('AppState', () => {
     });
   });
 
+  describe('showChannels()', () => {
+    it('adds channels from `channelsToShow`', () => {
+      appState.channelsToShow = [
+        ElectronReleaseChannel.beta,
+        ElectronReleaseChannel.stable,
+      ];
+      appState.showChannels([
+        ElectronReleaseChannel.beta,
+        ElectronReleaseChannel.nightly,
+      ]);
+      expect([...appState.channelsToShow].sort()).toEqual([
+        ElectronReleaseChannel.beta,
+        ElectronReleaseChannel.nightly,
+        ElectronReleaseChannel.stable,
+      ]);
+    });
+  });
+
+  describe('hideChannels()', () => {
+    it('removes channels from `channelsToShow`', () => {
+      appState.channelsToShow = [
+        ElectronReleaseChannel.beta,
+        ElectronReleaseChannel.nightly,
+        ElectronReleaseChannel.stable,
+        ElectronReleaseChannel.unsupported,
+      ];
+      appState.hideChannels([
+        ElectronReleaseChannel.beta,
+        ElectronReleaseChannel.unsupported,
+      ]);
+      expect([...appState.channelsToShow].sort()).toEqual([
+        ElectronReleaseChannel.nightly,
+        ElectronReleaseChannel.stable,
+      ]);
+    });
+  });
+
   describe('removeVersion()', () => {
     it('removes a version', async () => {
       appState.versions['2.0.2'].state = VersionState.ready;
@@ -398,6 +435,18 @@ 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', () => {
+      expect(appState.hasVersion(UNKNOWN_VERSION)).toEqual(false);
+    });
+    it('returns true if state has that version', () => {
+      expect(appState.hasVersion(KNOWN_VERSION!)).toBe(true);
+    });
+  });
+
   describe('setVersion()', () => {
     it('uses the newest version iff the specified version does not exist', async () => {
       await appState.setVersion('v999.99.99');
@@ -603,15 +652,15 @@ describe('AppState', () => {
     it('hides a given editor and creates a backup', () => {
       appState.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
       appState.closedPanels = {};
-      appState.hideAndBackupMosaic(EditorId.main);
+      appState.hideAndBackupMosaic(SORTED_EDITORS[0]);
 
       expect(appState.mosaicArrangement).toEqual({
         direction: 'row',
-        first: EditorId.renderer,
+        first: SORTED_EDITORS[1],
         second: {
           direction: 'column',
-          first: EditorId.preload,
-          second: EditorId.html,
+          first: SORTED_EDITORS[2],
+          second: SORTED_EDITORS[3],
         },
       });
       expect(appState.closedPanels[EditorId.main]).toBeTruthy();
@@ -635,15 +684,15 @@ describe('AppState', () => {
 
   describe('resetEditorLayout()', () => {
     it('Puts editors in default arrangement', () => {
-      appState.hideAndBackupMosaic(EditorId.main);
+      appState.hideAndBackupMosaic(SORTED_EDITORS[0]);
 
       expect(appState.mosaicArrangement).toEqual({
         direction: 'row',
-        first: EditorId.renderer,
+        first: SORTED_EDITORS[1],
         second: {
           direction: 'column',
-          first: EditorId.preload,
-          second: EditorId.html,
+          first: SORTED_EDITORS[2],
+          second: SORTED_EDITORS[3],
         },
       });
 
diff --git a/tests/renderer/task-runner-spec.tsx b/tests/renderer/task-runner-spec.tsx
new file mode 100644
index 00000000..c0f3bc6b
--- /dev/null
+++ b/tests/renderer/task-runner-spec.tsx
@@ -0,0 +1,177 @@
+import {
+  BisectRequest,
+  ElectronReleaseChannel,
+  RunResult,
+  RunnableVersion,
+  TestRequest,
+  VersionSource,
+  VersionState,
+} from '../../src/interfaces';
+import { IpcEvents } from '../../src/ipc-events';
+
+import { App } from '../../src/renderer/app';
+import { TaskRunner } from '../../src/renderer/task-runner';
+import { ipcRendererManager, IpcRendererManager } from '../../src/renderer/ipc';
+import { AppMock } from '../mocks/app';
+import { MockState } from '../mocks/state';
+
+describe('Task Runner component', () => {
+  let app: AppMock;
+  let appState: MockState;
+  let runner: any;
+  let ipc: IpcRendererManager;
+
+  function makeRunnables(versions: string[]): RunnableVersion[] {
+    return versions.map((version) => ({
+      source: VersionSource.remote,
+      state: VersionState.unknown,
+      version,
+    }));
+  }
+
+  beforeEach(() => {
+    app = (window.ElectronFiddle.app as unknown) as AppMock;
+    appState = app.state;
+    runner = app.runner;
+    runner.autobisect.foo = 'a';
+    ipc = ipcRendererManager;
+    app.taskRunner = new TaskRunner((app as unknown) as App);
+  });
+
+  async function requestAndWait(ipcEvent: IpcEvents, req: any) {
+    let resolve: any;
+    const fakeEvent = {};
+    const taskPromise = new Promise((r) => (resolve = r));
+    const sendSpy = jest
+      .spyOn(ipc, 'send')
+      .mockImplementationOnce((...params) => resolve(params));
+    ipc.emit(ipcEvent, fakeEvent, req);
+    const result: any = await taskPromise;
+    sendSpy.mockReset();
+    return result;
+  }
+
+  describe('ipc bisect request handler', () => {
+    const GOOD = '10.0.0';
+    const BAD = '11.0.2';
+    const GIST_ID = '8c5fc0c6a5153d49b5a4a56d3ed9da8f';
+    const SHOW = [ElectronReleaseChannel.stable];
+    const HIDE = [ElectronReleaseChannel.beta, ElectronReleaseChannel.nightly];
+    const VERSIONS = [
+      '12.0.0',
+      '11.2.0',
+      '11.1.0',
+      '11.0.2',
+      '11.0.1',
+      '11.0.0',
+      '10.1.0',
+      '10.0.0',
+      '9.0.0',
+    ];
+    const EXPECTED_VERSIONS = makeRunnables(
+      VERSIONS.slice(VERSIONS.indexOf(BAD), VERSIONS.indexOf(GOOD) + 1),
+    ).reverse();
+    const req: BisectRequest = {
+      goodVersion: GOOD,
+      badVersion: BAD,
+      setup: {
+        showChannels: SHOW,
+        hideChannels: HIDE,
+        fiddle: {
+          gistId: GIST_ID,
+        },
+      },
+    };
+
+    it('invokes the runner and returns the result', async () => {
+      const RESULT = RunResult.SUCCESS;
+
+      (app.openFiddle as jest.Mock).mockResolvedValue(0);
+      (appState.hasVersion as jest.Mock).mockReturnValueOnce(true);
+      (appState.hideChannels as jest.Mock).mockResolvedValue(0);
+      (appState.setVersion as jest.Mock).mockResolvedValue(0);
+      (appState.showChannels as jest.Mock).mockResolvedValue(0);
+      (appState.versionsToShow as any) = makeRunnables(VERSIONS);
+      (runner.autobisect as jest.Mock).mockResolvedValueOnce(RESULT);
+
+      const result = await requestAndWait(IpcEvents.TASK_BISECT, req);
+
+      expect(app.openFiddle).toHaveBeenCalledTimes(1);
+      expect(app.openFiddle).toHaveBeenCalledWith(req.setup.fiddle);
+      expect(appState.setVersion).not.toHaveBeenCalled();
+      expect(appState.showChannels).toHaveBeenCalledTimes(1);
+      expect(appState.showChannels).toHaveBeenCalledWith(SHOW);
+      expect(result[0]).toBe(IpcEvents.TASK_DONE);
+      expect(result[1]).toBe(RESULT);
+      expect(runner.autobisect).toHaveBeenCalledTimes(1);
+      expect(runner.autobisect).toHaveBeenCalledWith(EXPECTED_VERSIONS);
+    });
+
+    it('returns invalid if an exception is thrown', async () => {
+      const RESULT = RunResult.INVALID;
+      (app.openFiddle as jest.Mock).mockRejectedValue('💩');
+
+      const result = await requestAndWait(IpcEvents.TASK_BISECT, req);
+
+      expect(result[0]).toBe(IpcEvents.TASK_DONE);
+      expect(result[1]).toBe(RESULT);
+    });
+  });
+
+  describe('ipc test request handler', () => {
+    const VERSION = '10.0.0';
+    const PATH = '/path/to/fiddle';
+    const req: TestRequest = {
+      setup: {
+        showChannels: [],
+        hideChannels: [],
+        version: VERSION,
+        fiddle: {
+          filePath: PATH,
+        },
+      },
+    };
+
+    it('invokes the runner and returns the result', async () => {
+      const RESULT = RunResult.FAILURE;
+
+      (app.openFiddle as jest.Mock).mockResolvedValue(0);
+      (appState.hasVersion as jest.Mock).mockReturnValueOnce(true);
+      (appState.hideChannels as jest.Mock).mockResolvedValue(0);
+      (appState.setVersion as jest.Mock).mockResolvedValue(0);
+      (appState.showChannels as jest.Mock).mockResolvedValue(0);
+      (appState.versionsToShow as any) = makeRunnables([VERSION]);
+      (runner.run as jest.Mock).mockResolvedValueOnce(RESULT);
+
+      const result = await requestAndWait(IpcEvents.TASK_TEST, req);
+
+      expect(app.openFiddle).toHaveBeenCalledTimes(1);
+      expect(app.openFiddle).toHaveBeenCalledWith(req.setup.fiddle);
+      expect(appState.hasVersion).toHaveBeenCalledWith(VERSION);
+      expect(appState.hideChannels).not.toHaveBeenCalled();
+      expect(appState.setVersion).toHaveBeenCalledWith(VERSION);
+      expect(appState.showChannels).not.toHaveBeenCalled();
+      expect(result[0]).toBe(IpcEvents.TASK_DONE);
+      expect(result[1]).toBe(RESULT);
+      expect(runner.run).toHaveBeenCalledTimes(1);
+    });
+
+    it('returns invalid if an exception is thrown', async () => {
+      const RESULT = RunResult.INVALID;
+
+      (app.openFiddle as jest.Mock).mockResolvedValue(0);
+      (appState.hasVersion as jest.Mock).mockReturnValueOnce(false);
+      (appState.hideChannels as jest.Mock).mockResolvedValue(0);
+      (appState.setVersion as jest.Mock).mockResolvedValue(0);
+      (appState.showChannels as jest.Mock).mockResolvedValue(0);
+      (runner.run as jest.Mock).mockResolvedValueOnce(RESULT);
+
+      const result = await requestAndWait(IpcEvents.TASK_TEST, req);
+
+      expect(result[0]).toBe(IpcEvents.TASK_DONE);
+      expect(result[1]).toBe(RESULT);
+      expect(appState.hasVersion).toHaveBeenCalledWith(VERSION);
+      expect(appState.setVersion).not.toHaveBeenCalled();
+    });
+  });
+});
diff --git a/tests/renderer/versions-spec.ts b/tests/renderer/versions-spec.ts
index 6a9acf18..ff47cc6a 100644
--- a/tests/renderer/versions-spec.ts
+++ b/tests/renderer/versions-spec.ts
@@ -1,9 +1,12 @@
 import * as fs from 'fs';
 import * as path from 'path';
-import { RunnableVersion, VersionSource } from '../../src/interfaces';
 import {
-  addLocalVersion,
   ElectronReleaseChannel,
+  RunnableVersion,
+  VersionSource,
+} from '../../src/interfaces';
+import {
+  addLocalVersion,
   fetchVersions,
   getDefaultVersion,
   getKnownVersions,
diff --git a/tests/setup.js b/tests/setup.js
index 760becba..54d1682e 100644
--- a/tests/setup.js
+++ b/tests/setup.js
@@ -7,8 +7,12 @@ configure({ adapter: new Adapter() });
 
 global.confirm = jest.fn();
 
-jest.spyOn(global.console, 'log').mockImplementation(() => jest.fn());
-jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn());
+if (!process.env.hasOwnProperty('FIDDLE_VERBOSE_TESTS')) {
+  jest.spyOn(global.console, 'error').mockImplementation(() => jest.fn());
+  jest.spyOn(global.console, 'info').mockImplementation(() => jest.fn());
+  jest.spyOn(global.console, 'log').mockImplementation(() => jest.fn());
+  jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn());
+}
 jest.mock('electron', () => require('./mocks/electron'));
 jest.mock('fs-extra');
 jest.mock('@electron/get');
diff --git a/tests/utils/editors-mosaic-arrangement-spec.ts b/tests/utils/editors-mosaic-arrangement-spec.ts
index cd9225d1..90651118 100644
--- a/tests/utils/editors-mosaic-arrangement-spec.ts
+++ b/tests/utils/editors-mosaic-arrangement-spec.ts
@@ -1,5 +1,8 @@
 import { EditorId } from '../../src/interfaces';
-import { DEFAULT_MOSAIC_ARRANGEMENT } from '../../src/renderer/constants';
+import {
+  DEFAULT_MOSAIC_ARRANGEMENT,
+  SORTED_EDITORS,
+} from '../../src/renderer/constants';
 import {
   createMosaicArrangement,
   getVisibleMosaics,
@@ -27,12 +30,7 @@ describe('Mosaic Arrangement Utilities', () => {
     });
 
     it('creates the correct arrangement for the default visible panels', () => {
-      const result = createMosaicArrangement([
-        EditorId.main,
-        EditorId.renderer,
-        EditorId.preload,
-        EditorId.html,
-      ]);
+      const result = createMosaicArrangement(SORTED_EDITORS.slice(0, 4));
 
       expect(result).toEqual(DEFAULT_MOSAIC_ARRANGEMENT);
     });
@@ -64,7 +62,7 @@ describe('Mosaic Arrangement Utilities', () => {
     it('returns the correct array for the default mosaic', () => {
       const result = getVisibleMosaics(DEFAULT_MOSAIC_ARRANGEMENT);
 
-      expect(result).toEqual(['main', 'renderer', 'preload', 'html']);
+      expect(result).toEqual(SORTED_EDITORS.slice(0, 4));
     });
   });
 });
diff --git a/tests/utils/get-version-range-spec.ts b/tests/utils/get-version-range-spec.ts
new file mode 100644
index 00000000..d30961d2
--- /dev/null
+++ b/tests/utils/get-version-range-spec.ts
@@ -0,0 +1,70 @@
+import * as semver from 'semver';
+
+import { mockVersionsArray } from '../mocks/electron-versions';
+
+import { getVersionRange } from '../../src/utils/get-version-range';
+
+describe('getVersionRange', () => {
+  const knownVersions = [...mockVersionsArray].reverse();
+  const midpoint = Math.floor(knownVersions.length / 2);
+
+  it('returns an empty array if the lower bound cannot be found', () => {
+    const lowerBound = '1.0.0';
+    const upperBound = knownVersions[midpoint].version;
+    // test setup: confirm that one bound is present and the other is not
+    expect(knownVersions.map((v) => v.version)).not.toContain(lowerBound);
+    expect(knownVersions.map((v) => v.version)).toContain(upperBound);
+
+    const versions = getVersionRange(lowerBound, upperBound, knownVersions);
+
+    expect(versions).toEqual([]);
+  });
+
+  it('returns an empty array if the upper bound cannot be found', () => {
+    const lowerBound = knownVersions[midpoint].version;
+    const upperBound = '10000.0.0';
+    // test setup: confirm that one bound is present and the other is not
+    expect(knownVersions.map((v) => v.version)).toContain(lowerBound);
+    expect(knownVersions.map((v) => v.version)).not.toContain(upperBound);
+
+    const versions = getVersionRange(lowerBound, upperBound, knownVersions);
+
+    expect(versions).toEqual([]);
+  });
+
+  it('returns a subset clipped to the bounds', () => {
+    const oldestToNewest = knownVersions;
+    const subset = oldestToNewest.slice(1, -1);
+    const lowerBound = subset[0].version;
+    const upperBound = subset[subset.length - 1].version;
+
+    const versions = getVersionRange(lowerBound, upperBound, oldestToNewest);
+
+    expect(versions).toEqual(subset);
+  });
+
+  it('handles lower bound > upper bound', () => {
+    const oldestToNewest = knownVersions;
+    const lowerBound = knownVersions[knownVersions.length - 1].version;
+    const upperBound = knownVersions[0].version;
+    expect(semver.compare(lowerBound, upperBound)).toBeGreaterThan(0);
+
+    const versions = getVersionRange(lowerBound, upperBound, knownVersions);
+
+    expect(versions).toEqual(oldestToNewest);
+  });
+
+  it('ensures the result is sorted from oldest to newest', () => {
+    const oldestToNewest = knownVersions;
+    const newestToOldest = [...oldestToNewest].reverse();
+    const lowerBound = newestToOldest[newestToOldest.length - 1].version;
+    const upperBound = newestToOldest[0].version;
+    expect(semver.compare(lowerBound, upperBound)).toBeLessThan(0);
+
+    let versions = getVersionRange(lowerBound, upperBound, oldestToNewest);
+    expect(versions).toEqual(oldestToNewest);
+
+    versions = getVersionRange(lowerBound, upperBound, newestToOldest);
+    expect(versions).toEqual(oldestToNewest);
+  });
+});
diff --git a/tests/utils/gist-spec.ts b/tests/utils/gist-spec.ts
index cbe55991..8bc5cd3b 100644
--- a/tests/utils/gist-spec.ts
+++ b/tests/utils/gist-spec.ts
@@ -1,4 +1,4 @@
-import { idFromUrl, urlFromId } from '../../src/utils/gist';
+import { getGistId, idFromUrl, urlFromId } from '../../src/utils/gist';
 
 jest.mock('os', () => ({
   userInfo: () => ({
@@ -37,4 +37,43 @@ describe('gist', () => {
       expect(result).toBe('');
     });
   });
+
+  describe('getGistId', () => {
+    const GIST_ID = 'af3e1a018f5dcce4a2ff40004ef5bab5';
+
+    it('recognizes lowercase gist ids', () => {
+      const expected = GIST_ID.toLowerCase();
+      const input = expected;
+      const actual = getGistId(input);
+      expect(actual).toBe(expected);
+    });
+
+    it('recognizes uppercase gist ids', () => {
+      const expected = GIST_ID.toUpperCase();
+      const input = expected;
+      const actual = getGistId(input);
+      expect(actual).toBe(expected);
+    });
+
+    it('recognizes gist URLs without usernames', () => {
+      const expected = GIST_ID;
+      const input = `https://gist.github.com/${expected}`;
+      const actual = getGistId(input);
+      expect(actual).toBe(expected);
+    });
+
+    it('recognizes gist URLs with usernames', () => {
+      const expected = GIST_ID;
+      const input = `https://gist.github.com/ckerr/${expected}`;
+      const actual = getGistId(input);
+      expect(actual).toBe(expected);
+    });
+
+    it('recognizes gist URLs with trailing `/`', () => {
+      const expected = GIST_ID;
+      const input = `https://gist.github.com/${expected}/`;
+      const actual = getGistId(input);
+      expect(actual).toBe(expected);
+    });
+  });
 });
diff --git a/tests/utils/random-spec.ts b/tests/utils/random-spec.ts
deleted file mode 100644
index a146f363..00000000
--- a/tests/utils/random-spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getRandomNumber } from '../../src/utils/random';
-
-describe('random', () => {
-  it('returns a random number', () => {
-    const min = 1;
-    const max = 999999;
-    const a = getRandomNumber(min, max);
-    const b = getRandomNumber(min, max);
-
-    expect(a).toBeLessThanOrEqual(max);
-    expect(b).toBeLessThanOrEqual(max);
-    expect(a).toBeGreaterThanOrEqual(min);
-    expect(b).toBeGreaterThanOrEqual(min);
-    expect(a === b).toBe(false);
-  });
-
-  it('handles no input', () => {
-    const result = getRandomNumber();
-    expect(result === 0 || result === 1).toBeTruthy();
-  });
-});