Repository: electron/fiddle
Pull request #616 (2021-03-23 21:36:51)

refactor: limit tmpdir scope to tests that need them
A small refactor to the content-spec. I recently added a tmpdir for the content-spec to test live unzipping, but think I scoped the code wrong. The feature is only needed in content-spec, so that's where it should live instead of in the `tests/mocks/` helpers.

* In terms of sprawl, this is a good thing because the tmpdir scaffolding now has a narrower scope.
* Since our jest config calls resetMocks() between tests, it's preferable to KISS our mocks' default state.

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

Coverage decreased (-0.07%) to 93.129% when pulling **9bd3b177a535adf8b969e10cbdf9e8d656f314a4 on test/use-tmpdir-only-where-needed** 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/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 7968abb7..51466768 100644
--- a/src/renderer/content.ts
+++ b/src/renderer/content.ts
@@ -100,8 +100,7 @@ function isReleasedMajor(version: semver.SemVer) {
     .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();
+    .reduce((acc, cur) => (acc && acc.compare(cur!) > 0 ? acc : cur));
   return newestRelease && version.major <= newestRelease.major;
 }
 
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/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/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/electron.ts b/tests/mocks/electron.ts
index 5a4a2175..a0281fad 100644
--- a/tests/mocks/electron.ts
+++ b/tests/mocks/electron.ts
@@ -103,21 +103,6 @@ function CreateWindowStub() {
   };
 }
 
-let fakeUserDataPath: string;
-
-function getFakeUserDataPath() {
-  if (!fakeUserDataPath) {
-    const tmp = require('tmp');
-    tmp.setGracefulCleanup();
-    const tmpdir = tmp.dirSync({
-      template: 'electron-fiddle-tests--user-data-XXXXXX',
-      unsafeCleanup: true, // remove everything
-    });
-    fakeUserDataPath = tmpdir.name;
-  }
-  return fakeUserDataPath;
-}
-
 const app = {
   getName: jest.fn().mockReturnValue('Electron Fiddle'),
   exit: jest.fn(),
@@ -135,7 +120,7 @@ const app = {
   })),
   getLoginItemSettings: jest.fn(),
   getPath: jest.fn((name: string) => {
-    if (name === 'userData') return getFakeUserDataPath();
+    if (name === 'userData') return '/Users/fake-user';
     if (name === 'home') return `~`;
     return '/test-path';
   }),
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/content-spec.ts b/tests/renderer/content-spec.ts
index 0d936c7b..a17f760c 100644
--- a/tests/renderer/content-spec.ts
+++ b/tests/renderer/content-spec.ts
@@ -1,7 +1,10 @@
 import * as fs from 'fs-extra';
 import * as path from 'path';
+import * as tmp from 'tmp';
 import { Response } from 'cross-fetch';
 
+let fakeUserData: tmp.DirResult | null;
+
 import { EditorId } from '../../src/interfaces';
 import {
   getContent,
@@ -11,6 +14,20 @@ import {
 } from '../../src/renderer/content';
 
 jest.unmock('fs-extra');
+jest.mock('../../src/renderer/constants', () => {
+  // when USER_DATA_PATH is imported,
+  // set it to be a newly-allocated tmpdir
+  if (!fakeUserData) {
+    tmp.setGracefulCleanup();
+    fakeUserData = tmp.dirSync({
+      template: 'electron-fiddle-tests--user-data-XXXXXX',
+      unsafeCleanup: true, // remove everything
+    });
+  }
+  return {
+    USER_DATA_PATH: fakeUserData.name,
+  };
+});
 
 let lastResponse = new Response(null, {
   status: 503,
@@ -46,6 +63,13 @@ describe('content', () => {
   const VERSION_IN_FIXTURES = '11.0.0';
   const VERSION_NOT_IN_FIXTURES = '10.0.0';
 
+  afterEach(() => {
+    if (fakeUserData) {
+      fakeUserData.removeCallback();
+      fakeUserData = null;
+    }
+  });
+
   describe('getTestTemplate()', () => {
     beforeEach(() => {
       // @ts-ignore: force 'any'; fetch's param type is private / inaccessible
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,