Repository: electron/fiddle
Pull request #643 (2021-04-08 12:54:08)

fix: installers for nightlies
Closes https://github.com/electron/fiddle/issues/392.

Fixes packaging nightly builds. Also fixes fetching types for nightly builds.

Nightlies come from electron-nightly and not electron, so we need to change the name in package.json. We also need to force a new ABI number as none has been assigned for nightlies.

Tested on macOS.

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

Coverage decreased (-0.08%) to 93.842% when pulling **b86d4d575d7b9946d91ad2d0b3018703a886d232 on fix-nightly-installers** into **e6c1de6642a41a282d18e475c783804fca239e43 on master**.


Patch:
diff --git a/src/renderer/binary.ts b/src/renderer/binary.ts
index 9af0bcab..e9970bd0 100644
--- a/src/renderer/binary.ts
+++ b/src/renderer/binary.ts
@@ -2,14 +2,40 @@ import * as fs from 'fs-extra';
 import * as path from 'path';
 import extract from 'extract-zip';
 
-import { VersionState } from '../interfaces';
+import { Version, VersionSource, VersionState } from '../interfaces';
 import { normalizeVersion } from '../utils/normalize-version';
 import { USER_DATA_PATH } from './constants';
 import { removeTypeDefsForVersion } from './fetch-types';
 import { AppState } from './state';
 import { download as electronDownload } from '@electron/get';
 
