From 080ced8994d14f714cf9b6efad3dca00041f66ed Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 11 Oct 2023 16:52:28 -0700 Subject: [PATCH] (feat): detect app binary path for electron builder and forge (#222) * (feat): detect app binary path for electron builder and forge * add unit tests * linting and prettier fix * simplify docs * fix tests on windows * make it prettier * trigger build --- README.md | 67 +++++++------------- eslint.config.js | 1 - example-cjs/wdio.conf.ts | 10 --- example/tsconfig.json | 8 ++- example/wdio.conf.ts | 9 --- src/esm/constants.ts | 4 -- src/launcher.ts | 81 +++++++++++++++++++++++- src/utils.ts | 12 ++-- test/launcher.spec.ts | 132 ++++++++++++++++++++++++++++++++++++++- 9 files changed, 243 insertions(+), 81 deletions(-) delete mode 100644 src/esm/constants.ts diff --git a/README.md b/README.md index 79ab6fe6..0324ec12 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ Enables cross-platform E2E testing of Electron apps via the extensive WebdriverI Spiritual successor to [Spectron](https://github.com/electron-userland/spectron) ([RIP](https://github.com/electron-userland/spectron/issues/1045)). +### Features + +Using this service makes testing Electron applications much easier as it takes care of the following: + +- 🚗 auto-setup of required Chromedriver +- 📦 finds path to your bundled Electron application (if [Electron Forge](https://www.electronforge.io/) or [Electron Builder](https://www.electron.build/) is used) +- 🧩 enables ability to access Electron APIs within your tests +- 🕵️ allows to mock Electron APIs + ## Installation ```bash @@ -26,7 +35,7 @@ If you prefer to manage Chromedriver yourself you can install it directly or via #### Service Managed -If you are not specifying a Chromedriver binary then the service will download and use the appropriate version for your app's Electron version. The Electron version of your app is determined by the version of Electron in your `package.json`, however you may want to override this behaviour - for instance, if the app you are testing is in a different repo from the tests. You can specify the Electron version manually by setting the `browserVersion` capability, as shown in the example configuration below. +If you are not specifying a Chromedriver binary then the service will download and use the appropriate version for your app's Electron version. The Electron version of your app is determined by the version of `electron` or `electron-nighly` in your `package.json`, however you may want to override this behaviour - for instance, if the app you are testing is in a different repo from the tests. You can specify the Electron version manually by setting the `browserVersion` capability, as shown in the example configuration below. ## Example Configuration @@ -42,58 +51,28 @@ export const config = { outputDir: 'logs', // ... services: ['electron'], - capabilities: [ - { - 'browserName': 'electron', - 'wdio:electronServiceOptions': { - appBinaryPath: path.resolve(__dirname, 'dist', 'myElectronApplication.exe'), - }, - }, - ], + capabilities: [{ + 'browserName': 'electron' + }], // ... }; ``` -If you are building your app using [`electron-builder`](https://www.electron.build/), your configuration might resemble the following: - -```js -// wdio.conf.js -import url from 'node:url'; -import path from 'node:path'; -import fs from 'node:fs/promises'; -import { getBinaryPath } from 'wdio-electron-service/utils'; - -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); -const pkg = JSON.parse(await fs.readFile('./package.json')); +The service will attempt to find the path to your bundled Electron application if you use [Electron Forge](https://www.electronforge.io/) or [Electron Builder](https://www.electron.build/) as bundler. You can provide a custom path to the binary via custom service capabilities, e.g.: -export const config = { - outputDir: 'logs', - // ... - services: ['electron'], - capabilities: [ - { - 'browserName': 'electron', - 'browserVersion': '26.2.2', // optional override - 'wdio:electronServiceOptions': { - // Use `getBinaryPath` to point to the right binary, e.g. given your `productName` is "myElectronApplication" - // it would set the binary depending on your OS to: - // - // Linux: ./dist/linux-unpacked/myElectronApplication - // MacOS: ./dist/mac-arm64/myElectronApplication.app/Contents/MacOS/myElectronApplication - // Windows: ./win-unpacked/myElectronApplication.exe - appBinaryPath: getBinaryPath(__dirname, pkg.build.productName), - appArgs: ['foo', 'bar=baz'], - }, - }, - ], - // ... -}; +```ts +capabilities: [{ + 'browserName': 'electron', + 'wdio:electronServiceOptions': { + appBinaryPath: './path/to/bundled/electron/app.exe', + appArgs: ['foo', 'bar=baz'], + }, +}], ``` ### API Configuration -If you wish to use the Electron APIs then you will need to import (or require) the preload and main scripts in your app. -To import 3rd-party packages (node_modules) in your `preload.js`, you have to disable sandboxing in your `BrowserWindow` config. +If you wish to use the Electron APIs then you will need to import (or require) the preload and main scripts in your app. To import 3rd-party packages (node_modules) in your `preload.js`, you have to disable sandboxing in your `BrowserWindow` config. It is not recommended to disable sandbox mode in production; to control this behaviour you can set the `NODE_ENV` environment variable when executing WDIO: diff --git a/eslint.config.js b/eslint.config.js index 6292a874..2d4a428a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,6 @@ export default [ }, rules: { ...eslint.configs.recommended.rules, - 'no-nested-ternary': 'error', }, }, // Node & Electron main process files and scripts diff --git a/example-cjs/wdio.conf.ts b/example-cjs/wdio.conf.ts index 759ed371..b633f5a7 100644 --- a/example-cjs/wdio.conf.ts +++ b/example-cjs/wdio.conf.ts @@ -1,12 +1,4 @@ import path from 'node:path'; -import fs from 'node:fs'; - -import { getBinaryPath } from 'wdio-electron-service/utils'; - -const packageJson = JSON.parse(fs.readFileSync('./package.json').toString()); -const { - build: { productName }, -} = packageJson; process.env.TEST = 'true'; @@ -15,9 +7,7 @@ exports.config = { capabilities: [ { 'browserName': 'electron', - 'browserVersion': '27.0.0', 'wdio:electronServiceOptions': { - appBinaryPath: getBinaryPath(__dirname, productName), appArgs: ['foo', 'bar=baz'], }, }, diff --git a/example/tsconfig.json b/example/tsconfig.json index 9b4afa48..604704f3 100755 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -9,8 +9,12 @@ "compilerOptions": { "target": "ESNext", "module": "ESNext", - "types": ["node", "@wdio/globals/types"], - "typeRoots": ["./node_modules", "./node_modules/@types", "./../node_modules/@types", "../@types"], + "typeRoots": [ + "./node_modules", + "./node_modules/@types", + "./../node_modules/@types", + "../@types" + ], "outDir": "dist" }, "exclude": ["node_modules"], diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts index 8296aa68..87d8860a 100644 --- a/example/wdio.conf.ts +++ b/example/wdio.conf.ts @@ -1,15 +1,8 @@ /// -import fs from 'node:fs'; import url from 'node:url'; import path from 'node:path'; -import { getBinaryPath } from 'wdio-electron-service/utils'; - const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const packageJson = JSON.parse(fs.readFileSync('./package.json').toString()); -const { - build: { productName }, -} = packageJson; process.env.TEST = 'true'; @@ -18,9 +11,7 @@ export const config = { capabilities: [ { 'browserName': 'electron', - 'browserVersion': '28.0.0-nightly.20231009', 'wdio:electronServiceOptions': { - appBinaryPath: getBinaryPath(__dirname, productName), appArgs: ['foo', 'bar=baz'], }, }, diff --git a/src/esm/constants.ts b/src/esm/constants.ts deleted file mode 100644 index df7f5fab..00000000 --- a/src/esm/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -import path from 'node:path'; -import url from 'node:url'; - -export const esmDirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/src/launcher.ts b/src/launcher.ts index d6790fe8..5233629f 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -1,13 +1,23 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import util from 'node:util'; + import findVersions from 'find-versions'; import { readPackageUp, type NormalizedReadResult } from 'read-pkg-up'; import { SevereServiceError } from 'webdriverio'; import type { Services, Options, Capabilities } from '@wdio/types'; import log from './log.js'; +import { getBinaryPath } from './utils.js'; import { getChromeOptions, getChromedriverOptions, getElectronCapabilities } from './capabilities.js'; import { getChromiumVersion } from './versions.js'; import type { ElectronServiceOptions } from './types.js'; +const APP_NOT_FOUND_ERROR = + 'Could not find Electron app at %s build with %s!\n' + + 'If the application is not compiled, please do so before running your tests, via `%s`.\n' + + 'Otherwise if the application is compiled at a different location, please specify the `appBinaryPath` option in your capabilities.'; + export default class ElectronLaunchService implements Services.ServiceInstance { #globalOptions: ElectronServiceOptions; #projectRoot: string; @@ -28,7 +38,11 @@ export default class ElectronLaunchService implements Services.ServiceInstance { ({ packageJson: { dependencies: {}, devDependencies: {} } } as NormalizedReadResult); const { dependencies, devDependencies } = pkg.packageJson; - const pkgElectronVersion = dependencies?.electron || devDependencies?.electron; + const pkgElectronVersion = + dependencies?.electron || + devDependencies?.electron || + dependencies?.['electron-nightly'] || + devDependencies?.['electron-nightly']; const localElectronVersion = pkgElectronVersion ? findVersions(pkgElectronVersion, { loose: true })[0] : undefined; if (!caps.length) { @@ -43,7 +57,10 @@ export default class ElectronLaunchService implements Services.ServiceInstance { const chromiumVersion = await getChromiumVersion(electronVersion); log.debug(`found Electron v${electronVersion} with Chromedriver v${chromiumVersion}`); - const { appBinaryPath, appArgs } = Object.assign({}, this.#globalOptions, cap['wdio:electronServiceOptions']); + let { appBinaryPath, appArgs } = Object.assign({}, this.#globalOptions, cap['wdio:electronServiceOptions']); + if (!appBinaryPath) { + appBinaryPath = await detectBinaryPath(pkg); + } const invalidPathOpts = appBinaryPath === undefined; if (invalidPathOpts) { @@ -82,3 +99,63 @@ export default class ElectronLaunchService implements Services.ServiceInstance { }); } } + +/** + * detect the path to the Electron app binary + * @param pkg result of `readPackageUp` + * @param p process object (used for testing purposes) + * @returns path to the Electron app binary + */ +export async function detectBinaryPath(pkg: NormalizedReadResult, p = process) { + const appName: string = pkg.packageJson.build?.productName || pkg.packageJson.name; + if (!appName) { + return undefined; + } + + const isForgeSetup = Boolean( + pkg.packageJson.config?.forge || Object.keys(pkg.packageJson.devDependencies || {}).includes('@electron-forge/cli'), + ); + if (isForgeSetup) { + /** + * Electron Forge always bundles into an `out` directory, until this PR is merged: + * https://github.com/electron/forge/pull/2714 + */ + const outDir = path.join(path.dirname(pkg.path), 'out', `${appName}-${p.platform}-${p.arch}`); + const appPath = + p.platform === 'darwin' + ? path.join(outDir, `${appName}.app`, 'Contents', 'MacOS', appName) + : p.platform === 'win32' + ? path.join(outDir, `${appName}.exe`) + : path.join(outDir, appName); + const appExists = await fs.access(appPath).then( + () => true, + () => false, + ); + if (!appExists) { + throw new SevereServiceError( + util.format(APP_NOT_FOUND_ERROR, appPath, 'Electron Forge', 'npx electron-forge make'), + ); + } + return appPath; + } + + const isElectronBuilderSetup = Boolean( + pkg.packageJson.build?.appId || Object.keys(pkg.packageJson.devDependencies || {}).includes('electron-builder'), + ); + if (isElectronBuilderSetup) { + const distDirName = pkg.packageJson.build?.directories?.output || 'dist'; + const appPath = getBinaryPath(path.dirname(pkg.path), appName, distDirName, p); + const appExists = await fs.access(appPath).then( + () => true, + () => false, + ); + if (!appExists) { + throw new SevereServiceError( + util.format(APP_NOT_FOUND_ERROR, appPath, 'Electron Builder', 'npx electron-builder build'), + ); + } + return appPath; + } + + return undefined; +} diff --git a/src/utils.ts b/src/utils.ts index 3d9c1a3e..cd5c4c51 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,24 +1,22 @@ import path from 'node:path'; -export function getBinaryPath(appPath: string, appName: string, distDirName = 'dist') { +export function getBinaryPath(appPath: string, appName: string, distDirName = 'dist', p = process) { const SupportedPlatform = { darwin: 'darwin', linux: 'linux', win32: 'win32', }; - const { platform, arch } = process; - - if (!Object.values(SupportedPlatform).includes(platform)) { - throw new Error(`Unsupported platform: ${platform}`); + if (!Object.values(SupportedPlatform).includes(p.platform)) { + throw new Error(`Unsupported platform: ${p.platform}`); } const pathMap = { - darwin: path.join(arch === 'arm64' ? 'mac-arm64' : 'mac', `${appName}.app`, 'Contents', 'MacOS', appName), + darwin: path.join(p.arch === 'arm64' ? 'mac-arm64' : 'mac', `${appName}.app`, 'Contents', 'MacOS', appName), linux: path.join('linux-unpacked', appName), win32: path.join('win-unpacked', `${appName}.exe`), }; - const electronPath = pathMap[platform as keyof typeof SupportedPlatform]; + const electronPath = pathMap[p.platform as keyof typeof SupportedPlatform]; return path.join(appPath, distDirName, electronPath); } diff --git a/test/launcher.spec.ts b/test/launcher.spec.ts index b4b74ae7..47e19d2d 100644 --- a/test/launcher.spec.ts +++ b/test/launcher.spec.ts @@ -1,8 +1,8 @@ import path from 'node:path'; -import { describe, beforeEach, afterEach, it, expect } from 'vitest'; +import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; import type { Capabilities, Options } from '@wdio/types'; -import ElectronLaunchService from '../src/launcher'; +import ElectronLaunchService, { detectBinaryPath } from '../src/launcher'; import { mockProcessProperty, revertProcessProperty } from './helpers'; import type { ElectronServiceOptions } from '../src/index'; @@ -10,6 +10,12 @@ let LaunchService: typeof ElectronLaunchService; let instance: ElectronLaunchService | undefined; let options: ElectronServiceOptions; +vi.mock('node:fs/promises', () => ({ + default: { + access: vi.fn().mockResolvedValue(undefined), + }, +})); + beforeEach(async () => { mockProcessProperty('platform', 'darwin'); LaunchService = (await import('../src/launcher')).default; @@ -455,3 +461,125 @@ describe('onPrepare', () => { ]); }); }); + +describe('detectBinaryPath', () => { + const pkgJSONPath = '/foo/bar/package.json'; + const winProcess = { + arch: 'x64', + platform: 'win32', + } as NodeJS.Process; + const macProcess = { + arch: 'arm64', + platform: 'darwin', + } as NodeJS.Process; + const linuxProcess = { + arch: 'arm', + platform: 'linux', + } as NodeJS.Process; + + it('should return undefined if appName can not be determined', async () => { + expect(await detectBinaryPath({ packageJson: {} } as never)).toBeUndefined(); + }); + + it("should return app path for Electron Forge setup's", async () => { + expect( + await detectBinaryPath( + { + path: pkgJSONPath, + packageJson: { + build: { + productName: 'my-app', + }, + devDependencies: { + '@electron-forge/cli': '7.0.0-beta.54', + }, + }, + } as never, + winProcess, + ), + ).toBe(path.join('/foo', 'bar', 'out', 'my-app-win32-x64', 'my-app.exe')); + expect( + await detectBinaryPath( + { + path: pkgJSONPath, + packageJson: { + build: { + productName: 'my-app', + }, + devDependencies: { + '@electron-forge/cli': '7.0.0-beta.54', + }, + }, + } as never, + macProcess, + ), + ).toBe(path.join('/foo', 'bar', 'out', 'my-app-darwin-arm64', 'my-app.app', 'Contents', 'MacOS', 'my-app')); + expect( + await detectBinaryPath( + { + path: pkgJSONPath, + packageJson: { + build: { + productName: 'my-app', + }, + devDependencies: { + '@electron-forge/cli': '7.0.0-beta.54', + }, + }, + } as never, + linuxProcess, + ), + ).toBe(path.join('/foo', 'bar', 'out', 'my-app-linux-arm', 'my-app')); + }); + + it("should return app path for Electron Builder setup's", async () => { + expect( + await detectBinaryPath( + { + path: pkgJSONPath, + packageJson: { + build: { + productName: 'my-app', + }, + devDependencies: { + 'electron-builder': '^24.6.4', + }, + }, + } as never, + winProcess, + ), + ).toBe(path.join('/foo', 'bar', 'dist', 'win-unpacked', 'my-app.exe')); + expect( + await detectBinaryPath( + { + path: pkgJSONPath, + packageJson: { + build: { + productName: 'my-app', + }, + devDependencies: { + 'electron-builder': '^24.6.4', + }, + }, + } as never, + macProcess, + ), + ).toBe(path.join('/foo', 'bar', 'dist', 'mac-arm64', 'my-app.app', 'Contents', 'MacOS', 'my-app')); + expect( + await detectBinaryPath( + { + path: pkgJSONPath, + packageJson: { + build: { + productName: 'my-app', + }, + devDependencies: { + 'electron-builder': '^24.6.4', + }, + }, + } as never, + linuxProcess, + ), + ).toBe(path.join('/foo', 'bar', 'dist', 'linux-unpacked', 'my-app')); + }); +});