Repository: electron/fiddle
Pull request #612 (2021-03-19 17:51:28)

feat: add "New Test" button
This PR adds a 'New Test' menuitem that loads a template from https://github.com/electron/electron-quick-start/tree/test-template .

This is third in a series of PRs (first two are https://github.com/electron/fiddle/pull/610 and https://github.com/electron/fiddle/pull/611) to bring the autobisect branch into `master` in smaller, reviewable chunks instead of one big huge omnibus PR. Next in line is the autobisect feature!

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

Coverage decreased (-0.09%) to 93.044% when pulling **f989f38b821527df87ff61f965a2476734e74dcb on feat/new-test-button** into **adb4488a52df122faab135b6a2ae8f77161492d0 on master**.


Patch:
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/binary.ts b/src/renderer/binary.ts
index 2432e388..9af0bcab 100644
--- a/src/renderer/binary.ts
+++ b/src/renderer/binary.ts
@@ -1,13 +1,13 @@
-import * as fsType from 'fs-extra';
+import * as fs from 'fs-extra';
 import * as path from 'path';
 import extract from 'extract-zip';
 
 import { VersionState } from '../interfaces';
-import { fancyImport } from '../utils/import';
 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>> = {};
 
@@ -18,17 +18,17 @@ const pendingDownloads: Record<string, Promise<void>> = {};
  * @param {string} iVersion
  * @returns {Promise<void>}
  */
-export async function setupBinary(
+export function setupBinary(
   appState: AppState,
   iVersion: string,
 ): Promise<void> {
   const version = normalizeVersion(iVersion);
 
   // If we already have it, then we're done
-  if (await getIsDownloaded(version)) {
+  if (getIsDownloaded(version)) {
     console.log(`Binary: Electron ${version} already downloaded.`);
     appState.versions[version].state = VersionState.ready;
-    return;
+    return Promise.resolve();
   }
 
   // Return a promise that resolves when the download completes
@@ -58,7 +58,6 @@ async function downloadBinary(
     appState.versions[version].state = VersionState.unzipping;
 
     // Ensure the target path exists
-    const fs = await fancyImport<typeof fsType>('fs-extra');
     await fs.mkdirp(extractPath);
 
     // Ensure the target path is empty
@@ -85,7 +84,6 @@ async function downloadBinary(
  */
 export async function removeBinary(iVersion: string) {
   const version = normalizeVersion(iVersion);
-  const fs = await fancyImport<typeof fsType>('fs-extra');
   let isDeleted = false;
 
   // utility to re-run removal functions upon failure
@@ -106,7 +104,7 @@ export async function removeBinary(iVersion: string) {
   };
 
   const binaryCleaner = async () => {
-    if (await getIsDownloaded(version)) {
+    if (getIsDownloaded(version)) {
       // This is necessary since we're messing with .asar files inside
       // the Electron binaries. Electron, powering Fiddle, will try to
       // "correct" our calls, but we don't want that right here.
@@ -135,13 +133,8 @@ export async function removeBinary(iVersion: string) {
  * @param {string} dir
  * @returns {boolean}
  */
-export async function getIsDownloaded(
-  version: string,
-  dir?: string,
-): Promise<boolean> {
+export function getIsDownloaded(version: string, dir?: string): boolean {
   const expectedPath = getElectronBinaryPath(version, dir);
-  const fs = await fancyImport<typeof fsType>('fs-extra');
-
   return fs.existsSync(expectedPath);
 }
 
@@ -183,23 +176,14 @@ export function getDownloadingVersions(appState: AppState) {
  * @returns {Promise<Array<string>>}
  */
 export async function getDownloadedVersions(): Promise<Array<string>> {
-  const fs = await fancyImport<typeof fsType>('fs-extra');
   const downloadPath = path.join(USER_DATA_PATH, 'electron-bin');
   console.log(`Binary: Checking for downloaded versions`);
 
   try {
-    const directories = await fs.readdir(downloadPath);
-    const knownVersions: Array<string> = [];
-
-    for (const directory of directories) {
-      if (await getIsDownloaded(directory)) {
-        knownVersions.push(directory);
-      }
-    }
-
-    return knownVersions;
+    const dirs = await fs.readdir(downloadPath);
+    return dirs.filter((dir) => dir && getIsDownloaded(dir));
   } catch (error) {
-    console.warn(`Could not read known Electron versions`);
+    console.warn(`Could not read known Electron versions`, error);
     return [];
   }
 }
@@ -212,7 +196,6 @@ export async function getDownloadedVersions(): Promise<Array<string>> {
  * @returns {Promise<string>}
  */
 async function download(appState: AppState, version: string): Promise<string> {
-  const { download: electronDownload } = await import('@electron/get');
   const getProgressCallback = (progress: Progress) => {
     const roundedProgress = Math.round(progress.percent * 100) / 100;
 
diff --git a/src/renderer/components/dialog-add-version.tsx b/src/renderer/components/dialog-add-version.tsx
index eea2d1d4..3f5776ae 100644
--- a/src/renderer/components/dialog-add-version.tsx
+++ b/src/renderer/components/dialog-add-version.tsx
@@ -70,9 +70,8 @@ export class AddVersionDialog extends React.Component<
    *
    * @param {React.ChangeEvent<HTMLInputElement>} event
    */
-  public async setFolderPath(folderPath: string) {
-    const isValidElectron = !!(await getIsDownloaded('custom', folderPath));
-
+  public setFolderPath(folderPath: string) {
+    const isValidElectron = getIsDownloaded('custom', folderPath);
     this.setState({ folderPath, isValidElectron });
   }
 
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/content.ts b/src/renderer/content.ts
index a9dac3bc..7968abb7 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,63 @@ 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;
+  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 +123,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 +137,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 6d616349..f0fa0c9f 100644
--- a/src/renderer/runner.ts
+++ b/src/renderer/runner.ts
@@ -71,7 +71,7 @@ export class Runner {
       return false;
     }
 
-    const isReady = await getIsDownloaded(version, localPath);
+    const isReady = getIsDownloaded(version, localPath);
 
     if (!isReady) {
       console.warn(`Runner: Binary ${version} not ready`);
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/src/utils/sorted-electron-map.ts b/src/utils/sorted-electron-map.ts
index 53b6b75f..43dd023c 100644
--- a/src/utils/sorted-electron-map.ts
+++ b/src/utils/sorted-electron-map.ts
@@ -2,49 +2,46 @@ import * as semver from 'semver';
 
 import { RunnableVersion } from '../interfaces';
 
+function electronSemVerCompare(a: semver.SemVer, b: semver.SemVer) {
+  const l = a.compareMain(b);
+  if (l) return l;
+  // Electron's approach is nightly -> beta -> stable.
+  // Account for 'beta' coming before 'nightly' lexicographically
+  if (a.prerelease[0] === 'nightly' && b.prerelease[0] === 'beta') return -1;
+  if (a.prerelease[0] === 'beta' && b.prerelease[0] === 'nightly') return 1;
+  return a.comparePre(b);
+}
+
+function compare(a: semver.SemVer | string, b: semver.SemVer | string) {
+  // non-semver goes first, sorted lexicographically
+  const astr = typeof a === 'string';
+  const bstr = typeof b === 'string';
+  if (astr && bstr) return (a as string).localeCompare(b as string);
+  if (astr) return -1;
+  if (bstr) return 1;
+
+  // then semver, sorted newest-to-oldest
+  return -electronSemVerCompare(a as semver.SemVer, b as semver.SemVer);
+}
+
 /**
- * Sorts Electron versions and returns the result of a
- * map function.
+ * Sorts Electron versions and returns the result of a map function.
  *
- * @param {Record<string, RunnableVersion>} versions
- * @param {(key: string, version: RunnableVersion) => void} mapFn
+ * @param {Record<string, RunnableVersion?>} versions
+ * @param {(key: string, version: RunnableVersion?) => T} mapFn
  * @returns {Array<T>}
  */
 export function sortedElectronMap<T>(
   versions: Record<string, RunnableVersion>,
   mapFn: (key: string, version: RunnableVersion) => T,
 ) {
-  return Object.keys(versions)
-    .sort((a, b) => {
-      if (!semver.valid(a) && !semver.valid(b)) {
-        return a.localeCompare(b);
-      }
-
-      if (!semver.valid(a)) return -1;
-      if (!semver.valid(b)) return 1;
-
-      // Handle prerelease tag sorting - semver does not handle this.
-      // See https://www.npmjs.com/package/semver#prerelease-tags for more.
-      const bothPre = semver.prerelease(a) && semver.prerelease(b);
-      const sameCoerced =
-        semver.valid(semver.coerce(a)) === semver.valid(semver.coerce(b));
-      if (bothPre && sameCoerced) {
-        const [tagA, valueA] = semver.prerelease(a)!;
-        const [tagB, valueB] = semver.prerelease(b)!;
-
-        if (tagA === 'beta' && tagA === tagB) {
-          // Both are betas - compare beta number.
-          return valueA > valueB ? -1 : 1;
-        } else if (tagA === 'nightly' && tagA === tagB) {
-          // Both are nightlies - compare nightly dates strings.
-          return -`${valueA}`.localeCompare(`${valueB}`);
-        } else {
-          // beta > nightly for the same major version.
-          return tagA === 'beta' ? -1 : 1;
-        }
-      }
-
-      return semver.gt(a, b, true) ? -1 : 1;
-    })
-    .map((key) => mapFn(key, versions[key])) as Array<T>;
+  type VerSemRun = [
+    ver: string,
+    sem: semver.SemVer | null,
+    run: RunnableVersion,
+  ];
+  return Object.entries(versions)
+    .map(([ver, run]): VerSemRun => [ver, semver.parse(ver), run])
+    .sort(([vera, sema], [verb, semb]) => compare(sema || vera, semb || verb))
+    .map(([ver, _, run]) => mapFn(ver, run));
 }


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/renderer/binary-spec.ts b/tests/renderer/binary-spec.ts
index 0528a369..c11e45c3 100644
--- a/tests/renderer/binary-spec.ts
+++ b/tests/renderer/binary-spec.ts
@@ -19,9 +19,10 @@ jest.mock('../../src/renderer/constants', () => ({
 }));
 jest.mock('../../src/utils/import', () => ({
   fancyImport: async (p: string) => {
-    if (p === 'fs-extra') {
-      return require('fs-extra');
+    if (p === 'extract-zip') {
+      return { default: require('extract-zip') };
     }
+    throw new Error(`Unexpected module '${p}'`);
   },
 }));
 jest.mock('../../src/renderer/fetch-types', () => ({
@@ -33,6 +34,7 @@ jest.mock('@electron/get', () => ({
 
 describe('binary', () => {
   let mockState: any = {};
+  const fs = require('fs-extra');
 
   beforeEach(() => {
     mockState = {
@@ -50,8 +52,6 @@ describe('binary', () => {
 
   describe('remove()', () => {
     it('removes a version', async () => {
-      const fs = require('fs-extra');
-
       (fs.existsSync as jest.Mock<any>).mockReturnValue(true);
 
       await removeBinary('v3.0.0');
@@ -59,8 +59,6 @@ describe('binary', () => {
     });
 
     it('retries on failure', async () => {
-      const fs = require('fs-extra');
-
       (fs.existsSync as jest.Mock<any>).mockReturnValue(true);
       (fs.remove as jest.Mock<any>).mockImplementation(() => {
         throw new Error('Bwap bwap');
@@ -71,8 +69,6 @@ describe('binary', () => {
     });
 
     it("attempts to clean up the version's associated typedefs", async () => {
-      const fs = require('fs-extra');
-
       (fs.existsSync as jest.Mock<any>).mockReturnValue(true);
 
       await removeBinary('v3.0.0');
@@ -81,7 +77,6 @@ describe('binary', () => {
     });
 
     it('retries typedef cleanup upon failure', async () => {
-      const fs = require('fs-extra');
       (removeTypeDefsForVersion as jest.Mock).mockImplementation(() => {
         throw new Error('Bwap bwap');
       });
@@ -95,30 +90,22 @@ describe('binary', () => {
 
   describe('getDownloadedVersions()', () => {
     it('finds downloaded versions', async () => {
-      const fs = require('fs-extra');
-
-      (fs.readdir as jest.Mock<any>).mockReturnValue(['v3.0.0']);
+      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(['v3.0.0']);
+      expect(result).toEqual(versions);
     });
 
     it('is okay without versions ', async () => {
-      const fs = require('fs-extra');
-
-      (fs.readdir as jest.Mock<any>).mockReturnValue([]);
+      const versions: string[] = [];
+      (fs.readdir as jest.Mock<any>).mockResolvedValue(versions);
       const result = await getDownloadedVersions();
-      expect(result).toEqual([]);
+      expect(result).toEqual(versions);
     });
 
     it('is okay without versions and with errors', async () => {
-      const fs = require('fs-extra');
-
-      (fs.readdir as jest.Mock<any>).mockImplementationOnce(() => {
-        throw new Error('💩');
-      });
+      (fs.readdir as jest.Mock<any>).mockRejectedValueOnce('💩');
       const result = await getDownloadedVersions();
       expect(result).toEqual([]);
     });
@@ -165,40 +152,43 @@ describe('binary', () => {
   });
 
   describe('setupBinary()', () => {
-    it(`downloads a version it hasn't seen before`, async () => {
-      const { download } = require('@electron/get');
-      download.mockReturnValue('/fake/path');
+    const { download } = require('@electron/get');
 
-      mockState.versions['3.0.0'] = { state: 'unknown' };
-      await setupBinary(mockState, 'v3.0.0');
+    it(`downloads a version it has not seen before`, async () => {
+      (fs.existsSync as jest.Mock).mockReturnValueOnce(false);
+
+      const localPath = '/fake/path';
+      download.mockResolvedValue(localPath);
+
+      const version = '3.0.0';
+      mockState.versions[version] = { state: 'unknown' };
+      await setupBinary(mockState, version);
 
       expect(download).toHaveBeenCalled();
-      expect(mockState.versions['3.0.0'].state).toBe('ready');
+      expect(mockState.versions[version].state).toBe('ready');
     });
 
     it(`does not download a version again`, async () => {
-      const fs = require('fs-extra');
       (fs.existsSync as jest.Mock).mockReturnValue(true);
-      const { download } = require('@electron/get');
 
-      mockState.versions['3.0.0'] = { state: 'unknown' };
-      await setupBinary(mockState, 'v3.0.0');
+      const version = '3.0.0';
+      mockState.versions[version] = { state: 'unknown' };
+
+      await setupBinary(mockState, version);
 
-      expect(download).toHaveBeenCalledTimes(0);
-      expect(mockState.versions['3.0.0'].state).toBe('ready');
+      expect(download).not.toHaveBeenCalled();
+      expect(mockState.versions[version].state).toBe('ready');
     });
 
     it(`waits if asked for a version is already downloading`, async () => {
-      const fs = require('fs-extra');
-      const { download } = require('@electron/get');
-
       (fs.existsSync as jest.Mock).mockReturnValue(true);
-      mockState.versions['3.0.0'].state = 'downloading';
 
-      await setupBinary(mockState, 'v3.0.0');
+      const version = '3.0.0';
+      mockState.versions[version] = { state: 'downloading' };
+      await setupBinary(mockState, version);
 
-      expect(download).toHaveBeenCalledTimes(0);
-      expect(mockState.versions['3.0.0'].state).toBe('ready');
+      expect(download).not.toHaveBeenCalled();
+      expect(mockState.versions[version].state).toBe('ready');
     });
 
     it('handles an error in the zip file', async () => {
diff --git a/tests/renderer/components/dialog-add-version-spec.tsx b/tests/renderer/components/dialog-add-version-spec.tsx
index c6a7bca0..0fb38acd 100644
--- a/tests/renderer/components/dialog-add-version-spec.tsx
+++ b/tests/renderer/components/dialog-add-version-spec.tsx
@@ -29,9 +29,6 @@ describe('AddVersionDialog component', () => {
     store = {
       isAddVersionDialogShowing: true,
       addLocalVersion: jest.fn(),
-      binaryManager: {
-        getIsDownloaded: jest.fn(),
-      },
     };
   });
 
@@ -70,7 +67,7 @@ describe('AddVersionDialog component', () => {
 
   describe('setFolderPath()', () => {
     it('does something', async () => {
-      (getIsDownloaded as jest.Mock).mockResolvedValue(true);
+      (getIsDownloaded as jest.Mock).mockReturnValue(true);
       const wrapper = shallow(<AddVersionDialog appState={store} />);
       await (wrapper.instance() as any).setFolderPath('/test/');
 
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/utils/sorted-electron-map-spec.ts b/tests/utils/sorted-electron-map-spec.ts
index d25108e1..fce6b841 100644
--- a/tests/utils/sorted-electron-map-spec.ts
+++ b/tests/utils/sorted-electron-map-spec.ts
@@ -27,23 +27,17 @@ describe('sorted-electron-map', () => {
       '2.0.0-nightly.20200101': {
         version: 'v2.0.0-nightly.20200101',
       },
+      '2.0.0-beta.1': {
+        version: 'v2.0.0-beta.1',
+      },
       '2.0.0-nightly.20200102': {
         version: 'v2.0.0-nightly.20200102',
       },
-      '2.0.0': {
-        version: 'v2.0.0',
-      },
-      '3.0.0-nightly.20200105': {
-        version: 'v3.0.0-nightly.20200105',
-      },
-      '3.0.0': {
-        version: 'v3.0.0',
-      },
       '3.0.0-beta.1': {
         version: 'v3.0.0-beta.1',
       },
-      '2.0.0-beta.1': {
-        version: 'v2.0.0-beta.1',
+      '3.0.0-nightly.20200105': {
+        version: 'v3.0.0-nightly.20200105',
       },
       '2.0.0-beta.2': {
         version: 'v2.0.0-beta.2',
@@ -51,6 +45,12 @@ describe('sorted-electron-map', () => {
       '2.0.0-beta.3': {
         version: 'v2.0.0-beta.3',
       },
+      '2.0.0': {
+        version: 'v2.0.0',
+      },
+      '3.0.0': {
+        version: 'v3.0.0',
+      },
     };
 
     const result = sortedElectronMap(map, (_k, e) => e);