Repository: electron/fiddle
Pull request #677 (2021-04-29 18:36:26)

refactor: EditorValues does not need all defaults
Part 3 of breaking https://github.com/electron/fiddle/pull/664 into smaller, human-reviewable pieces.

This is a short PR but it makes two important changes to the code's assumptions:

1. `CustomEditorId` is replaced with `EditorId`, which is any file that ends in html, js, or css. The default editors are now a _subset_ of `EditorId`.

2. `EditorValues` is now a `Record<EditorId, string>`. The requirement that it contains every default editor has been removed. The only remaining [special case is main.js](https://github.com/electron/fiddle/blob/7550ab87a324713ec6ae76ba2ae0d059d093634c/src/interfaces.ts#L140), which must always be present in order to run and/or package the fiddle. (For example most of the show-me templates only have a main.js)

A lot of this PR is spent removing that "EditorValues contains all defaults" assumption from the file-manager and read-fiddle tests.

*edit:* This PR has no new uncovered code. Coverage is decreasing because it removes (slightly) more production code than it adds.

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

Coverage decreased (-0.007%) to 95.996% when pulling **e128a7d16500a72c4e48d34f1dc98dc842a12ad3 on refactor/editor-values** into **7550ab87a324713ec6ae76ba2ae0d059d093634c on master**.


Patch:
diff --git a/src/interfaces.ts b/src/interfaces.ts
index ea093eaf..f68c7f14 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -33,15 +33,6 @@ export interface Version {
   localPath?: string;
 }
 
-export interface EditorValues {
-  [DefaultEditorId.main]: string;
-  [DefaultEditorId.renderer]: string;
-  [DefaultEditorId.html]: string;
-  [DefaultEditorId.preload]: string;
-  [DefaultEditorId.css]: string;
-  PACKAGE_NAME?: string;
-}
-
 export enum RunResult {
   SUCCESS = 'success', // exit code === 0
   FAILURE = 'failure', // ran, but exit code !== 0
@@ -113,6 +104,10 @@ export const enum GenericDialogType {
   'success' = 'success',
 }
 
+export type EditorId = `${string}.${'js' | 'html' | 'css'}`;
+
+export type EditorValues = Record<EditorId, string>;
+
 // Default Editors
 export enum DefaultEditorId {
   'main' = 'main.js',
@@ -122,13 +117,6 @@ export enum DefaultEditorId {
   'css' = 'styles.css',
 }
 
-export type CustomEditorId = Exclude<
-  `${string}.${'js' | 'html' | 'css'}`,
-  DefaultEditorId
->;
-
-export type EditorId = DefaultEditorId | CustomEditorId;
-
 export const DEFAULT_EDITORS = [
   DefaultEditorId.main,
   DefaultEditorId.renderer,
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 7b6566c1..dd8280bd 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -11,7 +11,6 @@ import {
   GenericDialogType,
   SetFiddleOptions,
   EditorId,
-  CustomEditorId,
   PACKAGE_NAME,
   DEFAULT_EDITORS,
 } from '../interfaces';
@@ -19,7 +18,11 @@ import { WEBCONTENTS_READY_FOR_IPC_SIGNAL } from '../ipc-events';
 import { updateEditorLayout } from '../utils/editor-layout';
 import { getEditorValue } from '../utils/editor-value';
 import { getPackageJson, PackageJsonOptions } from '../utils/get-package';
-import { compareEditors, getEmptyContent } from '../utils/editor-utils';
+import {
+  compareEditors,
+  getEmptyContent,
+  isKnownFile,
+} from '../utils/editor-utils';
 import { FileManager } from './file-manager';
 import { RemoteLoader } from './remote-loader';
 import { Runner } from './runner';
@@ -52,7 +55,7 @@ export class App {
   }
 
   public async replaceFiddle(
-    editorValues: Partial<EditorValues>,
+    editorValues: EditorValues,
     { filePath, gistId, templateName }: Partial<SetFiddleOptions>,
   ) {
     // if unsaved, prompt user to make sure they're okay with overwriting and changing directory
@@ -71,15 +74,9 @@ export class App {
     }
 
     // Remove all previously created custom editors.
-    this.state.customMosaics = [];
-    const customEditors = Object.keys(editorValues).filter(
-      (v) => !Object.values(DefaultEditorId).includes(v as DefaultEditorId),
-    ) as CustomEditorId[];
-
-    // Re-add new custom editors.
-    for (const mosaic of customEditors) {
-      this.state.customMosaics.push(mosaic);
-    }
+    this.state.customMosaics = Object.keys(editorValues).filter(
+      (filename: string) => !isKnownFile(filename),
+    ) as EditorId[];
 
     // If the gist content is empty or matches the empty file output, don't show it.
     const shouldShow = (id: EditorId, val?: string) => {
@@ -88,7 +85,7 @@ export class App {
 
     // Sort and display all editors that have content.
     const visibleEditors: EditorId[] = Object.entries(editorValues)
-      .filter(([id, content]) => shouldShow(id as EditorId, content))
+      .filter(([id, content]) => shouldShow(id as EditorId, content as string))
       .map(([id]) => id as DefaultEditorId)
       .sort(compareEditors);
 
@@ -109,7 +106,7 @@ export class App {
    *
    * @param {EditorValues} values
    */
-  public async setEditorValues(values: Partial<EditorValues>): Promise<void> {
+  public async setEditorValues(values: EditorValues): Promise<void> {
     const { ElectronFiddle: fiddle } = window;
 
     if (!fiddle?.app) {
@@ -157,7 +154,7 @@ export class App {
       throw new Error('Fiddle not ready');
     }
 
-    const values = {} as EditorValues;
+    const values: EditorValues = {};
     for (const editor in fiddle?.editors) {
       values[editor] = getEditorValue(editor as EditorId);
     }
diff --git a/src/renderer/components/commands-editors.tsx b/src/renderer/components/commands-editors.tsx
index d89c4bdc..3c80be0d 100644
--- a/src/renderer/components/commands-editors.tsx
+++ b/src/renderer/components/commands-editors.tsx
@@ -12,10 +12,9 @@ import * as React from 'react';
 
 import {
   DEFAULT_EDITORS,
-  CustomEditorId,
-  GenericDialogType,
-  EditorId,
   DefaultEditorId,
+  EditorId,
+  GenericDialogType,
 } from '../../interfaces';
 import { getVisibleMosaics } from '../../utils/editors-mosaic-arrangement';
 import { AppState } from '../state';
@@ -173,7 +172,7 @@ export class EditorDropdown extends React.Component<
 
       appState.toggleGenericDialog();
     } else {
-      const name = result as CustomEditorId;
+      const name = result as EditorId;
 
       // Also fail if the user tries to create two identical editors.
       if (
diff --git a/src/renderer/npm.ts b/src/renderer/npm.ts
index ca082697..10c64f9d 100644
--- a/src/renderer/npm.ts
+++ b/src/renderer/npm.ts
@@ -81,7 +81,7 @@ export async function findModulesInEditors(values: EditorValues) {
   // Filter out all editor values which aren't JavaScript files.
   const contents = Object.entries(values)
     .filter(([filename]) => filename.endsWith('.js'))
-    .map(([_, content]) => content);
+    .map(([_, content]) => content) as string[];
 
   for (const content of contents) {
     const found = await findModules(content);
diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts
index cfcbc5cf..ffc11722 100644
--- a/src/renderer/remote-loader.ts
+++ b/src/renderer/remote-loader.ts
@@ -115,7 +115,7 @@ export class RemoteLoader {
     try {
       const octo = await getOctokit(this.appState);
       const gist = await octo.gists.get({ gist_id: gistId });
-      const values: Partial<EditorValues> = {};
+      const values: EditorValues = {};
 
       for (const [id, data] of Object.entries(gist.data.files)) {
         if (!isSupportedFile(id)) {
@@ -231,12 +231,12 @@ export class RemoteLoader {
   /**
    * Loading a fiddle from GitHub succeeded, let's move on.
    *
-   * @param {Partial<EditorValues>} values
+   * @param {EditorValues} values
    * @param {string} gistId
    * @returns {boolean}
    */
   private async handleLoadingSuccess(
-    values: Partial<EditorValues>,
+    values: EditorValues,
     gistId: string,
   ): Promise<boolean> {
     await window.ElectronFiddle.app.replaceFiddle(values, { gistId });
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index d3fc06c0..8db7950d 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -5,7 +5,6 @@ import { MosaicNode } from 'react-mosaic-component';
 
 import {
   BlockableAccelerator,
-  CustomEditorId,
   DEFAULT_EDITORS,
   EditorId,
   ElectronReleaseChannel,
@@ -127,7 +126,7 @@ export class AppState {
     wantsInput: false,
     placeholder: '',
   };
-  @observable public customMosaics: CustomEditorId[] = [];
+  @observable public customMosaics: EditorId[] = [];
   @observable public genericDialogLastResult: boolean | null = null;
   @observable public genericDialogLastInput: string | null = null;
   @observable
diff --git a/src/utils/read-fiddle.ts b/src/utils/read-fiddle.ts
index 29cc9882..6f947fd7 100644
--- a/src/utils/read-fiddle.ts
+++ b/src/utils/read-fiddle.ts
@@ -1,5 +1,5 @@
-import { DefaultEditorId, EditorValues } from '../interfaces';
-import { isKnownFile, isSupportedFile } from './editor-utils';
+import { EditorValues, MAIN_JS } from '../interfaces';
+import { isSupportedFile } from './editor-utils';
 
 import * as fs from 'fs-extra';
 import * as path from 'path';
@@ -11,24 +11,12 @@ import * as path from 'path';
  * @returns {Promise<EditorValues>} the loaded Fiddle
  */
 export async function readFiddle(folder: string): Promise<EditorValues> {
-  const customMosaics: Record<string, string> = {};
-  const defaultMosaics: EditorValues = {
-    [DefaultEditorId.css]: '',
-    [DefaultEditorId.html]: '',
-    [DefaultEditorId.main]: '',
-    [DefaultEditorId.preload]: '',
-    [DefaultEditorId.renderer]: '',
-  };
-
-  const hits: string[] = [];
-  const misses = new Set(Object.values(DefaultEditorId));
+  const got: EditorValues = {};
 
   const tryRead = (name: string) => {
     try {
       const filename = path.join(folder, name);
       const content = fs.readFileSync(filename, 'utf-8');
-      hits.push(name);
-      misses.delete(name as DefaultEditorId);
       return content || '';
     } catch (error) {
       console.warn(`Could not read file ${name}:`, error);
@@ -40,18 +28,16 @@ export async function readFiddle(folder: string): Promise<EditorValues> {
     console.warn(`readFiddle(): "${folder}" does not exist`);
   } else {
     for (const file of fs.readdirSync(folder)) {
-      if (isKnownFile(file)) {
-        defaultMosaics[file] = tryRead(file);
-      } else if (isSupportedFile(file)) {
-        customMosaics[file] = tryRead(file);
+      if (isSupportedFile(file)) {
+        got[file] = tryRead(file);
       }
     }
   }
 
-  console.log(`Got Fiddle from "${folder}".
-Found Default Mosaics: ${hits.sort().join(', ')}
-Found Custom Mosaics: ${Object.keys(customMosaics).sort().join(', ')}
-Missed: ${[...misses].sort().join(', ')}`);
+  if (!(MAIN_JS in got)) {
+    got[MAIN_JS] = '';
+  }
 
-  return { ...defaultMosaics, ...customMosaics };
+  console.log(`Got Fiddle from "${folder}". Found:`, Object.keys(got).sort());
+  return got;
 }


Test patch:
diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts
index a19c9599..7f880774 100644
--- a/tests/mocks/state.ts
+++ b/tests/mocks/state.ts
@@ -3,11 +3,10 @@ import { MosaicNode } from 'react-mosaic-component';
 
 import {
   BlockableAccelerator,
-  CustomEditorId,
+  EditorId,
   ElectronReleaseChannel,
   GenericDialogOptions,
   GistActionState,
-  EditorId,
   RunnableVersion,
 } from '../../src/interfaces';
 import { EditorBackup } from '../../src/utils/editor-backup';
@@ -22,7 +21,7 @@ export class StateMock {
   @observable public closedPanels: Partial<
     Record<EditorId, EditorBackup | true>
   > = {};
-  @observable public customMosaics: CustomEditorId[] = [];
+  @observable public customMosaics: EditorId[] = [];
   @observable public environmentVariables: string[] = [];
   @observable public executionFlags: string[] = [];
   @observable public genericDialogLastInput: string | null = null;
diff --git a/tests/renderer/file-manager-spec.ts b/tests/renderer/file-manager-spec.ts
index 1cd462e5..0eea7ac4 100644
--- a/tests/renderer/file-manager-spec.ts
+++ b/tests/renderer/file-manager-spec.ts
@@ -1,12 +1,14 @@
 import * as fs from 'fs-extra';
-import * as path from 'path';
 
-import { DefaultEditorId, Files } from '../../src/interfaces';
+import { AppState } from '../../src/renderer/state';
+import { Files, PACKAGE_NAME, SetFiddleOptions } from '../../src/interfaces';
 import { IpcEvents } from '../../src/ipc-events';
 import { FileManager } from '../../src/renderer/file-manager';
 import { ipcRendererManager } from '../../src/renderer/ipc';
+import { readFiddle } from '../../src/utils/read-fiddle';
 
 import { AppMock } from '../mocks/mocks';
+import { createEditorValues } from '../mocks/editor-values';
 
 jest.mock('fs-extra');
 jest.mock('tmp', () => ({
@@ -15,25 +17,23 @@ jest.mock('tmp', () => ({
     name: '/fake/temp',
   })),
 }));
-jest.mock('../../src/renderer/templates', () => ({
-  getTemplateValues: () => ({
-    [DefaultEditorId.html]: '',
-    [DefaultEditorId.main]: '',
-    [DefaultEditorId.renderer]: '',
-  }),
+
+jest.mock('../../src/utils/read-fiddle', () => ({
+  readFiddle: jest.fn(),
 }));
 
 describe('FileManager', () => {
+  const editorValues = createEditorValues();
   let app: AppMock;
   let fm: FileManager;
 
   beforeEach(() => {
-    ({ app } = (window as any).ElectronFiddle);
     ipcRendererManager.send = jest.fn();
+    (readFiddle as jest.Mock).mockReturnValue(Promise.resolve(editorValues));
 
-    fm = new FileManager({
-      setGenericDialogOptions: jest.fn(),
-    } as any);
+    // create a real FileManager and insert it into our mocks
+    ({ app } = (window as any).ElectronFiddle);
+    fm = new FileManager((app.state as unknown) as AppState);
     app.fileManager = fm as any;
   });
 
@@ -42,68 +42,25 @@ describe('FileManager', () => {
   });
 
   describe('openFiddle()', () => {
+    const filePath = '/fake/path';
+
     it('opens a local fiddle', async () => {
-      const fakePath = '/fake/path';
-      await fm.openFiddle(fakePath);
-
-      expect(app.replaceFiddle).toHaveBeenCalledWith<any>(
-        {
-          [DefaultEditorId.html]: '',
-          [DefaultEditorId.renderer]: '',
-          [DefaultEditorId.preload]: '',
-          [DefaultEditorId.main]: '',
-          [DefaultEditorId.css]: '',
-        },
-        { filePath: fakePath },
-      );
+      const opts: SetFiddleOptions = { filePath };
+      await fm.openFiddle(filePath);
+      expect(app.replaceFiddle).toHaveBeenCalledWith(editorValues, opts);
     });
 
-    it('can open a fiddle with custom editors', async () => {
-      const fakePath = '/fake/path';
+    it('opens a fiddle with custom editors', async () => {
       const file = 'file.js';
+      const content = 'hey';
+      const values = { ...editorValues, [file]: content };
+      (readFiddle as jest.Mock).mockResolvedValue(values);
 
       app.remoteLoader.verifyCreateCustomEditor.mockResolvedValue(true);
-      (fs.existsSync as jest.Mock).mockImplementationOnce(() => true);
-      (fs.readdirSync as jest.Mock).mockImplementation(() => [
-        file,
-        DefaultEditorId.html,
-      ]);
-      (fs.readFileSync as jest.Mock).mockImplementation((filename) => {
-        return path.basename(filename) === file ? 'hey' : '';
-      });
-
-      await fm.openFiddle(fakePath);
-
-      expect(app.replaceFiddle).toHaveBeenCalledWith<any>(
-        {
-          [DefaultEditorId.html]: '',
-          [DefaultEditorId.renderer]: '',
-          [DefaultEditorId.preload]: '',
-          [DefaultEditorId.main]: '',
-          [DefaultEditorId.css]: '',
-          [file]: 'hey',
-        },
-        { filePath: fakePath },
-      );
-    });
 
-    it('writes empty strings if readFile throws an error', async () => {
-      (fs.readFile as jest.Mock).mockImplementation(() => {
-        throw new Error('bwap');
-      });
-      const fakePath = '/fake/path';
-      await fm.openFiddle(fakePath);
-
-      expect(app.replaceFiddle).toHaveBeenCalledWith<any>(
-        {
-          [DefaultEditorId.html]: '',
-          [DefaultEditorId.renderer]: '',
-          [DefaultEditorId.preload]: '',
-          [DefaultEditorId.main]: '',
-          [DefaultEditorId.css]: '',
-        },
-        { filePath: fakePath },
-      );
+      await fm.openFiddle(filePath);
+      expect(readFiddle).toHaveBeenCalledWith(filePath);
+      expect(app.replaceFiddle).toHaveBeenCalledWith(values, { filePath });
     });
 
     it('runs it on IPC event', () => {
@@ -125,26 +82,22 @@ describe('FileManager', () => {
 
   describe('saveFiddle()', () => {
     it('saves all non-empty files in Fiddle', async () => {
-      await fm.saveFiddle('/fake/path');
+      const values = { ...editorValues };
+      app.getEditorValues.mockReturnValue(values);
 
-      expect(fs.outputFile).toHaveBeenCalledTimes(5);
+      await fm.saveFiddle('/fake/path');
+      expect(fs.outputFile).toHaveBeenCalledTimes(Object.keys(values).length);
     });
 
     it('saves a fiddle with custom editors', async () => {
       const file = 'file.js';
+      const content = 'hi';
+      const values = { ...editorValues, [file]: content };
       app.state.customMosaics = [file];
-      (app.getEditorValues as jest.Mock<any>).mockReturnValueOnce({
-        [file]: 'hi',
-        [DefaultEditorId.html]: 'html',
-        [DefaultEditorId.renderer]: 'renderer',
-        [DefaultEditorId.preload]: 'preload',
-        [DefaultEditorId.main]: 'main',
-        [DefaultEditorId.css]: 'css',
-      });
+      app.getEditorValues.mockReturnValueOnce(values);
 
       await fm.saveFiddle('/fake/path');
-
-      expect(fs.outputFile).toHaveBeenCalledTimes(6);
+      expect(fs.outputFile).toHaveBeenCalledTimes(Object.keys(values).length);
     });
 
     it('removes a file that is newly empty', async () => {
@@ -160,8 +113,9 @@ describe('FileManager', () => {
 
       await fm.saveFiddle('/fake/path');
 
-      expect(fs.outputFile).toHaveBeenCalledTimes(5);
-      expect(ipcRendererManager.send).toHaveBeenCalledTimes(5);
+      const n = Object.keys(editorValues).length;
+      expect(fs.outputFile).toHaveBeenCalledTimes(n);
+      expect(ipcRendererManager.send).toHaveBeenCalledTimes(n);
     });
 
     it('handles an error (remove)', async () => {
@@ -234,17 +188,11 @@ describe('FileManager', () => {
 
   describe('openTemplate()', () => {
     it('attempts to open a template', async () => {
-      await fm.openTemplate('test');
-      expect(app.replaceFiddle).toHaveBeenCalledWith<any>(
-        {
-          [DefaultEditorId.html]: '',
-          [DefaultEditorId.renderer]: '',
-          [DefaultEditorId.main]: '',
-        },
-        {
-          templateName: 'test',
-        },
-      );
+      const templateName = 'test';
+      await fm.openTemplate(templateName);
+      expect(app.replaceFiddle).toHaveBeenCalledWith(editorValues, {
+        templateName,
+      });
     });
 
     it('runs openTemplate on IPC event', () => {
@@ -284,49 +232,41 @@ describe('FileManager', () => {
   });
 
   describe('getFiles()', () => {
-    it('applies transforms', async () => {
-      const result = await fm.getFiles(undefined, async (files: Files) => {
-        files.set(DefaultEditorId.renderer, 'hi');
-        return files;
-      });
+    let expected: Files;
+
+    beforeEach(() => {
+      app.getEditorValues.mockReturnValue(editorValues);
+      expected = new Map(Object.entries(editorValues));
+      expected.set(PACKAGE_NAME, undefined as any);
+    });
 
-      expect(result.get(DefaultEditorId.renderer)).toBe('hi');
+    it(`always inserts ${PACKAGE_NAME}`, async () => {
+      expect(await fm.getFiles()).toStrictEqual(expected);
     });
 
-    it('handles custom editors', async () => {
+    it('includes custom editors', async () => {
       const file = 'file.js';
+      const content = '// file.js';
+      const values = { ...editorValues, [file]: content };
+      app.getEditorValues.mockReturnValue(values);
+      expected.set(file, content);
       app.state.customMosaics = [file];
 
-      (app.getEditorValues as jest.Mock<any>).mockReturnValueOnce({
-        [file]: 'file',
-        [DefaultEditorId.html]: 'html',
-        [DefaultEditorId.renderer]: 'renderer',
-        [DefaultEditorId.preload]: 'preload',
-        [DefaultEditorId.main]: 'main',
-        [DefaultEditorId.css]: 'css',
-      });
-
-      const result = await fm.getFiles(undefined, async (files: Files) => {
-        files.forEach((value, key) => {
-          files.set(key, `${value}!`);
-        });
-        return files;
-      });
+      expect(await fm.getFiles()).toStrictEqual(expected);
+    });
 
-      expect(result.get(DefaultEditorId.main)).toBe('main!');
-      expect(result.get(DefaultEditorId.renderer)).toBe('renderer!');
-      expect(result.get(DefaultEditorId.preload)).toBe('preload!');
-      expect(result.get(DefaultEditorId.html)).toBe('html!');
-      expect(result.get(DefaultEditorId.css)).toBe('css!');
-      expect(result.get(file)).toBe('file!');
+    it('applies transforms', async () => {
+      const transformed: Files = new Map([['👉', '👈']]);
+      const transform = async () => transformed;
+      expect(await fm.getFiles(undefined, transform)).toBe(transformed);
     });
 
     it('handles transform error', async () => {
-      const result = await fm.getFiles(undefined, async () => {
-        throw new Error('bwap bwap');
-      });
-
-      expect(result.get(DefaultEditorId.renderer)).toBe('renderer-content');
+      const transform = async () => {
+        throw new Error('💩');
+      };
+      const result = await fm.getFiles(undefined, transform);
+      expect(result).toStrictEqual(expected);
     });
   });
 });
diff --git a/tests/renderer/templates-spec.ts b/tests/renderer/templates-spec.ts
index e1193de4..7b807b19 100644
--- a/tests/renderer/templates-spec.ts
+++ b/tests/renderer/templates-spec.ts
@@ -1,4 +1,4 @@
-import { DefaultEditorId } from '../../src/interfaces';
+import { MAIN_JS } from '../../src/interfaces';
 import { getTemplateValues } from '../../src/renderer/templates';
 
 jest.unmock('fs-extra');
@@ -10,18 +10,12 @@ describe('templates', () => {
   describe('getTemplateValues()', () => {
     it('loads templates', async () => {
       const values = await getTemplateValues(KNOWN_GOOD_TEMPLATE);
-
-      expect(values[DefaultEditorId.html]).toMatch(/^<!DOCTYPE html>/);
-      expect(values[DefaultEditorId.main].length).toBeGreaterThan(0);
-      expect(values[DefaultEditorId.renderer].length).toBeGreaterThan(0);
+      expect(values[MAIN_JS].length).toBeGreaterThan(0);
     });
 
     it('handles errors', async () => {
       const values = await getTemplateValues(KNOWN_BAD_TEMPLATE);
-
-      expect(values[DefaultEditorId.html]).toBe('');
-      expect(values[DefaultEditorId.main]).toBe('');
-      expect(values[DefaultEditorId.renderer]).toBe('');
+      expect(values[MAIN_JS]).toBe('');
     });
 
     it('reports missing files', async () => {
@@ -29,9 +23,9 @@ describe('templates', () => {
 
       await getTemplateValues(KNOWN_BAD_TEMPLATE);
 
-      expect(console.log).toHaveBeenCalledTimes(1);
-      expect((console.log as jest.Mock).mock.calls[0][0]).toMatch(
-        'Missed: index.html, main.js, preload.js, renderer.js, styles.css',
+      expect(console.log).toHaveBeenCalledWith(
+        expect.stringMatching('Got Fiddle from'),
+        [MAIN_JS],
       );
 
       (console.log as jest.Mock).mockClear();
diff --git a/tests/utils/read-fiddle-spec.ts b/tests/utils/read-fiddle-spec.ts
index cc82f1df..83a82b47 100644
--- a/tests/utils/read-fiddle-spec.ts
+++ b/tests/utils/read-fiddle-spec.ts
@@ -1,20 +1,25 @@
-import * as path from 'path';
 import * as fs from 'fs-extra';
+import * as path from 'path';
 
-import { readFiddle } from '../../src/utils/read-fiddle';
+import { EditorValues, MAIN_JS } from '../../src/interfaces';
+import { createEditorValues } from '../mocks/editor-values';
 import { isSupportedFile } from '../../src/utils/editor-utils';
-import { DefaultEditorId } from '../../src/interfaces';
-
-const defaultFiles = ['LICENSE.txt', DefaultEditorId.main];
+import { readFiddle } from '../../src/utils/read-fiddle';
 
 describe('read-fiddle', () => {
   const folder = '/some/place';
 
-  beforeEach(async () => {
+  beforeEach(() => {
     (fs.existsSync as jest.Mock).mockImplementationOnce(() => true);
+    (fs.readFileSync as jest.Mock).mockImplementation((filename) => filename);
+    console.warn = jest.fn();
+  });
+
+  afterEach(() => {
+    (console.warn as jest.Mock).mockClear();
   });
 
-  function setupFSMocks(editorValues: Record<string, string>) {
+  function setupFSMocks(editorValues: EditorValues) {
     (fs.existsSync as jest.Mock).mockReturnValue(true);
     (fs.readdirSync as jest.Mock).mockReturnValue(Object.keys(editorValues));
     (fs.readFileSync as jest.Mock).mockImplementation((filename) => {
@@ -22,57 +27,30 @@ describe('read-fiddle', () => {
     });
   }
 
-  it('reads known content', async () => {
-    (fs.readdirSync as jest.Mock).mockImplementationOnce(() => defaultFiles);
-    (fs.readFileSync as jest.Mock).mockImplementation((filename) =>
-      path.basename(filename),
-    );
-    console.warn = jest.fn();
-    const folder = '/some/place';
-
-    const mosaics = await readFiddle(folder);
+  it('injects main.js if not present', async () => {
+    const mockValues = {}; // no files
+    setupFSMocks(mockValues);
 
-    expect(Object.keys(mosaics)).toHaveLength(5);
-    expect(mosaics[DefaultEditorId.css]).toBe('');
-    expect(mosaics[DefaultEditorId.html]).toBe('');
-    expect(mosaics[DefaultEditorId.main]).toBe(DefaultEditorId.main);
-    expect(mosaics[DefaultEditorId.preload]).toBe('');
-    expect(mosaics[DefaultEditorId.renderer]).toBe('');
-    expect(console.warn).toHaveBeenCalledTimes(0);
+    const fiddle = await readFiddle(folder);
 
-    (console.warn as jest.Mock).mockClear();
+    expect(console.warn).not.toHaveBeenCalled();
+    expect(fiddle).toStrictEqual({ [MAIN_JS]: '' });
   });
 
-  it('reads custom content', async () => {
-    const file = 'file.js';
-    (fs.readdirSync as jest.Mock).mockImplementationOnce(() => [file]);
-    (fs.readFileSync as jest.Mock).mockImplementation(() => '');
-
-    console.warn = jest.fn();
-    const folder = '/some/place';
-
-    const mosaics = await readFiddle(folder);
+  it('reads supported files', async () => {
+    const content = 'hello';
+    const mockValues = { [MAIN_JS]: content };
+    setupFSMocks(mockValues);
 
-    expect(Object.keys(mosaics)).toHaveLength(6);
-    expect(mosaics[file]).toBe('');
-    expect(mosaics[DefaultEditorId.css]).toBe('');
-    expect(mosaics[DefaultEditorId.html]).toBe('');
-    expect(mosaics[DefaultEditorId.main]).toBe('');
-    expect(mosaics[DefaultEditorId.preload]).toBe('');
-    expect(mosaics[DefaultEditorId.renderer]).toBe('');
-    expect(console.warn).toHaveBeenCalledTimes(0);
+    const fiddle = await readFiddle(folder);
 
-    (console.warn as jest.Mock).mockClear();
+    expect(fiddle).toStrictEqual(mockValues);
   });
 
   it('skips unsupported files', async () => {
     const content = 'hello';
     const mockValues = {
-      [DefaultEditorId.css]: content,
-      [DefaultEditorId.html]: content,
-      [DefaultEditorId.main]: content,
-      [DefaultEditorId.preload]: content,
-      [DefaultEditorId.renderer]: content,
+      [MAIN_JS]: content,
       'file.js': content,
       'file.php': content,
     };
@@ -82,46 +60,34 @@ describe('read-fiddle', () => {
     );
 
     const fiddle = await readFiddle(folder);
+
     expect(fiddle).toStrictEqual(expected);
   });
 
   it('handles read errors gracefully', async () => {
-    (fs.readdirSync as jest.Mock).mockImplementationOnce(() => defaultFiles);
+    const mockValues = createEditorValues();
+    setupFSMocks(mockValues);
     (fs.readFileSync as jest.Mock).mockImplementation(() => {
       throw new Error('bwap');
     });
-    console.warn = jest.fn();
-    const folder = '/some/place';
 
-    const mosaics = await readFiddle(folder);
+    const files = await readFiddle(folder);
 
-    expect(Object.keys(mosaics)).toHaveLength(5);
-    expect(mosaics[DefaultEditorId.css]).toBe('');
-    expect(mosaics[DefaultEditorId.html]).toBe('');
-    expect(mosaics[DefaultEditorId.main]).toBe('');
-    expect(mosaics[DefaultEditorId.preload]).toBe('');
-    expect(mosaics[DefaultEditorId.renderer]).toBe('');
-    expect(console.warn).toHaveBeenCalledTimes(1);
-
-    (console.warn as jest.Mock).mockClear();
+    const expectedFiles = Object.keys(mockValues);
+    expect(Object.keys(files)).toStrictEqual(expectedFiles);
+    Object.values(files).forEach((content) => expect(content).toBe(''));
+    expect(console.warn).toHaveBeenCalledTimes(expectedFiles.length);
   });
 
   it('ensures truthy even when read returns null', async () => {
-    (fs.readdirSync as jest.Mock).mockImplementationOnce(() => defaultFiles);
-    (fs.readFileSync as jest.Mock).mockImplementation(() => null);
-    console.warn = jest.fn();
-    const folder = '/some/place';
+    const mockValues = createEditorValues();
+    setupFSMocks(mockValues);
+    (fs.readFileSync as jest.Mock).mockReturnValue(null);
 
-    const mosaics = await readFiddle(folder);
+    const files = await readFiddle(folder);
 
-    expect(Object.keys(mosaics)).toHaveLength(5);
-    expect(mosaics[DefaultEditorId.css]).toBe('');
-    expect(mosaics[DefaultEditorId.html]).toBe('');
-    expect(mosaics[DefaultEditorId.main]).toBe('');
-    expect(mosaics[DefaultEditorId.preload]).toBe('');
-    expect(mosaics[DefaultEditorId.renderer]).toBe('');
+    expect(Object.keys(files)).toStrictEqual(Object.keys(mockValues));
+    Object.values(files).forEach((content) => expect(content).toBe(''));
     expect(console.warn).toHaveBeenCalledTimes(0);
-
-    (console.warn as jest.Mock).mockClear();
   });
 });