-const pendingDownloads: Record<string, Promise<void>> = {};
+// versions that are currently being downloaded
+const downloading: Map<string, Promise<void>> = new Map();
+
+// versions that are currently being unzipped
+const unzipping: Set<string> = new Set();
+
+/**
+ * Determine by inspection the VersionState of this version
+ *
+ * @param {Version} version
+ * @returns {VersionState} the state of the version
+ */
+export function getVersionState(ver: Version): VersionState {
+  const { localPath, version } = ver;
+
+  if (downloading.has(version)) {
+    return VersionState.downloading;
+  }
+
+  if (unzipping.has(version)) {
+    return VersionState.unzipping;
+  }
+
+  const dir = localPath || getDownloadPath(version);
+  const exec = path.join(dir, execSubpath());
+  return fs.existsSync(exec) ? VersionState.ready : VersionState.unknown;
+}
 
 /**
  * General setup, called with a version. Is called during construction
@@ -24,6 +50,11 @@ export function setupBinary(
 ): Promise<void> {
   const version = normalizeVersion(iVersion);
 
+  // Only remote versions can be downloaded
+  if (appState.versions[version].source !== VersionSource.remote) {
+    return Promise.resolve();
+  }
+
   // If we already have it, then we're done
   if (getIsDownloaded(version)) {
     console.log(`Binary: Electron ${version} already downloaded.`);
@@ -32,12 +63,13 @@ export function setupBinary(
   }
 
   // Return a promise that resolves when the download completes
-  let pending = pendingDownloads[version];
+  let pending = downloading.get(version);
   if (pending) {
     console.log(`Binary: Electron ${version} already downloading.`);
   } else {
     console.log(`Binary: Electron ${version} not present, downloading`);
-    pending = pendingDownloads[version] = downloadBinary(appState, version);
+    pending = downloadBinary(appState, version);
+    downloading.set(version, pending);
   }
   return pending;
 }
@@ -55,6 +87,8 @@ async function downloadBinary(
   );
 
   try {
+    unzipping.add(version);
+    downloading.delete(version);
     appState.versions[version].state = VersionState.unzipping;
 
     // Ensure the target path exists
@@ -71,7 +105,7 @@ async function downloadBinary(
     appState.versions[version].state = VersionState.unknown;
   } finally {
     // This task is done, so remove it from the pending tasks list
-    delete pendingDownloads[version];
+    unzipping.delete(version);
   }
 }
 
@@ -138,6 +172,21 @@ export function getIsDownloaded(version: string, dir?: string): boolean {
   return fs.existsSync(expectedPath);
 }
 
+function execSubpath() {
+  const { platform } = process;
+  switch (platform) {
+    case 'darwin':
+      return 'Electron.app/Contents/MacOS/Electron';
+    case 'linux':
+    case 'freebsd':
+      return 'electron';
+    case 'win32':
+      return 'electron.exe';
+    default:
+      throw new Error(`Electron builds are not available for ${platform}`);
+  }
+}
+
 /**
  * Gets the expected path for the binary of a given Electron version
  *
@@ -149,43 +198,7 @@ export function getElectronBinaryPath(
   version: string,
   dir: string = getDownloadPath(version),
 ): string {
-  switch (process.platform) {
-    case 'darwin':
-      return path.join(dir, 'Electron.app/Contents/MacOS/Electron');
-    case 'freebsd':
-    case 'linux':
-      return path.join(dir, 'electron');
-    case 'win32':
-      return path.join(dir, 'electron.exe');
-    default:
-      throw new Error(
-        `Electron builds are not available for ${process.platform}`,
-      );
-  }
-}
-
-export function getDownloadingVersions(appState: AppState) {
-  return Object.entries(appState.versions)
-    .filter(([_, { state }]) => state === 'downloading')
-    .map(([version, _]) => version);
-}
-
-/**
- * Returns an array of all versions downloaded to disk
- *
- * @returns {Promise<Array<string>>}
- */
-export async function getDownloadedVersions(): Promise<Array<string>> {
-  const downloadPath = path.join(USER_DATA_PATH, 'electron-bin');
-  console.log(`Binary: Checking for downloaded versions`);
-
-  try {
-    const dirs = await fs.readdir(downloadPath);
-    return dirs.filter((dir) => dir && getIsDownloaded(dir));
-  } catch (error) {
-    console.warn(`Could not read known Electron versions`, error);
-    return [];
-  }
+  return path.join(dir, execSubpath());
 }
 
 /**
diff --git a/src/renderer/fetch-types.ts b/src/renderer/fetch-types.ts
index 28a9fbb6..8f7a8603 100644
--- a/src/renderer/fetch-types.ts
+++ b/src/renderer/fetch-types.ts
@@ -16,7 +16,10 @@ const definitionPath = path.join(USER_DATA_PATH, 'electron-typedef');
  * @returns {Promise<string>}
  */
 export async function fetchTypeDefinitions(version: string): Promise<string> {
-  const url = `https://unpkg.com/electron@${version}/electron.d.ts`;
+  const packageName = version.includes('nightly')
+    ? 'electron-nightly'
+    : 'electron';
+  const url = `https://unpkg.com/${packageName}@${version}/electron.d.ts`;
 
   let text: string;
   try {
diff --git a/src/renderer/npm.ts b/src/renderer/npm.ts
index 49d9c58b..8d8926d9 100644
--- a/src/renderer/npm.ts
+++ b/src/renderer/npm.ts
@@ -138,7 +138,7 @@ export async function installModules(
   let nameArgs: Array<string> = [];
 
   if (packageManager === 'npm') {
-    nameArgs = names.length > 0 ? ['-S', ...names] : ['--dev --prod'];
+    nameArgs = names.length > 0 ? ['-S', ...names] : ['--also=dev --prod'];
   } else {
     nameArgs = [...names];
   }
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index 7e028c97..c0c0dfa1 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -30,12 +30,7 @@ import {
 import { getName } from '../utils/get-name';
 import { normalizeVersion } from '../utils/normalize-version';
 import { isEditorBackup, isEditorId, isPanelId } from '../utils/type-checks';
-import {
-  getDownloadedVersions,
-  getDownloadingVersions,
-  removeBinary,
-  setupBinary,
-} from './binary';
+import { getVersionState, removeBinary, setupBinary } from './binary';
 import { Bisector } from './bisect';
 import { DEFAULT_CLOSED_PANELS, DEFAULT_MOSAIC_ARRANGEMENT } from './constants';
 import { getTemplate, isContentUnchanged } from './content';
@@ -342,26 +337,17 @@ export class AppState {
    * current settings for states and channels to display
    */
   @computed get versionsToShow(): Array<RunnableVersion> {
-    return sortedElectronMap<RunnableVersion>(
-      this.versions,
-      (_key, item) => item,
-    ).filter((item) => {
-      if (!item) {
-        return false;
-      }
+    const { channelsToShow, statesToShow, versions } = this;
 
-      // Check if we want to show the version
-      if (!this.channelsToShow.includes(getReleaseChannel(item))) {
-        return false;
-      }
+    const filter = (ver: RunnableVersion) =>
+      ver &&
+      statesToShow.includes(ver.state) &&
+      channelsToShow.includes(getReleaseChannel(ver));
 
-      // Check if we want to show the state
-      if (!this.statesToShow.includes(item.state)) {
-        return false;
-      }
-
-      return true;
-    });
+    return sortedElectronMap<RunnableVersion>(
+      versions,
+      (_key, item) => item,
+    ).filter(filter);
   }
 
   /**
@@ -486,7 +472,7 @@ export class AppState {
     this.versions = Object.fromEntries(
       versions.map((ver) => [ver.version, ver]),
     );
-    this.updateDownloadedVersionState();
+    this.updateVersionStates();
   }
 
   /**
@@ -549,7 +535,7 @@ export class AppState {
       this.versions = updatedVersions;
 
       await setupBinary(this, version);
-      this.updateDownloadedVersionState();
+      this.updateVersionStates();
     } else {
       console.log(
         `State: Version ${version} already downloaded, doing nothing.`,
@@ -631,28 +617,12 @@ export class AppState {
    *
    * @returns {Promise<void>}
    */
-  @action public async updateDownloadedVersionState(): Promise<void> {
-    const updatedVersions = toJS(this.versions);
-
-    // Keep state of currently downloading binaries first
-    const downloadingVersions = getDownloadingVersions(this);
-    for (const version of downloadingVersions) {
-      if (updatedVersions[version]) {
-        updatedVersions[version].state = VersionState.downloading;
-      }
-    }
-
-    const downloadedVersions = await getDownloadedVersions();
-
-    for (const version of downloadedVersions) {
-      if (updatedVersions[version]) {
-        updatedVersions[version].state = VersionState.ready;
-      }
+  @action public updateVersionStates() {
+    const versions = toJS(this.versions);
+    for (const ver of Object.values(versions)) {
+      ver.state = getVersionState(ver);
     }
-
-    console.log(`State: Updated version state`, updatedVersions);
-
-    this.versions = updatedVersions;
+    this.versions = versions;
   }
 
   /**
diff --git a/src/renderer/transforms/forge.ts b/src/renderer/transforms/forge.ts
index 411f2138..6d168cf9 100644
--- a/src/renderer/transforms/forge.ts
+++ b/src/renderer/transforms/forge.ts
@@ -1,5 +1,7 @@
+import { execSync } from 'child_process';
 import { Files } from '../../interfaces';
 import { PACKAGE_NAME } from '../../shared-constants';
+import { getElectronBinaryPath } from '../binary';
 
 /**
  * This transform turns the files into an electron-forge
@@ -35,6 +37,19 @@ export async function forgeTransform(files: Files): Promise<Files> {
       parsed.config.forge = {};
       parsed.config.forge.packagerConfig = {};
 
+      const nightlyVersion = parsed.devDependencies['electron-nightly'];
+      if (nightlyVersion) {
+        // Fetch forced ABI for nightly.
+        const binaryPath = getElectronBinaryPath(nightlyVersion);
+        const abi = execSync(
+          `ELECTRON_RUN_AS_NODE=1 "${binaryPath}" -p process.versions.modules`,
+        );
+
+        parsed.config.forge.electronRebuildConfig = {
+          forceABI: abi.toString().trim(),
+        };
+      }
+
       // electron-forge makers
       parsed.config.forge.makers = [
         {
diff --git a/src/renderer/versions.ts b/src/renderer/versions.ts
index 8279c99f..8549c6b4 100644
--- a/src/renderer/versions.ts
+++ b/src/renderer/versions.ts
@@ -6,6 +6,7 @@ import {
   VersionSource,
   VersionState,
 } from '../interfaces';
+import { getVersionState } from './binary';
 import { normalizeVersion } from '../utils/normalize-version';
 
 /**
@@ -135,6 +136,7 @@ function saveVersions(key: VersionKeys, versions: Array<Version>) {
 
 function sanitizeVersion(ver: RunnableVersion): RunnableVersion {
   ver.version = normalizeVersion(ver.version);
+  ver.state = getVersionState(ver);
   return ver;
 }
 
diff --git a/src/utils/get-package.ts b/src/utils/get-package.ts
index a233b6c4..c2fe5cbd 100644
--- a/src/utils/get-package.ts
+++ b/src/utils/get-package.ts
@@ -35,7 +35,10 @@ export async function getPackageJson(
   const dependencies: Record<string, string> = {};
 
   if (includeElectron) {
-    devDependencies.electron = appState.version;
+    const packageName = appState.version?.includes('nightly')
+      ? 'electron-nightly'
+      : 'electron';
+    devDependencies[packageName] = appState.version;
   }
 
   if (includeDependencies && values) {


Test patch:
diff --git a/tests/mocks/electron-versions.ts b/tests/mocks/electron-versions.ts
index b86dee30..018a90e4 100644
--- a/tests/mocks/electron-versions.ts
+++ b/tests/mocks/electron-versions.ts
@@ -4,32 +4,25 @@ import {
   VersionState,
 } from '../../src/interfaces';
 
-export const mockVersions: Record<string, RunnableVersion> = {
-  '2.0.3': {
-    source: VersionSource.remote,
-    state: VersionState.ready,
-    version: '2.0.3',
-  },
-  '2.0.2': {
-    source: VersionSource.remote,
-    state: VersionState.ready,
-    version: '2.0.2',
-  },
-  '2.0.1': {
-    source: VersionSource.remote,
-    state: VersionState.ready,
-    version: '2.0.1',
-  },
-  '2.0.0': {
-    source: VersionSource.remote,
-    state: VersionState.ready,
-    version: '2.0.0',
-  },
-  '1.8.7': {
-    source: VersionSource.remote,
-    state: VersionState.ready,
-    version: '1.8.7',
-  },
-};
+export class MockVersions {
+  public readonly mockVersions: Record<string, RunnableVersion>;
+  public readonly mockVersionsArray: RunnableVersion[];
 
-export const mockVersionsArray = Object.values(mockVersions);
+  constructor() {
+    const versions = ['2.0.3', '2.0.2', '2.0.1', '2.0.0', '1.8.7'];
+
+    const arr = versions.map((version) => ({
+      source: VersionSource.remote,
+      state: VersionState.ready,
+      version,
+    }));
+
+    const obj = {};
+    for (const ver of arr) {
+      obj[ver.version] = ver;
+    }
+
+    this.mockVersions = obj;
+    this.mockVersionsArray = arr;
+  }
+}
diff --git a/tests/renderer/binary-spec.ts b/tests/renderer/binary-spec.ts
index 8d7c4ad3..6591cf50 100644
--- a/tests/renderer/binary-spec.ts
+++ b/tests/renderer/binary-spec.ts
@@ -1,14 +1,20 @@
 import {
-  getDownloadedVersions,
-  getDownloadingVersions,
   getElectronBinaryPath,
+  getVersionState,
   removeBinary,
   setupBinary,
 } from '../../src/renderer/binary';
+import {
+  RunnableVersion,
+  VersionSource,
+  VersionState,
+} from '../../src/interfaces';
 import { removeTypeDefsForVersion } from '../../src/renderer/fetch-types';
 import { overridePlatform, resetPlatform } from '../utils';
+import { waitFor } from '../../src/utils/wait-for';
 
 import * as path from 'path';
+import * as semver from 'semver';
 import extract from 'extract-zip';
 
 jest.mock('fs-extra');
@@ -80,33 +86,76 @@ describe('binary', () => {
     });
   });
 
-  describe('getDownloadedVersions()', () => {
-    it('finds downloaded versions', async () => {
-      const versions = ['v3.0.0'];
-      (fs.readdir as jest.Mock<any>).mockResolvedValue(versions);
-      (fs.existsSync as jest.Mock<any>).mockReturnValue(true);
-      const result = await getDownloadedVersions();
-      expect(result).toEqual(versions);
+  describe('getVersionState()', () => {
+    let version = '3.0.0';
+    let ver: RunnableVersion;
+
+    beforeEach(() => {
+      // the download module holds state between runs,
+      // so use a unique version number in each test to
+      // prevent state pollution
+      version = semver.inc(version, 'patch')!;
+      ver = {
+        source: VersionSource.remote,
+        state: VersionState.unknown,
+        version,
+      };
+      mockState.versions[version] = ver;
     });
 
-    it('is okay without versions ', async () => {
-      const versions: string[] = [];
-      (fs.readdir as jest.Mock<any>).mockResolvedValue(versions);
-      const result = await getDownloadedVersions();
-      expect(result).toEqual(versions);
+    it(`returns ${VersionState.downloading} when downloading`, () => {
+      // mock a download in progress
+      let downloadResolve: any;
+      const { download } = require('@electron/get');
+      download.mockReturnValue(new Promise((r) => (downloadResolve = r)));
+
+      setupBinary(mockState, ver.version);
+
+      expect(getVersionState(ver)).toBe(VersionState.downloading);
+      downloadResolve();
     });
 
-    it('is okay without versions and with errors', async () => {
-      (fs.readdir as jest.Mock<any>).mockRejectedValueOnce('💩');
-      const result = await getDownloadedVersions();
-      expect(result).toEqual([]);
+    it(`returns ${VersionState.unzipping} when unzipping`, async () => {
+      // mock a completed download + an unzip in progress
+      const { download } = require('@electron/get');
+      download.mockResolvedValueOnce('/fake/path');
+      let unzipResolve: any;
+      const unzipPromise = new Promise((r) => (unzipResolve = r));
+      (extract as jest.Mock).mockReturnValueOnce(unzipPromise);
+
+      setupBinary(mockState, ver.version);
+      await waitFor(() => getVersionState(ver) === VersionState.unzipping);
+
+      expect(getVersionState(ver)).toBe(VersionState.unzipping);
+      unzipResolve();
+    });
+
+    it(`returns ${VersionState.ready} when it is a local version that exists`, async () => {
+      ver.source = VersionSource.local;
+      ver.localPath = '/fake/path';
+      (fs.existsSync as jest.Mock).mockReturnValue(true);
+      expect(getVersionState(ver)).toBe(VersionState.ready);
     });
-  });
 
-  describe('getDownloadingVersions()', () => {
-    it('returns currently downloading versions', () => {
-      const result = getDownloadingVersions(mockState as any);
-      expect(result).toEqual(['3.0.0']);
+    it(`returns ${VersionState.unknown} when it is a local version that is missing`, async () => {
+      ver.source = VersionSource.local;
+      ver.localPath = '/fake/path';
+      (fs.existsSync as jest.Mock).mockReturnValue(false);
+      expect(getVersionState(ver)).toBe(VersionState.unknown);
+    });
+
+    it(`returns ${VersionState.ready} when it is a remote version that is downloaded`, async () => {
+      ver.source = VersionSource.remote;
+      ver.localPath = undefined;
+      (fs.existsSync as jest.Mock).mockReturnValue(true);
+      expect(getVersionState(ver)).toBe(VersionState.ready);
+    });
+
+    it(`returns ${VersionState.unknown} when it is a remote version is not downloaded`, async () => {
+      ver.source = VersionSource.remote;
+      ver.localPath = undefined;
+      (fs.existsSync as jest.Mock).mockReturnValue(false);
+      expect(getVersionState(ver)).toBe(VersionState.unknown);
     });
   });
 
@@ -146,6 +195,23 @@ describe('binary', () => {
   describe('setupBinary()', () => {
     const { download } = require('@electron/get');
 
+    it('does not download local versions', async () => {
+      const localPath = '/fake/path';
+      download.mockResolvedValue(localPath);
+
+      const version = '3.0.0';
+      mockState.versions[version] = {
+        source: VersionSource.local,
+        state: VersionState.unknown,
+        version,
+      };
+
+      await setupBinary(mockState, version);
+
+      expect(download).not.toHaveBeenCalled();
+      expect(mockState.versions[version].state).toBe(VersionState.unknown);
+    });
+
     it(`downloads a version it has not seen before`, async () => {
       (fs.existsSync as jest.Mock).mockReturnValueOnce(false);
 
@@ -153,7 +219,11 @@ describe('binary', () => {
       download.mockResolvedValue(localPath);
 
       const version = '3.0.0';
-      mockState.versions[version] = { state: 'unknown' };
+      mockState.versions[version] = {
+        source: VersionSource.remote,
+        state: VersionState.unknown,
+        version,
+      };
       await setupBinary(mockState, version);
 
       expect(download).toHaveBeenCalled();
@@ -164,7 +234,11 @@ describe('binary', () => {
       (fs.existsSync as jest.Mock).mockReturnValue(true);
 
       const version = '3.0.0';
-      mockState.versions[version] = { state: 'unknown' };
+      mockState.versions[version] = {
+        source: VersionSource.remote,
+        state: VersionState.unknown,
+        version,
+      };
 
       await setupBinary(mockState, version);
 
@@ -176,7 +250,12 @@ describe('binary', () => {
       (fs.existsSync as jest.Mock).mockReturnValue(true);
 
       const version = '3.0.0';
-      mockState.versions[version] = { state: 'downloading' };
+      mockState.versions[version] = {
+        source: VersionSource.remote,
+        state: VersionState.downloading,
+        version,
+      };
+
       await setupBinary(mockState, version);
 
       expect(download).not.toHaveBeenCalled();
@@ -187,7 +266,12 @@ describe('binary', () => {
       const { download } = require('@electron/get');
 
       download.mockReturnValue('/fake/path');
-      mockState.versions['3.0.0'] = { state: 'unknown' };
+      const version = '3.0.0';
+      mockState.versions[version] = {
+        source: VersionSource.remote,
+        state: VersionState.unknown,
+        version,
+      };
 
       (extract as jest.Mock).mockRejectedValue(new Error('bwap-bwap'));
 
diff --git a/tests/renderer/components/commands-runner-spec.tsx b/tests/renderer/components/commands-runner-spec.tsx
index bc3baafe..041409d5 100644
--- a/tests/renderer/components/commands-runner-spec.tsx
+++ b/tests/renderer/components/commands-runner-spec.tsx
@@ -5,7 +5,7 @@ import { VersionState } from '../../../src/interfaces';
 import { Runner } from '../../../src/renderer/components/commands-runner';
 import { ipcRendererManager } from '../../../src/renderer/ipc';
 import { ElectronFiddleMock } from '../../mocks/electron-fiddle';
-import { mockVersions } from '../../mocks/electron-versions';
+import { MockVersions } from '../../mocks/electron-versions';
 
 jest.mock('../../../src/renderer/npm');
 jest.mock('../../../src/renderer/file-manager');
@@ -16,11 +16,13 @@ describe('Runner component', () => {
   let store: any;
 
   beforeEach(() => {
+    const { mockVersions } = new MockVersions();
+
     ipcRendererManager.removeAllListeners();
 
     store = {
       version: '2.0.2',
-      versions: { ...mockVersions },
+      versions: mockVersions,
       isRunning: false,
       get currentElectronVersion() {
         return mockVersions[this.version];
diff --git a/tests/renderer/components/commands-version-chooser-spec.tsx b/tests/renderer/components/commands-version-chooser-spec.tsx
index a5e8a85b..eb813b3f 100644
--- a/tests/renderer/components/commands-version-chooser-spec.tsx
+++ b/tests/renderer/components/commands-version-chooser-spec.tsx
@@ -7,7 +7,7 @@ import {
   VersionState,
 } from '../../../src/interfaces';
 import { VersionChooser } from '../../../src/renderer/components/commands-version-chooser';
-import { mockVersions } from '../../mocks/electron-versions';
+import { MockVersions } from '../../mocks/electron-versions';
 
 const { unknown } = VersionState;
 const { remote } = VersionSource;
@@ -28,6 +28,8 @@ describe('VersionSelect component', () => {
   };
 
   beforeEach(() => {
+    const { mockVersions } = new MockVersions();
+
     const versions = {
       ...mockVersions,
       '3.1.3': undefined,
diff --git a/tests/renderer/components/settings-electron-spec.tsx b/tests/renderer/components/settings-electron-spec.tsx
index b845f528..2e91b312 100644
--- a/tests/renderer/components/settings-electron-spec.tsx
+++ b/tests/renderer/components/settings-electron-spec.tsx
@@ -3,16 +3,21 @@ import * as React from 'react';
 
 import {
   ElectronReleaseChannel,
+  RunnableVersion,
   VersionSource,
   VersionState,
 } from '../../../src/interfaces';
 import { ElectronSettings } from '../../../src/renderer/components/settings-electron';
-import { mockVersions, mockVersionsArray } from '../../mocks/electron-versions';
+import { MockVersions } from '../../mocks/electron-versions';
 
 describe('ElectronSettings component', () => {
   let store: any;
+  let mockVersions: Record<string, RunnableVersion>;
+  let mockVersionsArray: RunnableVersion[];
 
   beforeEach(() => {
+    ({ mockVersions, mockVersionsArray } = new MockVersions());
+
     store = {
       version: '2.0.1',
       versions: { ...mockVersions },
diff --git a/tests/renderer/components/version-select-spec.tsx b/tests/renderer/components/version-select-spec.tsx
index e53c2ffb..4e688b49 100644
--- a/tests/renderer/components/version-select-spec.tsx
+++ b/tests/renderer/components/version-select-spec.tsx
@@ -14,7 +14,7 @@ import {
   renderItem,
   VersionSelect,
 } from '../../../src/renderer/components/version-select';
-import { mockVersions } from '../../mocks/electron-versions';
+import { MockVersions } from '../../mocks/electron-versions';
 
 const { downloading, ready, unknown, unzipping } = VersionState;
 const { remote, local } = VersionSource;
@@ -35,6 +35,8 @@ describe('VersionSelect component', () => {
   };
 
   beforeEach(() => {
+    const { mockVersions } = new MockVersions();
+
     store = {
       version: '2.0.2',
       versions: {
diff --git a/tests/renderer/npm-spec.ts b/tests/renderer/npm-spec.ts
index 5081f67c..2cf58ae5 100644
--- a/tests/renderer/npm-spec.ts
+++ b/tests/renderer/npm-spec.ts
@@ -220,7 +220,7 @@ describe('npm', () => {
 
         expect(exec).toHaveBeenCalledWith<any>(
           '/my/directory',
-          'npm install --dev --prod',
+          'npm install --also=dev --prod',
         );
       });
     });
diff --git a/tests/renderer/runner-spec.tsx b/tests/renderer/runner-spec.tsx
index 3ef5b6b6..87ab01ea 100644
--- a/tests/renderer/runner-spec.tsx
+++ b/tests/renderer/runner-spec.tsx
@@ -5,7 +5,7 @@ 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 { RunResult, RunnableVersion } from '../../src/interfaces';
 import {
   findModulesInEditors,
   getIsPackageManagerInstalled,
@@ -17,7 +17,7 @@ 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, mockVersionsArray } from '../mocks/electron-versions';
+import { MockVersions } from '../mocks/electron-versions';
 
 jest.mock('../../src/renderer/npm');
 jest.mock('../../src/renderer/file-manager');
@@ -33,8 +33,12 @@ describe('Runner component', () => {
   let mockChild: MockChildProcess;
   let store: any;
   let instance: Runner;
+  let mockVersions: Record<string, RunnableVersion>;
+  let mockVersionsArray: RunnableVersion[];
 
   beforeEach(() => {
+    ({ mockVersions, mockVersionsArray } = new MockVersions());
+
     mockChild = new MockChildProcess();
     ipcRendererManager.removeAllListeners();
 
@@ -42,7 +46,7 @@ describe('Runner component', () => {
 
     store = {
       version: '2.0.2',
-      versions: { ...mockVersions },
+      versions: mockVersions,
       downloadVersion: jest.fn(),
       removeVersion: jest.fn(),
       pushOutput: jest.fn(),
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 3e15d524..fd98c789 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -7,13 +7,13 @@ import {
   ElectronReleaseChannel,
   GenericDialogType,
   PanelId,
+  RunnableVersion,
   VersionSource,
   VersionState,
 } from '../../src/interfaces';
 import { IpcEvents } from '../../src/ipc-events';
 import {
-  getDownloadedVersions,
-  getDownloadingVersions,
+  getVersionState,
   removeBinary,
   setupBinary,
 } from '../../src/renderer/binary';
@@ -32,7 +32,7 @@ import {
 import { createMosaicArrangement } from '../../src/utils/editors-mosaic-arrangement';
 import { waitFor } from '../../src/utils/wait-for';
 import { getName } from '../../src/utils/get-name';
-import { mockVersions, mockVersionsArray } from '../mocks/electron-versions';
+import { MockVersions } from '../mocks/electron-versions';
 import { overridePlatform, resetPlatform } from '../utils';
 
 jest.mock('../../src/renderer/content', () => ({
@@ -42,8 +42,7 @@ jest.mock('../../src/renderer/content', () => ({
 jest.mock('../../src/renderer/binary', () => ({
   removeBinary: jest.fn(),
   setupBinary: jest.fn(),
-  getDownloadedVersions: jest.fn().mockResolvedValue([]),
-  getDownloadingVersions: jest.fn().mockReturnValue([]),
+  getVersionState: jest.fn().mockImplementation((v) => v.state),
 }));
 jest.mock('../../src/renderer/fetch-types', () => ({
   updateEditorTypeDefinitions: jest.fn(),
@@ -52,13 +51,12 @@ jest.mock('../../src/renderer/versions', () => {
   const { getReleaseChannel } = jest.requireActual(
     '../../src/renderer/versions',
   );
+  const { MockVersions } = require('../mocks/electron-versions');
+  const { mockVersionsArray } = new MockVersions();
 
   return {
-    getUpdatedElectronVersions: jest.fn().mockImplementation(async () => {
-      return require('../mocks/electron-versions').mockVersionsArray;
-    }),
-    getElectronVersions: () =>
-      require('../mocks/electron-versions').mockVersionsArray,
+    getUpdatedElectronVersions: jest.fn().mockResolvedValue(mockVersionsArray),
+    getElectronVersions: () => mockVersionsArray,
     getDefaultVersion: () => '2.0.2',
     addLocalVersion: jest.fn(),
     saveLocalVersions: jest.fn(),
@@ -72,15 +70,21 @@ jest.mock('../../src/renderer/ipc');
 
 describe('AppState', () => {
   let appState: AppState;
+  let mockVersions: Record<string, RunnableVersion>;
+  let mockVersionsArray: RunnableVersion[];
 
   beforeEach(() => {
-    (getDownloadedVersions as jest.Mock).mockResolvedValue([]);
-    (getDownloadingVersions as jest.Mock).mockReturnValue([]);
+    ({ mockVersions, mockVersionsArray } = new MockVersions());
+
     (getUpdatedElectronVersions as jest.Mock).mockResolvedValue(
       mockVersionsArray,
     );
+    (getVersionState as jest.Mock).mockImplementation((v) => v.state);
 
     appState = new AppState();
+    appState.versions = mockVersions;
+    appState.updateVersionStates = jest.fn();
+
     ipcRendererManager.removeAllListeners();
   });
 
@@ -523,25 +527,31 @@ describe('AppState', () => {
       // We just want to verify that the version state was
       // refreshed - we didn't actually add the local version
       // above, since versions.ts is mocked
-      expect(Object.keys(appState.versions)).toEqual(
-        mockVersionsArray.map((v) => v.version),
-      );
+      expect(Object.keys(appState.versions)).toEqual(Object.keys(mockVersions));
     });
   });
 
-  describe('updateDownloadedVersionState()', () => {
-    it('downloads a version if necessary', async () => {
-      (getDownloadedVersions as jest.Mock).mockResolvedValue(['2.0.2']);
+  describe('updateVersionStates()', () => {
+    beforeEach(() => {
+      ipcRendererManager.removeAllListeners();
+      (getVersionState as jest.Mock).mockImplementation(
+        (v: RunnableVersion) => {
+          if (v.version === '2.0.1') return VersionState.downloading;
+          if (v.version === '2.0.2') return VersionState.ready;
+          return VersionState.unknown;
+        },
+      );
+      appState = new AppState();
+    });
 
-      await appState.updateDownloadedVersionState();
+    it('downloads a version if necessary', async () => {
+      await appState.updateVersionStates();
 
       expect(appState.versions['2.0.2'].state).toBe(VersionState.ready);
     });
 
     it('keeps downloading state intact', async () => {
-      (getDownloadingVersions as jest.Mock).mockReturnValue(['2.0.1']);
-
-      await appState.updateDownloadedVersionState();
+      await appState.updateVersionStates();
 
       expect(appState.versions['2.0.1'].state).toBe(VersionState.downloading);
     });
diff --git a/tests/renderer/versions-spec.ts b/tests/renderer/versions-spec.ts
index ff47cc6a..c4da7377 100644
--- a/tests/renderer/versions-spec.ts
+++ b/tests/renderer/versions-spec.ts
@@ -4,7 +4,9 @@ import {
   ElectronReleaseChannel,
   RunnableVersion,
   VersionSource,
+  VersionState,
 } from '../../src/interfaces';
+import { getVersionState } from '../../src/renderer/binary';
 import {
   addLocalVersion,
   fetchVersions,
@@ -18,6 +20,12 @@ import {
 } from '../../src/renderer/versions';
 import { mockFetchOnce } from '../utils';
 
+jest.mock('../../src/renderer/binary', () => ({
+  getVersionState: jest
+    .fn()
+    .mockImplementation((v: RunnableVersion) => v.state),
+}));
+
 const { expectedVersionCount } = require('../fixtures/releases-metadata.json');
 
 const mockVersions: Array<Partial<RunnableVersion>> = [
@@ -215,20 +223,35 @@ describe('versions', () => {
 
   describe('getUpdatedElectronVersions()', () => {
     it('gets known versions', async () => {
-      (window as any).localStorage.getItem.mockReturnValueOnce(
-        `[{ "version": "3.0.5" }]`,
-      );
-      (window as any).localStorage.getItem.mockReturnValueOnce(
-        `[{ "version": "3.0.5" }]`,
-      );
+      (getVersionState as jest.Mock).mockImplementation((v: any) => {
+        if (v.version === '3.0.5') return VersionState.ready;
+        if (v.version === '3.0.6') return VersionState.unknown;
+        return v.state;
+      });
+
+      (window as any).localStorage.getItem.mockImplementation((key: string) => {
+        if (key === 'known-electron-versions')
+          return '[{ "version": "3.0.5" }]';
+        if (key === 'local-electron-versions')
+          return '[{ "version": "3.0.6" }]';
+        throw new Error(`unexpected key ${key}`);
+      });
+
       mockFetchOnce('');
 
       const result = await getUpdatedElectronVersions();
-      const expectedVersion = { version: '3.0.5', state: 'unknown' };
 
       expect(result).toEqual([
-        { ...expectedVersion, source: 'remote' },
-        { ...expectedVersion, source: 'local', state: 'ready' },
+        {
+          source: VersionSource.remote,
+          state: VersionState.ready,
+          version: '3.0.5',
+        },
+        {
+          source: VersionSource.local,
+          state: VersionState.unknown,
+          version: '3.0.6',
+        },
       ]);
     });
   });
diff --git a/tests/utils/get-package-spec.ts b/tests/utils/get-package-spec.ts
index 45d56fa4..880ffcfb 100644
--- a/tests/utils/get-package-spec.ts
+++ b/tests/utils/get-package-spec.ts
@@ -47,6 +47,51 @@ describe('get-package', () => {
     );
   });
 
+  it('getPackageJson() includes electron-nightly if needed', async () => {
+    const result = await getPackageJson(
+      {
+        getName: () => 'test-app',
+        version: '1.0.0-nightly.123456789',
+      } as any,
+      {
+        main: 'app.goDoTheThing()',
+        renderer: `const say = require('say')`,
+        html: '<html />',
+        preload: 'preload',
+        css: 'body { color: black }',
+      },
+      {
+        includeElectron: true,
+        includeDependencies: true,
+      },
+    );
+
+    expect(result).toEqual(
+      JSON.stringify(
+        {
+          name: 'test-app',
+          productName: 'test-app',
+          description: 'My Electron application description',
+          keywords: [],
+          main: './main.js',
+          version: '1.0.0',
+          author: 'test-user',
+          scripts: {
+            start: 'electron .',
+          },
+          dependencies: {
+            say: '*',
+          },
+          devDependencies: {
+            'electron-nightly': '1.0.0-nightly.123456789',
+          },
+        },
+        undefined,
+        2,
+      ),
+    );
+  });
+
   it('getPackageJson() includes electron if needed', async () => {
     const result = await getPackageJson(
       {
diff --git a/tests/utils/get-version-range-spec.ts b/tests/utils/get-version-range-spec.ts
index d30961d2..8ebb3e92 100644
--- a/tests/utils/get-version-range-spec.ts
+++ b/tests/utils/get-version-range-spec.ts
@@ -1,10 +1,11 @@
 import * as semver from 'semver';
 
-import { mockVersionsArray } from '../mocks/electron-versions';
+import { MockVersions } from '../mocks/electron-versions';
 
 import { getVersionRange } from '../../src/utils/get-version-range';
 
 describe('getVersionRange', () => {
+  const { mockVersionsArray } = new MockVersions();
   const knownVersions = [...mockVersionsArray].reverse();
   const midpoint = Math.floor(knownVersions.length / 2);