Repository: electron/fiddle
Pull request #615 (2021-03-23 21:31:16)

feat: add Runner.autobisect()
Add `Runner.autobisect()` + full coverage testing.

Found a couple of edge cases, e.g. we want to make sure that the 'good' version is actually good and 'bad' version is actually bad -- the user might be wrong / the test doesn't work everywhere / etc. So we do now check that the 'good' version and 'bad' version didn't return the same RunResult.

Getting into the home stretch now; GUI + CLI are up next.

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

Coverage increased (+0.1%) to 93.313% when pulling **6c03d12f0bf0f5930aee274dd8003ff7df6ae059 on feat/autobisect** into **3664ff1aee93b48a578208f31b82a864f66d02c3 on master**.


Patch:
diff --git a/package.json b/package.json
index b5777067..b945d2c9 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,8 @@
     "semver": "^7.3.4",
     "tmp": "0.2.1",
     "tslib": "^2.1.0",
-    "update-electron-app": "^2.0.1"
+    "update-electron-app": "^2.0.1",
+    "valid-url": "^1.0.9"
   },
   "devDependencies": {
     "@babel/core": "^7.13.8",
@@ -85,6 +86,7 @@
     "@types/react-dom": "^16.9.11",
     "@types/semver": "^7.3.4",
     "@types/tmp": "0.2.0",
+    "@types/valid-url": "^1.0.3",
     "@typescript-eslint/eslint-plugin": "^4.15.2",
     "@typescript-eslint/parser": "^4.15.2",
     "cross-fetch": "^3.1.0",
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 25f1c229..2c572c58 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -44,6 +44,12 @@ export interface EditorValues {
   package?: string;
 }
 
+export enum RunResult {
+  SUCCESS = 'success', // exit code === 0
+  FAILURE = 'failure', // ran, but exit code !== 0
+  INVALID = 'invalid', // could not run
+}
+
 export interface RunnableVersion extends Version {
   state: VersionState;
   source: VersionSource;
diff --git a/src/ipc-events.ts b/src/ipc-events.ts
index cde07c2f..0932a403 100644
--- a/src/ipc-events.ts
+++ b/src/ipc-events.ts
@@ -8,6 +8,7 @@ export enum IpcEvents {
   MONACO_EXECUTE_COMMAND = 'MONACO_EXECUTE_COMMAND',
   MONACO_TOGGLE_OPTION = 'MONACO_TOGGLE_OPTION',
   FS_NEW_FIDDLE = 'FS_NEW_FIDDLE',
+  FS_NEW_TEST = 'FS_NEW_TEST',
   FS_OPEN_FIDDLE = 'FS_OPEN_FIDDLE',
   FS_OPEN_TEMPLATE = 'FS_OPEN_TEMPLATE',
   FS_SAVE_FIDDLE = 'FS_SAVE_FIDDLE',
@@ -54,6 +55,7 @@ export const ipcRendererEvents = [
   IpcEvents.MONACO_EXECUTE_COMMAND,
   IpcEvents.MONACO_TOGGLE_OPTION,
   IpcEvents.FS_NEW_FIDDLE,
+  IpcEvents.FS_NEW_TEST,
   IpcEvents.FS_OPEN_FIDDLE,
   IpcEvents.FS_OPEN_TEMPLATE,
   IpcEvents.FS_SAVE_FIDDLE,
diff --git a/src/main/menu.ts b/src/main/menu.ts
index d8390b8b..189a0ef5 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -194,6 +194,14 @@ function getFileMenu(
       },
       accelerator: 'CmdOrCtrl+N',
     },
+    {
+      label: 'New Test',
+      click: () => {
+        ipcMainManager.send(IpcEvents.CLEAR_CONSOLE);
+        return ipcMainManager.send(IpcEvents.FS_NEW_TEST);
+      },
+      accelerator: 'CmdOrCtrl+T',
+    },
     {
       label: 'New Window',
       click: () => createMainWindow(),
diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx
index 88d0d6a8..16709482 100644
--- a/src/renderer/components/editors.tsx
+++ b/src/renderer/components/editors.tsx
@@ -17,7 +17,7 @@ import { getFocusedEditor } from '../../utils/focused-editor';
 import { getAtPath, setAtPath } from '../../utils/js-path';
 import { toggleMonaco } from '../../utils/toggle-monaco';
 import { isEditorId } from '../../utils/type-checks';
-import { getTemplate } from '../content';
+import { getTemplate, getTestTemplate } from '../content';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 import { activateTheme } from '../themes';
@@ -97,6 +97,11 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
       await window.ElectronFiddle.app.replaceFiddle(fiddle, {});
     });
 
+    ipcRendererManager.on(IpcEvents.FS_NEW_TEST, async (_event) => {
+      const fiddle = await getTestTemplate();
+      await window.ElectronFiddle.app.replaceFiddle(fiddle, {});
+    });
+
     ipcRendererManager.on(
       IpcEvents.MONACO_TOGGLE_OPTION,
       (_event, cmd: string) => {
@@ -127,6 +132,7 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
   private stopListening() {
     ipcRendererManager.removeAllListeners(IpcEvents.MONACO_EXECUTE_COMMAND);
     ipcRendererManager.removeAllListeners(IpcEvents.FS_NEW_FIDDLE);
+    ipcRendererManager.removeAllListeners(IpcEvents.FS_NEW_TEST);
     ipcRendererManager.removeAllListeners(IpcEvents.MONACO_TOGGLE_OPTION);
     ipcRendererManager.removeAllListeners(IpcEvents.SELECT_ALL_IN_EDITOR);
   }
diff --git a/src/renderer/components/output.tsx b/src/renderer/components/output.tsx
index 0c1eba35..5ad4f6ab 100644
--- a/src/renderer/components/output.tsx
+++ b/src/renderer/components/output.tsx
@@ -2,6 +2,7 @@ import { autorun } from 'mobx';
 import { observer } from 'mobx-react';
 import * as React from 'react';
 import { MosaicContext } from 'react-mosaic-component';
+import { isWebUri } from 'valid-url';
 
 import { OutputEntry } from '../../interfaces';
 import { AppState } from '../state';
@@ -87,14 +88,24 @@ export class Output extends React.Component<CommandsProps> {
       ? { whiteSpace: 'initial' }
       : {};
 
-    return lines.map((text, lineIndex) => (
-      <div key={`${entry.timestamp}--${index}--${lineIndex}`}>
-        <span style={style} className="output-message">
-          {timestamp}
-          {text}
-        </span>
-      </div>
-    ));
+    const renderLine = (text: string, lineIndex: number): JSX.Element =>
+      isWebUri(text) ? (
+        <div key={`${entry.timestamp}--${index}--${lineIndex}`}>
+          <span style={style} className="output-message">
+            {timestamp}
+            <a href={text}>{text}</a>
+          </span>
+        </div>
+      ) : (
+        <div key={`${entry.timestamp}--${index}--${lineIndex}`}>
+          <span style={style} className="output-message">
+            {timestamp}
+            {text}
+          </span>
+        </div>
+      );
+
+    return lines.map(renderLine);
   }
 
   public render(): JSX.Element | null {
diff --git a/src/renderer/content.ts b/src/renderer/content.ts
index a9dac3bc..51466768 100644
--- a/src/renderer/content.ts
+++ b/src/renderer/content.ts
@@ -18,11 +18,14 @@ const STATIC_TEMPLATE_DIR = path.resolve(
   '../../static/electron-quick-start',
 );
 
+// electron-quick-start branch that holds the test template
+const TEST_TEMPLATE_BRANCH = 'test-template';
+
 /**
  * Ensure we have a fiddle for the specified Electron branch.
  * If we don't have it already, download it from electron-quick-start.
  *
- * @param {string} branch Electron branchname, e.g. `12-x-y` or `master`
+ * @param {string} branch - Electron branchname, e.g. `12-x-y` or `master`
  * @returns {Promise<string>} Path to the folder where the fiddle is kept
  */
 async function prepareTemplate(branch: string): Promise<string> {
@@ -56,49 +59,62 @@ async function prepareTemplate(branch: string): Promise<string> {
 
 const templateCache: Record<string, Promise<EditorValues>> = {};
 
+/**
+ * Get a cached copy of the Electron branch's fiddle
+ *
+ * @param {string} branch - Electron branchname, e.g. `12-x-y` or `master`
+ * @returns {Promise<EditorValues>}
+ */
+function getQuickStart(branch: string): Promise<EditorValues> {
+  // Load the template for that branch.
+  // Cache the work in a Promise to prevent parallel downloads.
+  let pending = templateCache[branch];
+  if (!pending) {
+    console.log(`Content: ${branch} template loading`);
+    pending = prepareTemplate(branch).then(readFiddle);
+    templateCache[branch] = pending;
+  }
+  return pending;
+}
+
+/**
+ * Get a cached copy of the Electron Test fiddle
+ *
+ * @returns {Promise<EditorValues>}
+ */
+export function getTestTemplate(): Promise<EditorValues> {
+  return getQuickStart(TEST_TEMPLATE_BRANCH);
+}
+
 /**
  * Helper to check if this version is from a released major branch.
  *
  * This way when we have a local version of Electron like '999.0.0'
  * we'll know to not try & download 999-x-y.zip from GitHub :D
  *
- * @param {string} [version]
+ * @param {semver.SemVer} version - Electron version, e.g. 12.0.0
  * @returns {boolean} true if major version is a known release
  */
-function isReleasedMajor(version?: string) {
+function isReleasedMajor(version: semver.SemVer) {
   const newestRelease = getElectronVersions()
     .filter((version) => version.source === VersionSource.remote)
     .map((version) => semver.parse(version.version))
     .filter((version) => !!version)
-    .sort((a: semver.SemVer, b: semver.SemVer) => semver.compare(a, b))
-    .pop();
-  const parsed = semver.parse(version);
-  return parsed && newestRelease && parsed.major <= newestRelease.major;
+    .reduce((acc, cur) => (acc && acc.compare(cur!) > 0 ? acc : cur));
+  return newestRelease && version.major <= newestRelease.major;
 }
 
 /**
  * Get a cached copy of the fiddle for the specified Electron version.
  *
- * @param {string} [version]
+ * @param {string} version - Electron version, e.g. 12.0.0
  * @returns {Promise<EditorValues>}
  */
-export async function getTemplate(version?: string): Promise<EditorValues> {
-  // get the branch
-  const parsed = semver.parse(version);
-  const branch: string = parsed?.major ? `${parsed.major}-x-y` : 'master';
-
-  // Load the template for that branch.
-  // Cache the work in a Promise to prevent parallel downloads.
-  let pending = templateCache[branch];
-  if (!pending) {
-    console.log(`Content: ${branch} template loading`);
-    pending = isReleasedMajor(version)
-      ? prepareTemplate(branch).then(readFiddle)
-      : readFiddle(STATIC_TEMPLATE_DIR);
-    templateCache[branch] = pending;
-  }
-
-  return pending;
+export function getTemplate(version: string): Promise<EditorValues> {
+  const sem = semver.parse(version);
+  return sem && isReleasedMajor(sem)
+    ? getQuickStart(`${sem.major}-x-y`)
+    : readFiddle(STATIC_TEMPLATE_DIR);
 }
 
 /**
@@ -106,12 +122,12 @@ export async function getTemplate(version?: string): Promise<EditorValues> {
  *
  * @export
  * @param {EditorId} name
- * @param {string} [version]
+ * @param {string} version
  * @returns {Promise<string>}
  */
 export async function getContent(
   name: EditorId,
-  version?: string,
+  version: string,
 ): Promise<string> {
   return (await getTemplate(version))[name];
 }
@@ -120,14 +136,18 @@ export async function getContent(
  * Did the content change?
  *
  * @param {EditorId} name
+ * @param {string} version - Electron version, e.g. 12.0.0
  * @returns {Promise<boolean>}
  */
-export async function isContentUnchanged(name: EditorId): Promise<boolean> {
+export async function isContentUnchanged(
+  name: EditorId,
+  version: string,
+): Promise<boolean> {
   if (!window.ElectronFiddle || !window.ElectronFiddle.app) return false;
 
   const values = await window.ElectronFiddle.app.getEditorValues({
     include: false,
   });
 
-  return values[name] === (await getContent(name));
+  return values[name] === (await getContent(name, version));
 }
diff --git a/src/renderer/runner.ts b/src/renderer/runner.ts
index f0fa0c9f..d3d12046 100644
--- a/src/renderer/runner.ts
+++ b/src/renderer/runner.ts
@@ -1,11 +1,17 @@
 import { ChildProcess, spawn } from 'child_process';
 import * as path from 'path';
 
-import { EditorValues, FileTransform } from '../interfaces';
+import {
+  EditorValues,
+  FileTransform,
+  RunResult,
+  RunnableVersion,
+} from '../interfaces';
 import { IpcEvents } from '../ipc-events';
 import { PackageJsonOptions } from '../utils/get-package';
 import { maybePlural } from '../utils/plural-maybe';
 import { getElectronBinaryPath, getIsDownloaded } from './binary';
+import { Bisector } from './bisect';
 import { ipcRendererManager } from './ipc';
 import {
   findModulesInEditors,
@@ -21,6 +27,12 @@ export enum ForgeCommands {
   MAKE = 'make',
 }
 
+const resultString: Record<RunResult, string> = Object.freeze({
+  [RunResult.FAILURE]: '❌ failed',
+  [RunResult.INVALID]: '❓ invalid',
+  [RunResult.SUCCESS]: '✅ passed',
+});
+
 export class Runner {
   public child: ChildProcess | null = null;
 
@@ -41,12 +53,87 @@ export class Runner {
     });
   }
 
+  /**
+   * Bisect the current fiddle across the specified versions.
+   *
+   * @param {Array<RunnableVersion>} versions - versions to bisect
+   * @returns {Promise<RunResult>}
+   * @memberof Runner
+   */
+  public async autobisect(
+    versions: Array<RunnableVersion>,
+  ): Promise<RunResult> {
+    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',
+      );
+      return RunResult.INVALID;
+    }
+
+    const results: Map<string, RunResult> = new Map();
+
+    const runVersion = async (version: string) => {
+      let result = results.get(version);
+      if (result === undefined) {
+        await appState.setVersion(version);
+        result = await this.run();
+        results.set(version, result);
+        const msg = `Runner: autobisect Electron ${version} - ${resultString[result]}`;
+        appState.pushOutput(msg);
+      }
+      return result;
+    };
+
+    const bisector = new Bisector(versions);
+    let targetVersion = bisector.getCurrentVersion();
+    let next;
+    while (true) {
+      const { version } = targetVersion;
+      appState.pushOutput(`Testing ${version}`, { isNotPre: true });
+
+      const result = await runVersion(version);
+      if (result === RunResult.INVALID) {
+        return result;
+      }
+
+      next = bisector.continue(result === RunResult.SUCCESS);
+      if (Array.isArray(next)) {
+        break;
+      }
+
+      targetVersion = next;
+    }
+
+    const [good, bad] = next.map((v) => v.version);
+    const resultGood = await runVersion(good);
+    const resultBad = await runVersion(bad);
+    if (resultGood === resultBad) {
+      appState.pushOutput(
+        `Runner: autobisect '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:',
+      `https://github.com/electron/electron/compare/v${good}...v${bad}`,
+    ];
+    msgs.forEach((msg) => appState.pushOutput(msg));
+    return RunResult.SUCCESS;
+  }
+
   /**
    * Actually run the fiddle.
    *
-   * @returns {Promise<boolean>}
+   * @returns {Promise<RunResult>}
    */
-  public async run(): Promise<boolean> {
+  public async run(): Promise<RunResult> {
     const { fileManager, getEditorValues } = window.ElectronFiddle.app;
     const options = { includeDependencies: false, includeElectron: false };
     const { currentElectronVersion } = this.appState;
@@ -61,14 +148,14 @@ export class Runner {
     const dir = await this.saveToTemp(options);
     const packageManager = this.appState.packageManager;
 
-    if (!dir) return false;
+    if (!dir) return RunResult.INVALID;
 
     try {
       await this.installModulesForEditor(values, { dir, packageManager });
     } catch (error) {
       console.error('Runner: Could not install modules', error);
       fileManager.cleanup(dir);
-      return false;
+      return RunResult.INVALID;
     }
 
     const isReady = getIsDownloaded(version, localPath);
@@ -83,22 +170,22 @@ export class Runner {
 
       this.appState.pushOutput(message, { isNotPre: true });
       fileManager.cleanup(dir);
-      return false;
+      return RunResult.INVALID;
     }
 
-    this.execute(dir);
-
-    return true;
+    return this.execute(dir);
   }
 
   /**
    * Stop a currently running Electron fiddle.
+   *
+   * @returns {boolean} true if runner is now idle
+   * @memberof Runner
    */
-  public async stop() {
-    if (this.child) {
-      this.child.kill();
-      this.appState.isRunning = false;
-    }
+  public stop(): boolean {
+    this.appState.isRunning = !!this.child && !this.child.kill();
+    const isIdle = !this.appState.isRunning;
+    return isIdle;
   }
 
   /**
@@ -214,7 +301,7 @@ export class Runner {
    * @returns {Promise<void>}
    * @memberof Runner
    */
-  public async execute(dir: string): Promise<void> {
+  public async execute(dir: string): Promise<RunResult> {
     const { currentElectronVersion, pushOutput } = this.appState;
     const { version, localPath } = currentElectronVersion;
     const binaryPath = getElectronBinaryPath(version, localPath);
@@ -234,27 +321,36 @@ export class Runner {
     // Add user-specified cli flags if any have been set.
     const options = [dir, '--inspect'].concat(this.appState.executionFlags);
 
-    this.child = spawn(binaryPath, options, { cwd: dir, env });
-    this.appState.isRunning = true;
-    pushOutput(`Electron v${version} started.`);
+    return new Promise((resolve, _reject) => {
+      this.child = spawn(binaryPath, options, { cwd: dir, env });
+      this.appState.isRunning = true;
+      pushOutput(`Electron v${version} started.`);
 
-    this.child.stdout!.on('data', (data) =>
-      pushOutput(data, { bypassBuffer: false }),
-    );
-    this.child.stderr!.on('data', (data) =>
-      pushOutput(data, { bypassBuffer: false }),
-    );
-    this.child.on('close', async (code) => {
-      const withCode =
-        typeof code === 'number' ? ` with code ${code.toString()}.` : `.`;
-
-      pushOutput(`Electron exited${withCode}`);
-      this.appState.isRunning = false;
-      this.child = null;
-
-      // Clean older folders
-      await window.ElectronFiddle.app.fileManager.cleanup(dir);
-      await this.deleteUserData();
+      this.child.stdout!.on('data', (data) =>
+        pushOutput(data, { bypassBuffer: false }),
+      );
+      this.child.stderr!.on('data', (data) =>
+        pushOutput(data, { bypassBuffer: false }),
+      );
+      this.child.on('close', async (code) => {
+        this.appState.isRunning = false;
+        this.child = null;
+
+        // Clean older folders
+        await window.ElectronFiddle.app.fileManager.cleanup(dir);
+        await this.deleteUserData();
+
+        if (typeof code !== 'number') {
+          pushOutput('Electron exited.');
+          resolve(RunResult.INVALID);
+        } else if (!code) {
+          pushOutput(`Electron exited with code ${code}.`);
+          resolve(RunResult.SUCCESS);
+        } else {
+          pushOutput(`Electron exited with code ${code}.`);
+          resolve(RunResult.FAILURE);
+        }
+      });
     });
   }
 
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index 971a7ef0..915f62b0 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -533,14 +533,14 @@ export class AppState {
 
     console.log(`State: Switching to Electron ${version}`);
 
-    this.version = version;
-
     // Should we update the editor?
-    if (await isContentUnchanged(EditorId.main)) {
+    if (await isContentUnchanged(EditorId.main, this.version)) {
       const editorValues = await getTemplate(version);
       await window.ElectronFiddle.app.replaceFiddle(editorValues, {});
     }
 
+    this.version = version;
+
     // Update TypeScript definitions
     const versionObject = this.versions[version];
 
diff --git a/yarn.lock b/yarn.lock
index d5bcbfa9..925240d6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2349,6 +2349,11 @@
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
   integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
 
+"@types/valid-url@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/valid-url/-/valid-url-1.0.3.tgz#a124389fb953559c7f889795a98620e91adb3687"
+  integrity sha512-+33x29mg+ecU88ODdWpqaie2upIuRkhujVLA7TuJjM823cNMbeggfI6NhxewaRaRF8dy+g33e4uIg/m5Mb3xDQ==
+
 "@types/yargs-parser@*":
   version "20.2.0"
   resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9"
@@ -12721,6 +12726,11 @@ v8-to-istanbul@^7.0.0:
     convert-source-map "^1.6.0"
     source-map "^0.7.3"
 
+valid-url@^1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
+  integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=
+
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"


Test patch:
diff --git a/tests/main/menu-spec.ts b/tests/main/menu-spec.ts
index 1275d724..3ff9853f 100644
--- a/tests/main/menu-spec.ts
+++ b/tests/main/menu-spec.ts
@@ -274,12 +274,13 @@ describe('menu', () => {
       let file: any;
       enum Idx {
         NEW_FIDDLE = 0,
-        NEW_WINDOW = 1,
-        OPEN = 3,
-        SAVE = 5,
-        SAVE_AS = 6,
-        PUBLISH = 8,
-        FORGE = 9,
+        NEW_TEST = 1,
+        NEW_WINDOW = 2,
+        OPEN = 4,
+        SAVE = 6,
+        SAVE_AS = 7,
+        PUBLISH = 9,
+        FORGE = 10,
       }
 
       beforeEach(() => {
@@ -295,6 +296,13 @@ describe('menu', () => {
         );
       });
 
+      it('creates a new fiddle', () => {
+        file.submenu[Idx.NEW_TEST].click();
+        expect(ipcMainManager.send).toHaveBeenCalledWith<any>(
+          IpcEvents.FS_NEW_TEST,
+        );
+      });
+
       it('creates a new window', () => {
         file.submenu[Idx.NEW_WINDOW].click();
         file.submenu[Idx.NEW_WINDOW].click();
diff --git a/tests/mocks/electron-versions.ts b/tests/mocks/electron-versions.ts
index df42ae1f..649861de 100644
--- a/tests/mocks/electron-versions.ts
+++ b/tests/mocks/electron-versions.ts
@@ -6,6 +6,11 @@ import {
 import { arrayToStringMap } from '../../src/utils/array-to-stringmap';
 
 export const mockVersionsArray = [
+  {
+    state: VersionState.ready,
+    version: '2.0.3',
+    source: VersionSource.remote,
+  },
   {
     state: VersionState.ready,
     version: '2.0.2',
@@ -16,6 +21,11 @@ export const mockVersionsArray = [
     version: '2.0.1',
     source: VersionSource.remote,
   },
+  {
+    state: VersionState.ready,
+    version: '2.0.0',
+    source: VersionSource.remote,
+  },
   {
     state: VersionState.ready,
     version: '1.8.7',
diff --git a/tests/mocks/runner.ts b/tests/mocks/runner.ts
index e61015a4..8f07bdeb 100644
--- a/tests/mocks/runner.ts
+++ b/tests/mocks/runner.ts
@@ -1,4 +1,5 @@
 export class RunnerMock {
+  public autobisect = jest.fn();
   public run = jest.fn();
   public stop = jest.fn();
 }
diff --git a/tests/renderer/components/__snapshots__/commands-version-chooser-spec.tsx.snap b/tests/renderer/components/__snapshots__/commands-version-chooser-spec.tsx.snap
index 934b97d5..84940e53 100644
--- a/tests/renderer/components/__snapshots__/commands-version-chooser-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/commands-version-chooser-spec.tsx.snap
@@ -31,6 +31,11 @@ exports[`VersionSelect component renders 1`] = `
             "state": "ready",
             "version": "1.8.7",
           },
+          "2.0.0": Object {
+            "source": "remote",
+            "state": "ready",
+            "version": "2.0.0",
+          },
           "2.0.1": Object {
             "source": "remote",
             "state": "ready",
@@ -41,6 +46,11 @@ exports[`VersionSelect component renders 1`] = `
             "state": "ready",
             "version": "2.0.2",
           },
+          "2.0.3": Object {
+            "source": "remote",
+            "state": "ready",
+            "version": "2.0.3",
+          },
           "3.0.0-unsupported": Object {
             "source": "remote",
             "state": "unknown",
@@ -49,6 +59,11 @@ exports[`VersionSelect component renders 1`] = `
           "3.1.3": undefined,
         },
         "versionsToShow": Array [
+          Object {
+            "source": "remote",
+            "state": "ready",
+            "version": "2.0.3",
+          },
           Object {
             "source": "remote",
             "state": "ready",
@@ -59,6 +74,11 @@ exports[`VersionSelect component renders 1`] = `
             "state": "ready",
             "version": "2.0.1",
           },
+          Object {
+            "source": "remote",
+            "state": "ready",
+            "version": "2.0.0",
+          },
           Object {
             "source": "remote",
             "state": "ready",
diff --git a/tests/renderer/components/__snapshots__/settings-electron-spec.tsx.snap b/tests/renderer/components/__snapshots__/settings-electron-spec.tsx.snap
index b56a08fb..401141d8 100644
--- a/tests/renderer/components/__snapshots__/settings-electron-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/settings-electron-spec.tsx.snap
@@ -204,6 +204,33 @@ exports[`ElectronSettings component renders 1`] = `
             />
           </td>
         </tr>
+        <tr
+          key="2.0.3"
+        >
+          <td>
+            2.0.3
+          </td>
+          <td>
+            <span>
+              <Blueprint3.Icon
+                icon="box"
+              />
+               
+              Downloaded
+            </span>
+          </td>
+          <td
+            className="action"
+          >
+            <Blueprint3.Button
+              fill={true}
+              icon="trash"
+              onClick={[Function]}
+              small={true}
+              text="Delete"
+            />
+          </td>
+        </tr>
         <tr
           key="2.0.2"
         >
@@ -259,6 +286,33 @@ exports[`ElectronSettings component renders 1`] = `
             />
           </td>
         </tr>
+        <tr
+          key="2.0.0"
+        >
+          <td>
+            2.0.0
+          </td>
+          <td>
+            <span>
+              <Blueprint3.Icon
+                icon="box"
+              />
+               
+              Downloaded
+            </span>
+          </td>
+          <td
+            className="action"
+          >
+            <Blueprint3.Button
+              fill={true}
+              icon="trash"
+              onClick={[Function]}
+              small={true}
+              text="Delete"
+            />
+          </td>
+        </tr>
       </tbody>
     </HTMLTable>
   </Blueprint3.Callout>
diff --git a/tests/renderer/components/editors-spec.tsx b/tests/renderer/components/editors-spec.tsx
index 804cc123..19f02891 100644
--- a/tests/renderer/components/editors-spec.tsx
+++ b/tests/renderer/components/editors-spec.tsx
@@ -257,6 +257,33 @@ describe('Editors component', () => {
       });
     });
 
+    it('handles an FS_NEW_TEST command', async () => {
+      const { app } = window.ElectronFiddle;
+
+      // setup
+      const getTestTemplateSpy = jest
+        .spyOn(content, 'getTestTemplate')
+        .mockImplementation(() => Promise.resolve(fakeValues));
+      let replaceResolve: any;
+      const replacePromise = new Promise((r) => {
+        replaceResolve = r;
+      });
+      const replaceFiddleSpy = jest
+        .spyOn(app, 'replaceFiddle')
+        .mockImplementation(() => replaceResolve());
+
+      // invoke the call
+      ipcRendererManager.emit(IpcEvents.FS_NEW_TEST);
+      await replacePromise;
+
+      // check the results
+      expect(getTestTemplateSpy).toHaveBeenCalled();
+      expect(replaceFiddleSpy).toHaveBeenCalled();
+
+      // cleanup
+      getTestTemplateSpy.mockRestore();
+      replaceFiddleSpy.mockRestore();
+    });
     it('handles a SELECT_ALL_IN_EDITOR command', (done) => {
       shallow(<Editors appState={store} />);
       const mockEditor = {
diff --git a/tests/renderer/content-spec.ts b/tests/renderer/content-spec.ts
index 120aedce..0d936c7b 100644
--- a/tests/renderer/content-spec.ts
+++ b/tests/renderer/content-spec.ts
@@ -3,7 +3,12 @@ import * as path from 'path';
 import { Response } from 'cross-fetch';
 
 import { EditorId } from '../../src/interfaces';
-import { getContent, isContentUnchanged } from '../../src/renderer/content';
+import {
+  getContent,
+  getTemplate,
+  getTestTemplate,
+  isContentUnchanged,
+} from '../../src/renderer/content';
 
 jest.unmock('fs-extra');
 
@@ -38,6 +43,28 @@ const fetchFromFilesystem = (url: string) => {
 };
 
 describe('content', () => {
+  const VERSION_IN_FIXTURES = '11.0.0';
+  const VERSION_NOT_IN_FIXTURES = '10.0.0';
+
+  describe('getTestTemplate()', () => {
+    beforeEach(() => {
+      // @ts-ignore: force 'any'; fetch's param type is private / inaccessible
+      jest.spyOn(global, 'fetch').mockImplementation(fetchFromFilesystem);
+    });
+
+    afterEach(() => {
+      (global.fetch as jest.Mock).mockClear();
+    });
+
+    it('loads a test template', async () => {
+      await getTestTemplate();
+      expect(global.fetch as jest.Mock).toHaveBeenCalledTimes(1);
+      expect(global.fetch as jest.Mock).toHaveBeenLastCalledWith(
+        'https://github.com/electron/electron-quick-start/archive/test-template.zip',
+      );
+    });
+  });
+
   describe('getContent()', () => {
     beforeEach(() => {
       // @ts-ignore: force 'any'; fetch's param type is private / inaccessible
@@ -48,15 +75,18 @@ describe('content', () => {
     });
 
     it('returns content for HTML editor', async () => {
-      expect(await getContent(EditorId.html)).toBeTruthy();
+      const curVer = VERSION_IN_FIXTURES;
+      expect(await getContent(EditorId.html, curVer)).toBeTruthy();
     });
 
     it('returns content for the renderer editor', async () => {
-      expect(await getContent(EditorId.renderer)).toBeTruthy();
+      const curVer = VERSION_IN_FIXTURES;
+      expect(await getContent(EditorId.renderer, curVer)).toBeTruthy();
     });
 
     it('returns content for the main editor', async () => {
-      expect(await getContent(EditorId.main)).toBeTruthy();
+      const curVer = VERSION_IN_FIXTURES;
+      expect(await getContent(EditorId.main, curVer)).toBeTruthy();
     });
 
     it('returns fallback content for an unparsable version', async () => {
@@ -68,34 +98,52 @@ describe('content', () => {
     });
 
     it('downloads and returns content for known versions', async () => {
-      const content = await getContent(EditorId.html, '11.0.0');
+      const content = await getContent(EditorId.html, VERSION_IN_FIXTURES);
       expect(lastResponse).toMatchObject({ status: 200 });
       expect(content).toMatch(/^<!DOCTYPE html>/);
     });
 
     it('provides fallback content if downloads fail', async () => {
-      const VERSION_NOT_IN_FIXTURES = '10.0.0';
       const content = await getContent(EditorId.html, VERSION_NOT_IN_FIXTURES);
       expect(lastResponse).toMatchObject({ status: 404 });
       expect(content).toMatch(/^<!DOCTYPE html>/);
     });
 
     it('returns the same content when called multiple times', async () => {
+      const curVer = VERSION_IN_FIXTURES;
       const id = EditorId.main;
+      const expected = await getContent(id, curVer);
+
       const numTries = 3;
-      const expected = await getContent(id);
       for (let i = 0; i < numTries; ++i) {
-        const content = await getContent(EditorId.main);
+        const content = await getContent(id, curVer);
         expect(content).toEqual(expected);
       }
     });
   });
 
+  describe('getTemplate()', () => {
+    beforeEach(() => {
+      // @ts-ignore: force 'any'; fetch's param type is private / inaccessible
+      jest.spyOn(global, 'fetch').mockImplementation(fetchFromFilesystem);
+    });
+    afterEach(() => {
+      (global.fetch as jest.Mock).mockClear();
+    });
+
+    it('returns the same promise if the work is already pending', async () => {
+      const prom1 = getTemplate(VERSION_IN_FIXTURES);
+      const prom2 = getTemplate(VERSION_IN_FIXTURES);
+      expect(prom1).toEqual(prom2);
+    });
+  });
+
   describe('isContentUnchanged()', () => {
+    const curVer = VERSION_IN_FIXTURES;
+
     it('returns false if app is not available', async () => {
       (window.ElectronFiddle.app as any) = null;
-
-      const isUnchanged = await isContentUnchanged(EditorId.main);
+      const isUnchanged = await isContentUnchanged(EditorId.main, curVer);
       expect(isUnchanged).toBe(false);
     });
 
@@ -106,27 +154,17 @@ describe('content', () => {
           main: 'hi',
         });
 
-        const isUnchanged = await isContentUnchanged(EditorId.main);
+        const isUnchanged = await isContentUnchanged(EditorId.main, curVer);
         expect(isUnchanged).toBe(false);
       });
 
       it('returns true if it did not change', async () => {
         (window.ElectronFiddle.app
           .getEditorValues as jest.Mock<any>).mockReturnValueOnce({
-          main: await getContent(EditorId.main),
-        });
-
-        const isUnchanged = await isContentUnchanged(EditorId.main);
-        expect(isUnchanged).toBe(true);
-      });
-
-      it('returns true if it did not change (1.0 version)', async () => {
-        (window.ElectronFiddle.app
-          .getEditorValues as jest.Mock<any>).mockReturnValueOnce({
-          main: await getContent(EditorId.main, '1-x-y'),
+          main: await getContent(EditorId.main, curVer),
         });
 
-        const isUnchanged = await isContentUnchanged(EditorId.main);
+        const isUnchanged = await isContentUnchanged(EditorId.main, curVer);
         expect(isUnchanged).toBe(true);
       });
     });
@@ -138,17 +176,17 @@ describe('content', () => {
           renderer: 'hi',
         });
 
-        const isUnchanged = await isContentUnchanged(EditorId.renderer);
+        const isUnchanged = await isContentUnchanged(EditorId.renderer, curVer);
         expect(isUnchanged).toBe(false);
       });
 
       it('returns true if it did not change', async () => {
         (window.ElectronFiddle.app
           .getEditorValues as jest.Mock<any>).mockReturnValueOnce({
-          renderer: await getContent(EditorId.renderer),
+          renderer: await getContent(EditorId.renderer, curVer),
         });
 
-        const isUnchanged = await isContentUnchanged(EditorId.renderer);
+        const isUnchanged = await isContentUnchanged(EditorId.renderer, curVer);
         expect(isUnchanged).toBe(true);
       });
     });
diff --git a/tests/renderer/runner-spec.tsx b/tests/renderer/runner-spec.tsx
index a67202e5..4e655007 100644
--- a/tests/renderer/runner-spec.tsx
+++ b/tests/renderer/runner-spec.tsx
@@ -1,9 +1,11 @@
 import { spawn } from 'child_process';
 import * as path from 'path';
+import * as semver from 'semver';
 
 import { IpcEvents } from '../../src/ipc-events';
 import { getIsDownloaded } from '../../src/renderer/binary';
 import { ipcRendererManager } from '../../src/renderer/ipc';
+import { RunResult } from '../../src/interfaces';
 import {
   findModulesInEditors,
   getIsPackageManagerInstalled,
@@ -11,10 +13,11 @@ import {
   packageRun,
 } from '../../src/renderer/npm';
 import { ForgeCommands, Runner } from '../../src/renderer/runner';
+import { waitFor } from '../../src/utils/wait-for';
 import { AppState } from '../../src/renderer/state';
 import { MockChildProcess } from '../mocks/child-process';
 import { ElectronFiddleMock } from '../mocks/electron-fiddle';
-import { mockVersions } from '../mocks/electron-versions';
+import { mockVersions, mockVersionsArray } from '../mocks/electron-versions';
 
 jest.mock('../../src/renderer/npm');
 jest.mock('../../src/renderer/file-manager');
@@ -50,6 +53,10 @@ describe('Runner component', () => {
         return mockVersions['2.0.2'];
       },
       getName: async () => 'test-app-name',
+      setVersion: (version: string) => {
+        store.version = version;
+        return true;
+      },
     };
 
     (window as any).ElectronFiddle = new ElectronFiddleMock();
@@ -58,104 +65,153 @@ describe('Runner component', () => {
     instance = new Runner(store as AppState);
   });
 
-  it('runs', async () => {
-    (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
-    (spawn as any).mockReturnValueOnce(mockChild);
+  describe('run()', () => {
+    it('runs', async () => {
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as any).mockReturnValueOnce(mockChild);
+
+      // wait for run() to get running
+      const runPromise = instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
 
-    expect(await instance.run()).toBe(true);
-    expect(getIsDownloaded).toHaveBeenCalled();
-    expect(window.ElectronFiddle.app.fileManager.saveToTemp).toHaveBeenCalled();
-    expect(installModules).toHaveBeenCalled();
-    expect(store.isRunning).toBe(true);
-  });
+      // child process exits with success
+      setTimeout(() => mockChild.emit('close', 0));
+      const result = await runPromise;
 
-  it('runs with logging when enabled', async () => {
-    store.isEnablingElectronLogging = true;
-    (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
-    (spawn as jest.Mock).mockImplementationOnce((_, __, opts) => {
-      expect(opts.env).toHaveProperty('ELECTRON_ENABLE_LOGGING');
-      expect(opts.env).toHaveProperty('ELECTRON_ENABLE_STACK_DUMPING');
-      return mockChild;
+      expect(result).toBe(RunResult.SUCCESS);
+      expect(store.isRunning).toBe(false);
+      expect(getIsDownloaded).toHaveBeenCalled();
+      expect(
+        window.ElectronFiddle.app.fileManager.saveToTemp,
+      ).toHaveBeenCalled();
+      expect(installModules).toHaveBeenCalled();
     });
 
-    expect(await instance.run()).toBe(true);
-    expect(getIsDownloaded).toHaveBeenCalled();
-    expect(window.ElectronFiddle.app.fileManager.saveToTemp).toHaveBeenCalled();
-    expect(installModules).toHaveBeenCalled();
-    expect(store.isRunning).toBe(true);
-  });
+    it('runs with logging when enabled', async () => {
+      store.isEnablingElectronLogging = true;
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as jest.Mock).mockImplementationOnce((_, __, opts) => {
+        expect(opts.env).toHaveProperty('ELECTRON_ENABLE_LOGGING');
+        expect(opts.env).toHaveProperty('ELECTRON_ENABLE_STACK_DUMPING');
+        return mockChild;
+      });
 
-  it('emits output', async () => {
-    (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
-    (spawn as any).mockReturnValueOnce(mockChild);
+      // wait for run() to get running
+      const runPromise = instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
 
-    // Output
-    expect(await instance.run()).toBe(true);
-    mockChild.stdout.emit('data', 'hi');
-    mockChild.stderr.emit('data', 'hi');
-    expect(store.pushOutput).toHaveBeenCalledTimes(7);
+      // child process exits with success
+      setTimeout(() => mockChild.emit('close', 0));
+      const result = await runPromise;
 
-    // Stop (with code)
-    mockChild.emit('close', 0);
-    expect(store.pushOutput).toHaveBeenLastCalledWith(
-      'Electron exited with code 0.',
-    );
+      expect(result).toBe(RunResult.SUCCESS);
+      expect(store.isRunning).toBe(false);
+      expect(getIsDownloaded).toHaveBeenCalled();
+      expect(
+        window.ElectronFiddle.app.fileManager.saveToTemp,
+      ).toHaveBeenCalled();
+      expect(installModules).toHaveBeenCalled();
+    });
 
-    // Stop (without code)
-    mockChild.emit('close');
-    expect(store.pushOutput).toHaveBeenLastCalledWith('Electron exited.');
+    it('emits output with exitCode', async () => {
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as any).mockReturnValueOnce(mockChild);
 
-    expect(store.isRunning).toBe(false);
-  });
+      // wait for run() to get running
+      const runPromise = instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
 
-  it('stops on close', async () => {
-    (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
-    (spawn as any).mockReturnValueOnce(mockChild);
+      // mock child process gives output,
+      // then exits with exitCode 0
+      mockChild.stdout.emit('data', 'hi');
+      mockChild.stderr.emit('data', 'hi');
+      mockChild.emit('close', 0);
 
-    // Stop
-    expect(await instance.run()).toBe(true);
-    expect(store.isRunning).toBe(true);
-    instance.stop();
-    expect(store.isRunning).toBe(false);
-  });
+      const result = await runPromise;
 
-  it('stops on stop()', async () => {
-    (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
-    (spawn as any).mockReturnValueOnce(mockChild);
+      expect(result).toBe(RunResult.SUCCESS);
+      expect(store.isRunning).toBe(false);
+      expect(store.pushOutput).toHaveBeenCalledTimes(8);
+      expect(store.pushOutput).toHaveBeenLastCalledWith(
+        'Electron exited with code 0.',
+      );
+    });
 
-    // Stop
-    expect(await instance.run()).toBe(true);
-    mockChild.emit('close', 0);
-    expect(store.isRunning).toBe(false);
-  });
+    it('returns failure when app exits nonzero', async () => {
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as any).mockReturnValueOnce(mockChild);
+      const ARBITRARY_FAIL_CODE = 50;
+
+      // wait for run() to get running
+      const runPromise = instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
+
+      // mock child process exits with ARBITRARY_FAIL_CODE
+      mockChild.emit('close', ARBITRARY_FAIL_CODE);
+      const result = await runPromise;
+
+      expect(result).toBe(RunResult.FAILURE);
+      expect(store.isRunning).toBe(false);
+      expect(store.pushOutput).toHaveBeenLastCalledWith(
+        `Electron exited with code ${ARBITRARY_FAIL_CODE}.`,
+      );
+    });
+
+    it('emits output without exitCode', async () => {
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as any).mockReturnValueOnce(mockChild);
+
+      // wait for run() to get running
+      const runPromise = instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
+
+      // mock child process gives output,
+      // then exits without an explicit exitCode
+      mockChild.stdout.emit('data', 'hi');
+      mockChild.stderr.emit('data', 'hi');
+      mockChild.emit('close');
+      const result = await runPromise;
+
+      expect(result).toBe(RunResult.INVALID);
+      expect(store.isRunning).toBe(false);
+      expect(store.pushOutput).toHaveBeenCalledTimes(8);
+      expect(store.pushOutput).toHaveBeenLastCalledWith('Electron exited.');
+    });
 
-  describe('run()', () => {
     it('cleans the app data dir after a run', async (done) => {
+      // get run() out of the way
       (spawn as any).mockReturnValueOnce(mockChild);
-      expect(await instance.run()).toBe(true);
-      mockChild.emit('close', 0);
+      setTimeout(() => mockChild.emit('close', 0));
+      const result = await instance.run();
 
+      expect(result).toBe(RunResult.SUCCESS);
       process.nextTick(() => {
-        expect(
-          window.ElectronFiddle.app.fileManager.cleanup,
-        ).toHaveBeenCalledTimes(2);
-        expect(
-          window.ElectronFiddle.app.fileManager.cleanup,
-        ).toHaveBeenLastCalledWith(path.join(`/test-path/test-app-name`));
+        const { cleanup } = window.ElectronFiddle.app.fileManager;
+        expect(cleanup).toHaveBeenCalledTimes(2);
+        expect(cleanup).toHaveBeenLastCalledWith(
+          path.join(`/test-path/test-app-name`),
+        );
         done();
       });
     });
 
     it('does not clean the app data dir after a run if configured', async (done) => {
       (instance as any).appState.isKeepingUserDataDirs = true;
+
+      // get run() out of the way
       (spawn as any).mockReturnValueOnce(mockChild);
-      expect(await instance.run()).toBe(true);
-      mockChild.emit('close', 0);
+      setTimeout(() => mockChild.emit('close', 0));
+      const result = await instance.run();
 
+      expect(result).toBe(RunResult.SUCCESS);
       process.nextTick(() => {
-        expect(
-          window.ElectronFiddle.app.fileManager.cleanup,
-        ).toHaveBeenCalledTimes(1);
+        const { cleanup } = window.ElectronFiddle.app.fileManager;
+        expect(cleanup).toHaveBeenCalledTimes(1);
         done();
       });
     });
@@ -163,25 +219,27 @@ describe('Runner component', () => {
     it('automatically cleans the console when enabled', async () => {
       store.isClearingConsoleOnRun = true;
       (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+
+      // get run() out of the way
       (spawn as any).mockReturnValueOnce(mockChild);
-      expect(await instance.run()).toBe(true);
+      setTimeout(() => mockChild.emit('close', 0));
+      const result = await instance.run();
+
+      expect(result).toBe(RunResult.SUCCESS);
       expect(store.clearConsole).toHaveBeenCalled();
-      mockChild.emit('close', 0);
     });
 
     it('does not run version not yet downloaded', async () => {
       (getIsDownloaded as jest.Mock).mockReturnValueOnce(false);
 
-      expect(await instance.run()).toBe(false);
+      expect(await instance.run()).toBe(RunResult.INVALID);
     });
 
     it('does not run if writing files fails', async () => {
       (window.ElectronFiddle.app.fileManager
-        .saveToTemp as jest.Mock).mockImplementationOnce(() => {
-        throw new Error('bwap bwap');
-      });
+        .saveToTemp as jest.Mock).mockRejectedValueOnce('bwap bwap');
 
-      expect(await instance.run()).toBe(false);
+      expect(await instance.run()).toBe(RunResult.INVALID);
     });
 
     it('does not run if installing modules fails', async () => {
@@ -194,12 +252,134 @@ describe('Runner component', () => {
           throw new Error('Bwap-bwap');
         });
 
-      expect(await instance.run()).toBe(false);
+      expect(await instance.run()).toBe(RunResult.INVALID);
 
       console.error = oldError;
     });
   });
 
+  describe('stop()', () => {
+    it('stops a running session', async () => {
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as any).mockReturnValueOnce(mockChild);
+      mockChild.kill.mockImplementationOnce(() => {
+        mockChild.emit('close');
+        return true;
+      });
+
+      // wait for run() to get running
+      const runPromise = instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
+
+      // call stop and wait for run() to resolve
+      const stopped = instance.stop();
+      const runResult = await runPromise;
+
+      expect(stopped).toBe(true);
+      expect(runResult).toBe(RunResult.INVALID);
+      expect(store.isRunning).toBe(false);
+    });
+
+    it('returns failure if killing child process fails', async () => {
+      (findModulesInEditors as any).mockReturnValueOnce(['fake-module']);
+      (spawn as any).mockReturnValueOnce(mockChild);
+      mockChild.kill.mockReturnValueOnce(false);
+
+      // wait for run() to get running
+      instance.run();
+      await waitFor(() => store.isRunning);
+      expect(store.isRunning).toBe(true);
+
+      const stopped = instance.stop();
+      expect(stopped).toBe(false);
+    });
+
+    it('does nothing if called when idle', () => {
+      const stopped = instance.stop();
+
+      expect(stopped).toBe(true);
+    });
+  });
+
+  describe('autobisect()', () => {
+    it('returns success if bisection succeeds', async () => {
+      // make sure good, bad exist in our mock
+      const LAST_GOOD = '2.0.1';
+      const FIRST_BAD = '2.0.2';
+      expect(mockVersions[LAST_GOOD]).toEqual(expect.anything());
+      expect(mockVersions[FIRST_BAD]).toEqual(expect.anything());
+
+      const spy = jest.spyOn(store, 'setVersion');
+      instance.run = jest.fn().mockImplementation(() => {
+        // test succeeds iff version <= LAST_GOOD
+        return semver.compare(store.version, LAST_GOOD) <= 0
+          ? RunResult.SUCCESS
+          : RunResult.FAILURE;
+      });
+
+      const bisectRange = [...mockVersionsArray].reverse();
+      const result = await instance.autobisect(bisectRange);
+
+      expect(result).toBe(RunResult.SUCCESS);
+      expect((store.setVersion as jest.Mock).mock.calls).toHaveLength(2);
+      expect(spy).toHaveBeenNthCalledWith(1, LAST_GOOD);
+      expect(spy).toHaveBeenNthCalledWith(2, FIRST_BAD);
+      expect(store.pushOutput).toHaveBeenLastCalledWith(
+        `https://github.com/electron/electron/compare/v${LAST_GOOD}...v${FIRST_BAD}`,
+      );
+
+      spy.mockRestore();
+    });
+
+    it('returns invalid if unable to run', async () => {
+      const spy = jest.spyOn(store, 'setVersion');
+      instance.run = jest.fn().mockImplementation(() => RunResult.INVALID);
+
+      const bisectRange = [...mockVersionsArray].reverse();
+      const result = await instance.autobisect(bisectRange);
+
+      expect(result).toBe(RunResult.INVALID);
+      expect(store.pushOutput).toHaveBeenLastCalledWith(
+        'Runner: autobisect Electron 2.0.1 - ❓ invalid',
+      );
+
+      spy.mockRestore();
+    });
+
+    it('returns invalid if not enough versions to bisect', async () => {
+      const bisectRange = [mockVersions['2.0.1']];
+
+      const result = await instance.autobisect(bisectRange);
+
+      expect(result).toBe(RunResult.INVALID);
+      expect(store.pushOutput).toHaveBeenLastCalledWith(
+        'Runner: autobisect needs at least two Electron versions',
+      );
+    });
+
+    async function allRunsReturn(runResult: RunResult) {
+      instance.run = jest.fn().mockImplementation(() => runResult);
+      const bisectRange = [...mockVersionsArray].reverse();
+
+      const bisectResult = await instance.autobisect(bisectRange);
+
+      expect(bisectResult).toBe(RunResult.INVALID);
+      expect(store.pushOutput).toHaveBeenCalled();
+      expect((store.pushOutput as jest.Mock).mock.calls.pop()[0]).toMatch(
+        'both returned',
+      );
+    }
+
+    it('returns invalid if a bad version cannot be found', () => {
+      allRunsReturn(RunResult.SUCCESS);
+    });
+
+    it('returns invalid if a good version cannot be found', async () => {
+      allRunsReturn(RunResult.FAILURE);
+    });
+  });
+
   describe('installModules()', () => {
     it('installs modules', async () => {
       expect(
@@ -209,9 +389,7 @@ describe('Runner component', () => {
     });
 
     it('handles an error', async () => {
-      (installModules as jest.Mock).mockImplementationOnce(() => {
-        throw new Error('bwap bwap');
-      });
+      (installModules as jest.Mock).mockRejectedValueOnce('bwap bwap');
 
       expect(
         await instance.packageInstall({ dir: '', packageManager: 'npm' }),
@@ -251,9 +429,7 @@ describe('Runner component', () => {
     });
 
     it('handles an error in packageInstall()', async () => {
-      (installModules as jest.Mock).mockImplementationOnce(() => {
-        throw new Error('bwap bwap');
-      });
+      (installModules as jest.Mock).mockRejectedValueOnce('bwap bwap');
 
       expect(await instance.performForgeOperation(ForgeCommands.MAKE)).toBe(
         false,
@@ -261,9 +437,7 @@ describe('Runner component', () => {
     });
 
     it('handles an error in packageRun()', async () => {
-      (packageRun as jest.Mock).mockImplementationOnce(() => {
-        throw new Error('bwap bwap');
-      });
+      (packageRun as jest.Mock).mockRejectedValueOnce('bwap bwap');
 
       expect(await instance.performForgeOperation(ForgeCommands.MAKE)).toBe(
         false,