diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 532938e9ee..c576411de2 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -1,23 +1,28 @@ -import { ReleaseInfo, SemVer } from '@electron/fiddle-core'; import * as MonacoType from 'monaco-editor'; import { BisectRequest, BlockableAccelerator, + DownloadVersionParams, EditorValues, FiddleEvent, FileTransformOperation, Files, IPackageManager, InstallState, + InstallStateEvent, MessageOptions, NodeTypes, OutputEntry, PMOperationOptions, PackageJsonOptions, + ProgressObject, + ReleaseInfo, RunResult, RunnableVersion, SelectedLocalVersion, + SemVer, + StartFiddleParams, TestRequest, Version, } from './interfaces'; @@ -42,6 +47,14 @@ declare global { listener: (commandId: string) => void, options?: { signal: AbortSignal }, ): void; + addEventListener( + type: 'fiddle-runner-output', + listener: (output: string) => void, + ): void; + addEventListener( + type: 'fiddle-stopped', + listener: (code: number | null, signal: string | null) => void, + ): void; addEventListener( type: 'load-example', listener: (exampleInfo: { path: string; tag: string }) => void, @@ -72,6 +85,14 @@ declare global { type: 'toggle-monaco-option', listener: (path: string) => void, ): void; + addEventListener( + type: 'version-download-progress', + listener: (version: string, progress: ProgressObject) => void, + ): void; + addEventListener( + type: 'version-state-changed', + listener: (event: InstallStateEvent) => void, + ): void; addEventListener( type: 'electron-types-changed', listener: (types: string, version: string) => void, @@ -81,7 +102,6 @@ declare global { ...names: Array ): Promise; app: App; - appPaths: Record; arch: string; blockAccelerators(acceleratorsToBlock: BlockableAccelerator[]): void; cleanupDirectory(dir: string): Promise; @@ -91,6 +111,10 @@ declare global { name?: string, ): Promise; deleteUserData(name: string): Promise; + downloadVersion( + version: string, + opts?: Partial, + ): Promise; fetchVersions(): Promise; getAvailableThemes(): Promise>; getElectronTypes(ver: RunnableVersion): Promise; @@ -111,6 +135,7 @@ declare global { getReleaseInfo(version: string): Promise; getReleasedVersions(): Array; getUsername(): string; + getVersionState(version: string): InstallState; isDevMode: boolean; isReleasedMajor(major: number): Promise; macTitlebarClicked(): void; @@ -132,6 +157,7 @@ declare global { readThemeFile(name?: string): Promise; reloadWindows(): void; removeAllListeners(type: FiddleEvent): void; + removeVersion(version: string): Promise; saveFilesToTemp(files: Files): Promise; selectLocalVersion: () => Promise; sendReady(): void; @@ -139,6 +165,8 @@ declare global { setShowMeTemplate(template?: string): void; showWarningDialog(messageOptions: MessageOptions): void; showWindow(): void; + startFiddle(params: StartFiddleParams): Promise; + stopFiddle(): void; taskDone(result: RunResult): void; themePath: string; uncacheTypes(ver: RunnableVersion): Promise; diff --git a/src/interfaces.ts b/src/interfaces.ts index 45de794e11..581dbe720d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,3 +1,11 @@ +import type { Mirrors } from '@electron/fiddle-core'; +export type { + InstallStateEvent, + ProgressObject, + ReleaseInfo, + SemVer, +} from '@electron/fiddle-core'; + export type Files = Map; export type FileTransform = (files: Files) => Promise; @@ -158,6 +166,8 @@ export type FiddleEvent = | 'clear-console' | 'electron-types-changed' | 'execute-monaco-command' + | 'fiddle-runner-output' + | 'fiddle-stopped' | 'load-example' | 'load-gist' | 'make-fiddle' @@ -177,7 +187,9 @@ export type FiddleEvent = | 'test-task' | 'toggle-bisect' | 'toggle-monaco-option' - | 'undo-in-editor'; + | 'undo-in-editor' + | 'version-download-progress' + | 'version-state-changed'; export interface MessageOptions { message: string; @@ -251,3 +263,16 @@ export interface PackageJsonOptions { includeElectron?: boolean; includeDependencies?: boolean; } + +export interface StartFiddleParams { + localPath: string | undefined; + isValidBuild: boolean; // If the localPath is a valid Electron build + version: string; // The user selected version + dir: string; + options: string[]; + env: { [x: string]: string | undefined }; +} + +export interface DownloadVersionParams { + mirror: Mirrors; +} diff --git a/src/ipc-events.ts b/src/ipc-events.ts index 7ea9727466..1b90a15cfe 100644 --- a/src/ipc-events.ts +++ b/src/ipc-events.ts @@ -20,7 +20,6 @@ export enum IpcEvents { BISECT_COMMANDS_TOGGLE = 'BISECT_COMMANDS_TOGGLE', BEFORE_QUIT = 'BEFORE_QUIT', CONFIRM_QUIT = 'CONFIRM_QUIT', - GET_APP_PATHS = 'GET_APP_PATHS', SELECT_ALL_IN_EDITOR = 'SELECT_ALL_IN_EDITOR', UNDO_IN_EDITOR = 'UNDO_IN_EDITOR', REDO_IN_EDITOR = 'REDO_IN_EDITOR', @@ -66,6 +65,15 @@ export enum IpcEvents { SAVE_FILES_TO_TEMP = 'SAVE_FILES_TO_TEMP', SAVED_LOCAL_FIDDLE = 'SAVED_LOCAL_FIDDLE', GET_FILES = 'GET_FILES', + START_FIDDLE = 'START_FIDDLE', + STOP_FIDDLE = 'STOP_FIDDLE', + GET_VERSION_STATE = 'GET_VERSION_STATE', + DOWNLOAD_VERSION = 'DOWNLOAD_VERSION', + REMOVE_VERSION = 'REMOVE_VERSION', + FIDDLE_RUNNER_OUTPUT = 'FIDDLE_RUNNER_OUTPUT', + FIDDLE_STOPPED = 'FIDDLE_STOPPED', + VERSION_DOWNLOAD_PROGRESS = 'VERSION_DOWNLOAD_PROGRESS', + VERSION_STATE_CHANGED = 'VERSION_STATE_CHANGED', } export const ipcMainEvents = [ @@ -107,6 +115,11 @@ export const ipcMainEvents = [ IpcEvents.CLEANUP_DIRECTORY, IpcEvents.DELETE_USER_DATA, IpcEvents.SAVE_FILES_TO_TEMP, + IpcEvents.START_FIDDLE, + IpcEvents.STOP_FIDDLE, + IpcEvents.GET_VERSION_STATE, + IpcEvents.DOWNLOAD_VERSION, + IpcEvents.REMOVE_VERSION, ]; export const WEBCONTENTS_READY_FOR_IPC_SIGNAL = diff --git a/src/main/constants.ts b/src/main/constants.ts index 259b9ffd76..da1a23777a 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,3 +1,14 @@ import * as path from 'node:path'; +import { app } from 'electron'; + export const STATIC_DIR = path.resolve(__dirname, '../static'); + +export const ELECTRON_DOWNLOAD_PATH = path.join( + app.getPath('userData'), + 'electron-bin', +); +export const ELECTRON_INSTALL_PATH = path.join( + ELECTRON_DOWNLOAD_PATH, + 'current', +); diff --git a/src/main/fiddle-core.ts b/src/main/fiddle-core.ts new file mode 100644 index 0000000000..a60ac7e444 --- /dev/null +++ b/src/main/fiddle-core.ts @@ -0,0 +1,164 @@ +import { ChildProcess } from 'node:child_process'; + +import { ElectronVersions, Installer, Runner } from '@electron/fiddle-core'; +import { BrowserWindow, IpcMainEvent, WebContents } from 'electron'; + +import { ELECTRON_DOWNLOAD_PATH, ELECTRON_INSTALL_PATH } from './constants'; +import { ipcMainManager } from './ipc'; +import { + DownloadVersionParams, + ProgressObject, + StartFiddleParams, +} from '../interfaces'; +import { IpcEvents } from '../ipc-events'; + +let installer: Installer; +let runner: Runner; + +// Keep track of which fiddle process belongs to which WebContents +const fiddleProcesses = new WeakMap(); + +const downloadingVersions = new Map>(); +const removingVersions = new Map>(); + +/** + * Start running an Electron fiddle. + */ +export async function startFiddle( + webContents: WebContents, + params: StartFiddleParams, +): Promise { + const { dir, env, isValidBuild, localPath, options, version } = params; + const child = await runner.spawn( + isValidBuild && localPath ? Installer.getExecPath(localPath) : version, + dir, + { args: options, cwd: dir, env }, + ); + fiddleProcesses.set(webContents, child); + + const pushOutput = (data: string | Buffer) => { + ipcMainManager.send( + IpcEvents.FIDDLE_RUNNER_OUTPUT, + [data.toString()], + webContents, + ); + }; + + child.stdout?.on('data', pushOutput); + child.stderr?.on('data', pushOutput); + + child.on('close', async (code, signal) => { + fiddleProcesses.delete(webContents); + + ipcMainManager.send(IpcEvents.FIDDLE_STOPPED, [code, signal], webContents); + }); +} + +/** + * Stop a currently running Electron fiddle. + */ +export function stopFiddle(webContents: WebContents): void { + const child = fiddleProcesses.get(webContents); + child?.kill(); + + if (child) { + // If the child process is still alive 1 second after we've + // attempted to kill it by normal means, kill it forcefully. + setTimeout(() => { + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + }, 1000); + } +} + +export async function setupFiddleCore(versions: ElectronVersions) { + // For managing downloads and versions for electron + installer = new Installer({ + electronDownloads: ELECTRON_DOWNLOAD_PATH, + electronInstall: ELECTRON_INSTALL_PATH, + }); + + // Broadcast state changes to all windows + installer.on('state-changed', (event) => { + for (const window of BrowserWindow.getAllWindows()) { + ipcMainManager.send( + IpcEvents.VERSION_STATE_CHANGED, + [event], + window.webContents, + ); + } + }); + + runner = await Runner.create({ installer, versions }); + + ipcMainManager.on( + IpcEvents.GET_VERSION_STATE, + (event: IpcMainEvent, version: string) => { + event.returnValue = installer.state(version); + }, + ); + ipcMainManager.handle( + IpcEvents.DOWNLOAD_VERSION, + async ( + event: IpcMainEvent, + version: string, + opts?: Partial, + ) => { + const webContents = event.sender; + + if (removingVersions.has(version)) { + throw new Error('Version is being removed'); + } + + if (!downloadingVersions.has(version)) { + const promise = installer.ensureDownloaded(version, { + ...opts, + progressCallback: (progress: ProgressObject) => { + ipcMainManager.send( + IpcEvents.VERSION_DOWNLOAD_PROGRESS, + [version, progress], + webContents, + ); + }, + }); + + downloadingVersions.set(version, promise); + } + + try { + await downloadingVersions.get(version); + } finally { + downloadingVersions.delete(version); + } + }, + ); + ipcMainManager.handle( + IpcEvents.REMOVE_VERSION, + async (_: IpcMainEvent, version: string) => { + if (downloadingVersions.has(version)) { + throw new Error('Version is being downloaded'); + } + + if (!removingVersions.has(version)) { + removingVersions.set(version, installer.remove(version)); + } + + try { + await removingVersions.get(version); + return installer.state(version); + } finally { + removingVersions.delete(version); + } + }, + ); + ipcMainManager.handle( + IpcEvents.START_FIDDLE, + async (event: IpcMainEvent, params: StartFiddleParams) => { + await startFiddle(event.sender, params); + }, + ); + ipcMainManager.on(IpcEvents.STOP_FIDDLE, (event: IpcMainEvent) => { + stopFiddle(event.sender); + }); +} diff --git a/src/main/main.ts b/src/main/main.ts index 818769f04f..2dca280ce3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15,6 +15,7 @@ import { setupContent } from './content'; import { setupDevTools } from './devtools'; import { setupDialogs } from './dialogs'; import { setupTypes } from './electron-types'; +import { setupFiddleCore } from './fiddle-core'; import { onFirstRunMaybe } from './first-run'; import { ipcMainManager } from './ipc'; import { setupNpm } from './npm'; @@ -64,6 +65,7 @@ export async function onReady() { setupGetProjectName(); setupGetUsername(); setupTypes(knownVersions); + await setupFiddleCore(knownVersions); // Do this after setting everything up to ensure that // any IPC listeners are set up before they're used diff --git a/src/main/windows.ts b/src/main/windows.ts index 2d498d4c82..caacdc647f 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; -import { BrowserWindow, app, shell } from 'electron'; +import { BrowserWindow, shell } from 'electron'; import { createContextMenu } from './context-menu'; import { ipcMainManager } from './ipc'; @@ -109,22 +109,6 @@ export function createMainWindow(): Electron.BrowserWindow { browserWindow?.reload(); }); - ipcMainManager.handle(IpcEvents.GET_APP_PATHS, () => { - const pathsToQuery = [ - 'home', - 'appData', - 'userData', - 'temp', - 'downloads', - 'desktop', - ] as const; - const paths = {} as Record<(typeof pathsToQuery)[number], string>; - for (const path of pathsToQuery) { - paths[path] = app.getPath(path); - } - return paths; - }); - browserWindows.push(browserWindow); return browserWindow; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 64c4dea206..f50c7a4f7d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -2,10 +2,12 @@ import { IpcRendererEvent, ipcRenderer } from 'electron'; import { + DownloadVersionParams, FiddleEvent, FileTransformOperation, Files, PackageJsonOptions, + StartFiddleParams, } from '../interfaces'; import { IpcEvents, WEBCONTENTS_READY_FOR_IPC_SIGNAL } from '../ipc-events'; import { FiddleTheme } from '../themes-defaults'; @@ -16,6 +18,8 @@ const channelMapping: Record = { 'clear-console': IpcEvents.CLEAR_CONSOLE, 'electron-types-changed': IpcEvents.ELECTRON_TYPES_CHANGED, 'execute-monaco-command': IpcEvents.MONACO_EXECUTE_COMMAND, + 'fiddle-runner-output': IpcEvents.FIDDLE_RUNNER_OUTPUT, + 'fiddle-stopped': IpcEvents.FIDDLE_STOPPED, 'load-example': IpcEvents.LOAD_ELECTRON_EXAMPLE_REQUEST, 'load-gist': IpcEvents.LOAD_GIST_REQUEST, 'make-fiddle': IpcEvents.FIDDLE_MAKE, @@ -36,6 +40,8 @@ const channelMapping: Record = { 'toggle-bisect': IpcEvents.BISECT_COMMANDS_TOGGLE, 'toggle-monaco-option': IpcEvents.MONACO_TOGGLE_OPTION, 'undo-in-editor': IpcEvents.UNDO_IN_EDITOR, + 'version-download-progress': IpcEvents.VERSION_DOWNLOAD_PROGRESS, + 'version-state-changed': IpcEvents.VERSION_STATE_CHANGED, } as const; async function preload() { @@ -70,7 +76,6 @@ export async function setupFiddleGlobal() { ); }, app: null as any, // will be set in main.tsx - appPaths: await ipcRenderer.invoke(IpcEvents.GET_APP_PATHS), arch: process.arch, blockAccelerators(acceleratorsToBlock) { ipcRenderer.send(IpcEvents.BLOCK_ACCELERATORS, acceleratorsToBlock); @@ -87,6 +92,12 @@ export async function setupFiddleGlobal() { async deleteUserData(name: string) { await ipcRenderer.invoke(IpcEvents.DELETE_USER_DATA, name); }, + async downloadVersion( + version: string, + opts?: Partial, + ) { + await ipcRenderer.invoke(IpcEvents.DOWNLOAD_VERSION, version, opts); + }, fetchVersions() { return ipcRenderer.invoke(IpcEvents.FETCH_VERSIONS); }, @@ -138,6 +149,8 @@ export async function setupFiddleGlobal() { }, getTestTemplate: () => ipcRenderer.invoke(IpcEvents.GET_TEST_TEMPLATE), getUsername: () => ipcRenderer.sendSync(IpcEvents.GET_USERNAME), + getVersionState: (version: string) => + ipcRenderer.sendSync(IpcEvents.GET_VERSION_STATE, version), isDevMode: ipcRenderer.sendSync(IpcEvents.IS_DEV_MODE), macTitlebarClicked() { ipcRenderer.send(IpcEvents.CLICK_TITLEBAR_MAC); @@ -186,6 +199,9 @@ export async function setupFiddleGlobal() { ipcRenderer.removeAllListeners(channel); } }, + async removeVersion(version: string) { + return ipcRenderer.invoke(IpcEvents.REMOVE_VERSION, version); + }, saveFilesToTemp(files: Files) { return ipcRenderer.invoke(IpcEvents.SAVE_FILES_TO_TEMP, [ ...files.entries(), @@ -209,6 +225,12 @@ export async function setupFiddleGlobal() { showWindow() { ipcRenderer.send(IpcEvents.SHOW_WINDOW); }, + async startFiddle(params: StartFiddleParams) { + await ipcRenderer.invoke(IpcEvents.START_FIDDLE, params); + }, + stopFiddle() { + ipcRenderer.send(IpcEvents.STOP_FIDDLE); + }, taskDone(result) { ipcRenderer.send(IpcEvents.TASK_DONE, result); }, diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index fb954a9e28..3d37904ebf 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,12 +1,3 @@ -import * as path from 'node:path'; - -const USER_DATA_PATH = window.ElectronFiddle.appPaths.userData; -export const ELECTRON_DOWNLOAD_PATH = path.join(USER_DATA_PATH, 'electron-bin'); -export const ELECTRON_INSTALL_PATH = path.join( - ELECTRON_DOWNLOAD_PATH, - 'current', -); - export const ELECTRON_ORG = 'electron'; export const ELECTRON_REPO = 'electron'; diff --git a/src/renderer/runner.ts b/src/renderer/runner.ts index a60516c4b4..2949318317 100644 --- a/src/renderer/runner.ts +++ b/src/renderer/runner.ts @@ -1,7 +1,3 @@ -import { ChildProcess } from 'node:child_process'; - -import { Installer } from '@electron/fiddle-core'; - import { Bisector } from './bisect'; import { AppState } from './state'; import { maybePlural } from './utils/plural-maybe'; @@ -33,8 +29,6 @@ const resultString: Record = Object.freeze({ }); export class Runner { - public child: ChildProcess | null = null; - constructor(private readonly appState: AppState) { this.run = this.run.bind(this); this.stop = this.stop.bind(this); @@ -222,18 +216,7 @@ export class Runner { * @memberof Runner */ public stop(): void { - const child = this.child; - this.appState.isRunning = !!child && !child.kill(); - - if (child) { - // If the child process is still alive 1 second after we've - // attempted to kill it by normal means, kill it forcefully. - setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL'); - } - }, 1000); - } + window.ElectronFiddle.stopFiddle(); } /** @@ -374,10 +357,8 @@ export class Runner { * or the user selected electron version */ private async runFiddle(params: RunFiddleParams): Promise { - const { localPath, isValidBuild, version, dir } = params; - const { versionRunner, pushOutput, flushOutput, executionFlags } = - this.appState; - const fiddleRunner = await versionRunner; + const { version, dir } = params; + const { pushOutput, flushOutput, executionFlags } = this.appState; const env = this.buildChildEnvVars(); // Add user-specified cli flags if any have been set. @@ -387,7 +368,6 @@ export class Runner { flushOutput(); this.appState.isRunning = false; - this.child = null; // Clean older folders await window.ElectronFiddle.cleanupDirectory(dir); @@ -396,13 +376,11 @@ export class Runner { return new Promise(async (resolve, _reject) => { try { - this.child = await fiddleRunner.spawn( - isValidBuild && localPath - ? Installer.getExecPath(localPath) - : version, - dir, - { args: options, cwd: dir, env }, - ); + await window.ElectronFiddle.startFiddle({ + ...params, + options, + env, + }); } catch (e) { pushOutput(`Failed to spawn Fiddle: ${e.message}`); await cleanup(); @@ -413,23 +391,30 @@ export class Runner { pushOutput(`Electron v${version} started.`); - this.child?.stdout?.on('data', (data) => - pushOutput(data, { bypassBuffer: false }), - ); - this.child?.stderr?.on('data', (data) => - pushOutput(data, { bypassBuffer: false }), + window.ElectronFiddle.removeAllListeners('fiddle-runner-output'); + window.ElectronFiddle.removeAllListeners('fiddle-stopped'); + + window.ElectronFiddle.addEventListener( + 'fiddle-runner-output', + (output: string) => { + pushOutput(output, { bypassBuffer: false }); + }, ); - this.child?.on('close', async (code, signal) => { - await cleanup(); - if (typeof code !== 'number') { - pushOutput(`Electron exited with signal ${signal}.`); - resolve(RunResult.FAILURE); - } else { - pushOutput(`Electron exited with code ${code}.`); - resolve(code === 0 ? RunResult.SUCCESS : RunResult.FAILURE); - } - }); + window.ElectronFiddle.addEventListener( + 'fiddle-stopped', + async (code, signal) => { + await cleanup(); + + if (typeof code !== 'number') { + pushOutput(`Electron exited with signal ${signal}.`); + resolve(RunResult.FAILURE); + } else { + pushOutput(`Electron exited with code ${code}.`); + resolve(code === 0 ? RunResult.SUCCESS : RunResult.FAILURE); + } + }, + ); }); } diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 5ddd0b684b..5f79d313e5 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -1,21 +1,13 @@ -import { - BaseVersions, - Installer, - ProgressObject, - Runner, -} from '@electron/fiddle-core'; import { action, autorun, computed, makeObservable, observable, - runInAction, when, } from 'mobx'; import { Bisector } from './bisect'; -import { ELECTRON_DOWNLOAD_PATH, ELECTRON_INSTALL_PATH } from './constants'; import { EditorMosaic } from './editor-mosaic'; import { ELECTRON_MIRROR } from './mirror-constants'; import { normalizeVersion } from './utils/normalize-version'; @@ -44,6 +36,7 @@ import { InstallState, OutputEntry, OutputOptions, + ProgressObject, RunnableVersion, SetFiddleOptions, Version, @@ -51,12 +44,6 @@ import { WindowSpecificSetting, } from '../interfaces'; -class UpdateableBaseVersions extends BaseVersions { - public updateVersions(val: unknown): void { - this.setVersions(val); - } -} - /** * The application's state. Exported as a singleton below. * @@ -195,20 +182,6 @@ export class AppState { private readonly defaultVersion: string; public appData: string; - // Populating versions in fiddle-core - public baseVersions = new UpdateableBaseVersions(getElectronVersions()); - - // For managing downloads and versions for electron - public installer: Installer = new Installer({ - electronDownloads: ELECTRON_DOWNLOAD_PATH, - electronInstall: ELECTRON_INSTALL_PATH, - }); - - public versionRunner: Promise = Runner.create({ - installer: this.installer, - versions: this.baseVersions, - }); - // Used for communications between windows private broadcastChannel: AppStateBroadcastChannel = new BroadcastChannel( 'AppState', @@ -312,6 +285,7 @@ export class AppState { toggleBisectDialog: action, toggleConsole: action, toggleSettings: action, + updateDownloadProgress: action, updateElectronVersions: action, version: observable, versions: observable, @@ -340,6 +314,7 @@ export class AppState { this.clearConsole = this.clearConsole.bind(this); this.toggleSettings = this.toggleSettings.bind(this); this.toggleBisectDialog = this.toggleBisectDialog.bind(this); + this.updateDownloadProgress = this.updateDownloadProgress.bind(this); this.updateElectronVersions = this.updateElectronVersions.bind(this); this.setIsQuitting = this.setIsQuitting.bind(this); this.addAcceleratorToBlock = this.addAcceleratorToBlock.bind(this); @@ -371,6 +346,7 @@ export class AppState { window.ElectronFiddle.removeAllListeners('clear-console'); window.ElectronFiddle.removeAllListeners('open-settings'); window.ElectronFiddle.removeAllListeners('show-welcome-tour'); + window.ElectronFiddle.removeAllListeners('version-download-progress'); window.ElectronFiddle.addEventListener( 'open-settings', @@ -383,6 +359,10 @@ export class AppState { this.toggleBisectCommands, ); window.ElectronFiddle.addEventListener('before-quit', this.setIsQuitting); + window.ElectronFiddle.addEventListener( + 'version-download-progress', + this.updateDownloadProgress, + ); /** * Listens for changes in the app settings made in other windows @@ -576,9 +556,13 @@ export class AppState { this.setVersion(this.version); // Trigger the change state event - this.installer.on('state-changed', ({ version, state }) => { - this.changeRunnableState(version, state); - }); + window.ElectronFiddle.removeAllListeners('version-state-changed'); + window.ElectronFiddle.addEventListener( + 'version-state-changed', + ({ version, state }) => { + this.changeRunnableState(version, state); + }, + ); } /** @@ -641,7 +625,6 @@ export class AppState { .filter((ver) => !(ver.version in this.versions)) .map((ver) => makeRunnable(ver)), ); - this.baseVersions.updateVersions(fullVersions); } catch (error) { console.warn(`State: Could not update Electron versions`, error); } @@ -731,6 +714,19 @@ export class AppState { this.resetView({ isSettingsShowing: !this.isSettingsShowing }); } + public updateDownloadProgress(version: string, progress: ProgressObject) { + const percent = Math.round(progress.percent * 100) / 100; + const ver = this.versions[version]; + // Stop if its undefined or has same downloadProgress percent + if (ver === undefined || ver.downloadProgress === percent) { + return; + } + + ver.downloadProgress = percent; + this.versions[version] = ver; + this.broadcastVersionStates([ver]); + } + public setIsQuitting() { this.isQuitting = true; } @@ -813,8 +809,10 @@ export class AppState { state === InstallState.installed || state == InstallState.downloaded ) { - await this.installer.remove(version); - if (this.installer.state(version) === InstallState.missing) { + if ( + (await window.ElectronFiddle.removeVersion(version)) === + InstallState.missing + ) { await window.ElectronFiddle.app.electronTypes.uncache(ver); this.broadcastVersionStates([ver]); @@ -862,22 +860,11 @@ export class AppState { ]); // Download the version without setting it as the current version. - await this.installer.ensureDownloaded(version, { + await window.ElectronFiddle.downloadVersion(version, { mirror: { electronMirror, electronNightlyMirror, }, - progressCallback: (progress: ProgressObject) => { - // https://mobx.js.org/actions.html#runinaction - runInAction(() => { - const percent = Math.round(progress.percent * 100) / 100; - if (ver.downloadProgress !== percent) { - ver.downloadProgress = percent; - - this.broadcastVersionStates([ver]); - } - }); - }, }); this.broadcastVersionStates([ver]); @@ -1182,7 +1169,7 @@ export class AppState { * @returns {InstallState} */ public getVersionState(version: string): InstallState { - return this.installer.state(version); + return window.ElectronFiddle.getVersionState(version); } /** diff --git a/tests/main/main-spec.ts b/tests/main/main-spec.ts index 60addee09f..102801b0a4 100644 --- a/tests/main/main-spec.ts +++ b/tests/main/main-spec.ts @@ -23,6 +23,12 @@ import { getOrCreateMainWindow } from '../../src/main/windows'; import { BrowserWindowMock } from '../mocks/browser-window'; import { overridePlatform, resetPlatform } from '../utils'; +// Need to mock this out or CI will hit an error due to +// code being run async continuing after the test ends: +// > ReferenceError: You are trying to `import` a file +// > after the Jest environment has been torn down. +jest.mock('getos'); + jest.mock('../../src/main/windows', () => ({ getOrCreateMainWindow: jest.fn(), mainIsReady: jest.fn(), diff --git a/tests/main/windows-spec.ts b/tests/main/windows-spec.ts index 7dab920265..a6994734fa 100644 --- a/tests/main/windows-spec.ts +++ b/tests/main/windows-spec.ts @@ -4,10 +4,8 @@ import * as path from 'node:path'; -import * as electron from 'electron'; import { mocked } from 'jest-mock'; -import { IpcEvents } from '../../src/ipc-events'; import { createContextMenu } from '../../src/main/context-menu'; import { browserWindows, @@ -125,25 +123,5 @@ describe('windows', () => { (await getOrCreateMainWindow()).webContents.emit('will-navigate', e); expect(e.preventDefault).toHaveBeenCalled(); }); - - it('returns app.getPath() values on IPC event', async () => { - // we want to remove the effects of previous calls - browserWindows.length = 0; - - // can't .emit() to trigger .handleOnce() so instead we mock - // to instantly call the listener. - let result: Record = {}; - mocked(electron.app.getPath).mockImplementation((name) => name); - mocked(electron.ipcMain.handle).mockImplementation((event, listener) => { - if (event === IpcEvents.GET_APP_PATHS) { - result = listener(null as any); - } - }); - await getOrCreateMainWindow(); - expect(Object.values(result).length).toBeGreaterThan(0); - for (const prop in result) { - expect(prop).toBe(result[prop]); - } - }); }); }); diff --git a/tests/mocks/electron-fiddle.ts b/tests/mocks/electron-fiddle.ts index 5ff8ef8a44..01bd99608a 100644 --- a/tests/mocks/electron-fiddle.ts +++ b/tests/mocks/electron-fiddle.ts @@ -4,16 +4,13 @@ export class ElectronFiddleMock { public addEventListener = jest.fn(); public addModules = jest.fn(); public app = new AppMock(); - public appPaths = { - userData: '/fake/path', - home: `~`, - }; public arch = process.arch; public blockAccelerators = jest.fn(); public cleanupDirectory = jest.fn(); public confirmQuit = jest.fn(); public createThemeFile = jest.fn(); public deleteUserData = jest.fn(); + public downloadVersion = jest.fn(); public fetchVersions = jest.fn(); public getAvailableThemes = jest.fn(); public getElectronTypes = jest.fn(); @@ -30,6 +27,7 @@ export class ElectronFiddleMock { public isReleasedMajor = jest.fn(); public getProjectName = jest.fn(); public getUsername = jest.fn(); + public getVersionState = jest.fn(); public macTitlebarClicked = jest.fn(); public monaco = new MonacoMock(); public onGetFiles = jest.fn(); @@ -39,6 +37,7 @@ export class ElectronFiddleMock { public pushOutputEntry = jest.fn(); public reloadWindows = jest.fn(); public removeAllListeners = jest.fn(); + public removeVersion = jest.fn(); public saveFilesToTemp = jest.fn(); public selectLocalVersion = jest.fn(); public sendReady = jest.fn(); @@ -46,6 +45,8 @@ export class ElectronFiddleMock { public setShowMeTemplate = jest.fn(); public showWarningDialog = jest.fn(); public showWindow = jest.fn(); + public startFiddle = jest.fn(); + public stopFiddle = jest.fn(); public taskDone = jest.fn(); public readThemeFile = jest.fn(); public themePath = '~/.electron-fiddle/themes'; diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts index 5fb1f5ea2c..76eb566711 100644 --- a/tests/mocks/state.ts +++ b/tests/mocks/state.ts @@ -2,7 +2,6 @@ import { makeObservable, observable } from 'mobx'; import { BisectorMock } from './bisector'; import { VersionsMock } from './electron-versions'; -import { FiddleRunnerMock, InstallerMock } from './fiddle-core'; import { BlockableAccelerator, ElectronReleaseChannel, @@ -113,8 +112,6 @@ export class StateMock { public updateElectronVersions = jest.fn(); public startDeletingAll = jest.fn(); public stopDeletingAll = jest.fn(); - public installer = new InstallerMock(); - public versionRunner = new FiddleRunnerMock(); constructor() { makeObservable(this, { diff --git a/tests/preload/preload-spec.ts b/tests/preload/preload-spec.ts index 097ba45541..5811d3f5fb 100644 --- a/tests/preload/preload-spec.ts +++ b/tests/preload/preload-spec.ts @@ -1,10 +1,7 @@ /** * @jest-environment node */ -import * as electron from 'electron'; -import { mocked } from 'jest-mock'; -import { IpcEvents } from '../../src/ipc-events'; import { setupFiddleGlobal } from '../../src/preload/preload'; describe('preload', () => { @@ -21,19 +18,5 @@ describe('preload', () => { expect(window.ElectronFiddle).toMatchObject({ app: null }); }); - - it('sets app paths', async () => { - const obj = { - appPath: '/fake/path', - }; - mocked(electron.ipcRenderer.invoke).mockResolvedValue(obj); - - await setupFiddleGlobal(); - - expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith( - IpcEvents.GET_APP_PATHS, - ); - expect(window.ElectronFiddle.appPaths).toBe(obj); - }); }); }); diff --git a/tests/renderer/components/commands-runner-spec.tsx b/tests/renderer/components/commands-runner-spec.tsx index 1430ae3b62..6d22b6e3f2 100644 --- a/tests/renderer/components/commands-runner-spec.tsx +++ b/tests/renderer/components/commands-runner-spec.tsx @@ -7,8 +7,6 @@ import { Runner } from '../../../src/renderer/components/commands-runner'; import { AppState } from '../../../src/renderer/state'; jest.mock('../../../src/renderer/file-manager'); -jest.mock('node:child_process'); -jest.mock('fs-extra'); describe('Runner component', () => { let store: AppState; diff --git a/tests/renderer/runner-spec.tsx b/tests/renderer/runner-spec.tsx index 56d861294e..339e00b73a 100644 --- a/tests/renderer/runner-spec.tsx +++ b/tests/renderer/runner-spec.tsx @@ -18,7 +18,6 @@ import { import { emitEvent, waitFor } from '../utils'; jest.mock('../../src/renderer/file-manager'); -jest.mock('fs-extra'); describe('Runner component', () => { let store: StateMock; @@ -50,8 +49,8 @@ describe('Runner component', () => { await waitFor(() => store.isRunning); expect(store.isRunning).toBe(true); - // child process exits with success - setTimeout(() => store.versionRunner.child.emit('close', 0)); + // fiddle exits with success + setTimeout(() => emitEvent('fiddle-stopped', 0)); const result = await runPromise; expect(result).toBe(RunResult.SUCCESS); @@ -62,11 +61,10 @@ describe('Runner component', () => { it('runs with logging when enabled', async () => { store.isEnablingElectronLogging = true; - const spyChildProcess = jest.spyOn(store.versionRunner, 'spawn'); - (spyChildProcess as jest.Mock).mockImplementationOnce((_, __, opts) => { - expect(opts.env).toHaveProperty('ELECTRON_ENABLE_LOGGING'); - expect(opts.env).toHaveProperty('ELECTRON_ENABLE_STACK_DUMPING'); - return store.versionRunner.child; + const spy = jest.spyOn(window.ElectronFiddle, 'startFiddle'); + spy.mockImplementationOnce(async (params) => { + expect(params.env).toHaveProperty('ELECTRON_ENABLE_LOGGING'); + expect(params.env).toHaveProperty('ELECTRON_ENABLE_STACK_DUMPING'); }); // wait for run() to get running @@ -74,8 +72,8 @@ describe('Runner component', () => { await waitFor(() => store.isRunning); expect(store.isRunning).toBe(true); - // child process exits with success - setTimeout(() => store.versionRunner.child.emit('close', 0)); + // fiddle exits with success + setTimeout(() => emitEvent('fiddle-stopped', 0)); const result = await runPromise; expect(result).toBe(RunResult.SUCCESS); @@ -90,11 +88,11 @@ describe('Runner component', () => { await waitFor(() => store.isRunning); expect(store.isRunning).toBe(true); - // mock child process gives output, + // mock fiddle gives output, // then exits with exitCode 0 - store.versionRunner.child.stdout.emit('data', 'hi'); - store.versionRunner.child.stderr.emit('data', 'hi'); - store.versionRunner.child.emit('close', 0); + emitEvent('fiddle-runner-output', 'hi'); + emitEvent('fiddle-runner-output', 'hi'); + emitEvent('fiddle-stopped', 0); const result = await runPromise; @@ -115,8 +113,8 @@ describe('Runner component', () => { await waitFor(() => store.isRunning); expect(store.isRunning).toBe(true); - // mock child process exits with ARBITRARY_FAIL_CODE - store.versionRunner.child.emit('close', ARBITRARY_FAIL_CODE); + // mock fiddle exits with ARBITRARY_FAIL_CODE + emitEvent('fiddle-stopped', ARBITRARY_FAIL_CODE); const result = await runPromise; expect(result).toBe(RunResult.FAILURE); @@ -155,11 +153,11 @@ describe('Runner component', () => { const signal = 'SIGTERM'; - // mock child process gives output, + // mock fiddle gives output, // then exits without an explicit exitCode - store.versionRunner.child.stdout.emit('data', 'hi'); - store.versionRunner.child.stderr.emit('data', 'hi'); - store.versionRunner.child.emit('close', null, signal); + emitEvent('fiddle-runner-output', 'hi'); + emitEvent('fiddle-runner-output', 'hi'); + emitEvent('fiddle-stopped', null, signal); const result = await runPromise; expect(result).toBe(RunResult.FAILURE); @@ -172,7 +170,7 @@ describe('Runner component', () => { }); it('cleans the app data dir after a run', async () => { - setTimeout(() => store.versionRunner.child.emit('close', 0)); + setTimeout(() => emitEvent('fiddle-stopped', 0)); const result = await instance.run(); expect(result).toBe(RunResult.SUCCESS); @@ -187,7 +185,7 @@ describe('Runner component', () => { it('does not clean the app data dir after a run if configured', async () => { (instance as any).appState.isKeepingUserDataDirs = true; - setTimeout(() => store.versionRunner.child.emit('close', 0)); + setTimeout(() => emitEvent('fiddle-stopped', 0)); const result = await instance.run(); expect(result).toBe(RunResult.SUCCESS); @@ -198,7 +196,7 @@ describe('Runner component', () => { it('automatically cleans the console when enabled', async () => { store.isClearingConsoleOnRun = true; - setTimeout(() => store.versionRunner.child.emit('close', 0)); + setTimeout(() => emitEvent('fiddle-stopped', 0)); const result = await instance.run(); expect(result).toBe(RunResult.SUCCESS); @@ -232,9 +230,8 @@ describe('Runner component', () => { describe('stop()', () => { it('stops a running session', async () => { - store.versionRunner.child.kill.mockImplementationOnce(() => { - store.versionRunner.child.emit('close'); - return true; + mocked(window.ElectronFiddle.stopFiddle).mockImplementationOnce(() => { + emitEvent('fiddle-stopped', RunResult.FAILURE); }); // wait for run() to get running @@ -250,8 +247,8 @@ describe('Runner component', () => { expect(store.isRunning).toBe(false); }); - it('fails if killing child process fails', async () => { - store.versionRunner.child.kill.mockReturnValueOnce(false); + it('fails if stopping fiddle fails', async () => { + mocked(window.ElectronFiddle.stopFiddle).mockImplementationOnce(() => {}); // wait for run() to get running instance.run(); diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index ca8467e55b..ef99e0df5e 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -46,7 +46,6 @@ describe('AppState', () => { let mockVersions: Record; let mockVersionsArray: RunnableVersion[]; let removeSpy: jest.SpyInstance; - let installSpy: jest.SpyInstance; let ensureDownloadedSpy: jest.SpyInstance; let broadcastMessageSpy: jest.SpyInstance; @@ -54,20 +53,17 @@ describe('AppState', () => { ({ mockVersions, mockVersionsArray } = new VersionsMock()); mocked(fetchVersions).mockResolvedValue(mockVersionsArray); - jest - .spyOn(AppState.prototype, 'getVersionState') - .mockImplementation(() => InstallState.installed); + mocked(window.ElectronFiddle.getVersionState).mockReturnValue( + InstallState.installed, + ); appState = new AppState(mockVersionsArray); removeSpy = jest - .spyOn(appState.installer, 'remove') - .mockResolvedValue(undefined); - installSpy = jest - .spyOn(appState.installer, 'install') - .mockResolvedValue(''); + .spyOn(window.ElectronFiddle, 'removeVersion') + .mockResolvedValue(InstallState.missing); ensureDownloadedSpy = jest - .spyOn(appState.installer, 'ensureDownloaded') - .mockResolvedValue({ path: '', alreadyExtracted: false }); + .spyOn(window.ElectronFiddle, 'downloadVersion') + .mockResolvedValue(undefined); broadcastMessageSpy = jest.spyOn( (appState as any).broadcastChannel, 'postMessage', @@ -320,6 +316,9 @@ describe('AppState', () => { it('removes a version', async () => { const ver = appState.versions[version]; ver.state = InstallState.installed; + mocked(window.ElectronFiddle.getVersionState).mockReturnValue( + InstallState.missing, + ); await appState.removeVersion(ver); expect(removeSpy).toHaveBeenCalledWith(ver.version); expect(broadcastMessageSpy).toHaveBeenCalledWith({ @@ -363,7 +362,6 @@ describe('AppState', () => { await appState.downloadVersion(ver); expect(ensureDownloadedSpy).toHaveBeenCalled(); - expect(installSpy).not.toHaveBeenCalled(); expect(broadcastMessageSpy).toHaveBeenCalledWith({ type: AppStateBroadcastMessageType.syncVersions, payload: [ver], @@ -378,7 +376,6 @@ describe('AppState', () => { await appState.downloadVersion(ver); expect(ensureDownloadedSpy).not.toHaveBeenCalled(); - expect(installSpy).not.toHaveBeenCalled(); expect(broadcastMessageSpy).not.toHaveBeenCalled(); }); }); @@ -668,9 +665,7 @@ describe('AppState', () => { appState.addLocalVersion(ver); - // `getElectronVersions` is called when the AppState is initialized - // as well - expect(getElectronVersions).toHaveBeenCalledTimes(2); + expect(getElectronVersions).toHaveBeenCalledTimes(1); expect(appState.getVersion(version)).toStrictEqual(ver); }); });