UNPKG

@wdio/electron-utils

Version:

Utilities for WebdriverIO Electron Service

370 lines (359 loc) 15.5 kB
import path, { dirname } from 'node:path'; import findVersions from 'find-versions'; import fs from 'node:fs/promises'; import log from './log.js'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { allOfficialArchsForPlatformAndVersion } from '@electron/packager'; import 'debug'; import '@wdio/logger'; const BUILD_TOOL_DETECTION_ERROR = 'No build tool was detected, if the application is compiled at a different location, please specify the `appBinaryPath` option in your capabilities.'; const APP_NAME_DETECTION_ERROR = 'No application name was detected, please set name / productName in your package.json or build tool configuration.'; const PKG_NAME_ELECTRON = { STABLE: 'electron', NIGHTLY: 'electron-nightly', }; const PNPM_CATALOG_PREFIX = 'catalog:'; const PNPM_WORKSPACE_YAML = 'pnpm-workspace.yaml'; const BUILDER_CONFIG_NOT_FOUND_ERROR = 'Electron-builder was detected but no configuration was found, make sure your config file is named correctly, e.g. `electron-builder.config.json`.'; const FORGE_CONFIG_NOT_FOUND_ERROR = 'Forge was detected but no configuration was found.'; const MULTIPLE_BUILD_TOOL_WARNING = { DESCRIPTION: 'Detected both Forge and Builder configurations, the Forge configuration will be used to determine build information', SUGGESTION: 'You can override this by specifying the `appBinaryPath` option in your capabilities.', }; const SUPPORTED_PLATFORM = { darwin: 'darwin', linux: 'linux', win32: 'win32', }; const SUPPORTED_BUILD_TOOL = { forge: 'forge', builder: 'builder', }; let _projectDir; let pnpmWorkspace; async function findPnpmCatalogVersion(pkgName, pkgVersion, projectDir) { if (!projectDir) { return undefined; } // Determine catalog names const electronCatalogName = pkgVersion?.split(PNPM_CATALOG_PREFIX)[1]?.trim(); log.debug(`Locating ${PNPM_WORKSPACE_YAML}...`); try { // Traverse up the directory tree to find pnpm-workspace.yaml let currentDir = projectDir; let workspaceYamlPath; let yamlContent; if (!pnpmWorkspace || _projectDir !== projectDir) { _projectDir = projectDir; while (currentDir !== path.parse(currentDir).root) { workspaceYamlPath = path.join(currentDir, PNPM_WORKSPACE_YAML); try { yamlContent = await fs.readFile(workspaceYamlPath, 'utf8'); log.debug(`Found ${PNPM_WORKSPACE_YAML} at ${workspaceYamlPath}`); break; } catch (_e) { // Move up one directory currentDir = path.dirname(currentDir); } } if (!yamlContent) { return undefined; } pnpmWorkspace = (await import('yaml')).parse(yamlContent); } // Handle named catalog if (electronCatalogName && pnpmWorkspace.catalogs?.[electronCatalogName]?.[pkgName]) { return pnpmWorkspace.catalogs[electronCatalogName][pkgName]; } // Handle default catalog if (pkgVersion === PNPM_CATALOG_PREFIX && pnpmWorkspace.catalog?.[pkgName]) { return pnpmWorkspace.catalog[pkgName]; } return undefined; } catch (error) { // Gracefully handle other errors log.debug(`Error finding pnpm workspace: ${error.message}`); return undefined; } } async function getElectronVersion(pkg) { const projectDir = dirname(pkg.path); const { dependencies, devDependencies } = pkg.packageJson; const getElectronDependencies = async (pkgName) => { const deps = dependencies?.[pkgName] || devDependencies?.[pkgName]; if (typeof deps === `undefined`) { return deps; } return deps.startsWith(PNPM_CATALOG_PREFIX) ? await findPnpmCatalogVersion(pkgName, deps, projectDir) : deps; }; const pkgElectronVersion = await getElectronDependencies(PKG_NAME_ELECTRON.STABLE); const pkgElectronNightlyVersion = await getElectronDependencies(PKG_NAME_ELECTRON.NIGHTLY); const electronVersion = pkgElectronVersion || pkgElectronNightlyVersion; return electronVersion ? findVersions(electronVersion, { loose: true })[0] : undefined; } const __filename = fileURLToPath(import.meta.url); async function readConfig(configFile, projectDir) { const configFilePath = path.join(projectDir, configFile); await fs.access(configFilePath, fs.constants.R_OK); const ext = path.parse(configFile).ext; const extRegex = { js: /\.(c|m)?(j|t)s$/, json: /\.json(5)?$/, toml: /\.toml$/, yaml: /\.y(a)?ml$/, }; let result; if (extRegex.js.test(ext)) { const { tsImport } = await import('tsx/esm/api'); const configFilePathUrl = pathToFileURL(configFilePath).toString(); const readResult = (await tsImport(configFilePathUrl, __filename)).default; if (typeof readResult === 'function') { result = readResult(); } else { result = readResult; } result = await Promise.resolve(result); } else { const data = await fs.readFile(configFilePath, 'utf8'); if (extRegex.json.test(ext)) { const json5 = await import('json5'); // JSON5 exports parse as default in ESM, but as a named export in CJS // https://github.com/json5/json5/issues/240 const parseJson = json5.parse || json5.default.parse; result = parseJson(data); } else if (extRegex.toml.test(ext)) { result = (await import('smol-toml')).parse(data); } else if (extRegex.yaml.test(ext)) { result = (await import('yaml')).parse(data); } } return { result, configFile }; } async function getConfig$1(pkg) { const rootDir = path.dirname(pkg.path); let builderConfig = pkg.packageJson.build; if (!builderConfig) { // if builder config is not found in the package.json, attempt to read `electron-builder.{yaml, yml, json, json5, toml}` // see also https://www.electron.build/configuration.html try { log.info('Locating builder config file...'); const config = await readBuilderConfig(getBuilderConfigCandidates(), rootDir); if (!config) { throw new Error(); } log.info(`Detected config file: ${config.configFile}`); builderConfig = config.result; } catch (_e) { log.warn('Builder config file not found or invalid.'); return undefined; } } return builderBuildInfo(builderConfig, pkg); } async function readBuilderConfig(fileCandidate, projectDir) { for (const configFile of fileCandidate) { try { log.debug(`Attempting to read config file: ${configFile}...`); return await readConfig(configFile, projectDir); } catch (_e) { log.debug('unsuccessful'); } } return undefined; } function getBuilderConfigCandidates(configFileName = 'electron-builder') { const exts = ['.yml', '.yaml', '.json', '.json5', '.toml', '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']; return exts.reduce((acc, ext) => acc.concat([`${configFileName}${ext}`, `${configFileName}.config${ext}`]), []); } function builderBuildInfo(builderConfig, pkg) { log.info(`Builder configuration detected: \n${JSON.stringify(builderConfig)}`); const appName = pkg.packageJson.productName || builderConfig?.productName || pkg.packageJson.name; if (!appName) { throw new Error(APP_NAME_DETECTION_ERROR); } return { appName, config: builderConfig, isForge: false, isBuilder: true, }; } function forgeBuildInfo(forgeConfig, pkg) { log.info(`Forge configuration detected: \n${JSON.stringify(forgeConfig)}`); const appName = pkg.packageJson.productName || forgeConfig?.packagerConfig?.name || pkg.packageJson.name; if (!appName) { throw new Error(APP_NAME_DETECTION_ERROR); } return { appName, config: forgeConfig, isForge: true, isBuilder: false, }; } async function getConfig(pkg) { const forgePackageJsonConfig = pkg.packageJson.config?.forge; // if config.forge is a string it is a custom config file path const isConfigFilePath = typeof forgePackageJsonConfig === 'string'; const rootDir = path.dirname(pkg.path); let forgeConfig = forgePackageJsonConfig; if (!forgePackageJsonConfig || isConfigFilePath) { // if no forge config or a linked file is found in the package.json, attempt to read Forge JS-based config const forgeConfigFileName = isConfigFilePath ? forgePackageJsonConfig : 'forge.config.js'; const forgeConfigPath = path.join(rootDir, forgeConfigFileName); try { log.info(`Reading Forge config file: ${forgeConfigPath}...`); forgeConfig = (await readConfig(forgeConfigFileName, rootDir)).result; } catch (_) { log.warn(`Forge config file not found or invalid at ${forgeConfigPath}.`); return undefined; } } return forgeBuildInfo(forgeConfig, pkg); } /** * Determine build information about the Electron application * @param pkg normalized package.json * @returns promise resolving to the app build information */ async function getAppBuildInfo(pkg) { const forgeDependencyDetected = Object.keys(pkg.packageJson.devDependencies || {}).includes('@electron-forge/cli'); const builderDependencyDetected = Object.keys(pkg.packageJson.devDependencies || {}).includes('electron-builder'); const forgeConfig = forgeDependencyDetected ? await getConfig(pkg) : undefined; const builderConfig = builderDependencyDetected ? await getConfig$1(pkg) : undefined; const isForge = typeof forgeConfig !== 'undefined'; const isBuilder = typeof builderConfig !== 'undefined'; if (forgeDependencyDetected && !isForge && !isBuilder) { throw new Error(FORGE_CONFIG_NOT_FOUND_ERROR); } if (builderDependencyDetected && !isForge && !isBuilder) { throw new Error(BUILDER_CONFIG_NOT_FOUND_ERROR); } if (isForge && isBuilder) { log.warn(MULTIPLE_BUILD_TOOL_WARNING.DESCRIPTION); log.warn(MULTIPLE_BUILD_TOOL_WARNING.SUGGESTION); } if (isForge) { log.info('Using Forge configuration to get app build information...'); return forgeConfig; } if (isBuilder) { log.info('Using Builder configuration to get app build information...'); return builderConfig; } throw new Error(BUILD_TOOL_DETECTION_ERROR); } async function selectExecutable(binaryPaths) { // for each path, check if it exists and is executable const binaryPathsAccessResults = await Promise.all(binaryPaths.map(async (binaryPath) => { try { log.debug(`Checking binary path: ${binaryPath}...`); await fs.access(binaryPath, fs.constants.X_OK); log.debug(`'${binaryPath}' is executable.`); return true; } catch (e) { log.debug(`'${binaryPath}' is not executable.`, e.message); return false; } })); // get the list of executable paths const executableBinaryPaths = binaryPaths.filter((_binaryPath, index) => binaryPathsAccessResults[index]); // no executable binary case if (executableBinaryPaths.length === 0) { throw new Error(`No executable binary found, checked: \n${binaryPaths.join(', \n')}`); } // multiple executable binaries case if (executableBinaryPaths.length > 1) { log.info(`Detected multiple app binaries, using the first one: \n${executableBinaryPaths.join(', \n')}`); } return executableBinaryPaths[0]; } function getForgeDistDir(config, appName, platform, electronVersion) { const archs = allOfficialArchsForPlatformAndVersion(platform, electronVersion); const forgeOutDir = config?.outDir || 'out'; return archs.map((arch) => path.join(forgeOutDir, `${appName}-${platform}-${arch}`)); } function getBuilderDistDir(config, platform) { const builderOutDirName = config?.directories?.output || 'dist'; const builderOutDirMap = (arch) => ({ darwin: path.join(builderOutDirName, arch === 'x64' ? 'mac' : `mac-${arch}`), linux: path.join(builderOutDirName, 'linux-unpacked'), win32: path.join(builderOutDirName, 'win-unpacked'), }); // return [builderOutDirMap[platform]]; if (platform === 'darwin') { // macOS output dir depends on the arch used // - we check all of the possible dirs const archs = ['arm64', 'armv7l', 'ia32', 'universal', 'x64']; return archs.map((arch) => builderOutDirMap(arch)[platform]); } else { // other platforms have a single output dir which is not dependent on the arch return [builderOutDirMap('x64')[platform]]; } } function getPlatformBinaryPath(outDir, binaryName, platform) { const binaryPathMap = { darwin: () => path.join(`${binaryName}.app`, 'Contents', 'MacOS', binaryName), linux: () => binaryName, win32: () => `${binaryName}.exe`, }; return path.join(outDir, binaryPathMap[platform]()); } function getBinaryName(options) { const { buildTool, appName, config } = options; if (buildTool === SUPPORTED_BUILD_TOOL.forge) { return config.packagerConfig?.executableName || appName; } return appName; } function getOutDir(options) { const { buildTool, config, appName, platform, electronVersion, projectDir } = options; const outDirs = buildTool === SUPPORTED_BUILD_TOOL.forge ? getForgeDistDir(config, appName, platform, electronVersion) : getBuilderDistDir(config, platform); return outDirs.map((dir) => path.join(projectDir, dir)); } /** * Determine the path to the Electron application binary * @param packageJsonPath path to the nearest package.json * @param appBuildInfo build information about the Electron application * @param electronVersion version of Electron to use * @param p process object (used for testing purposes) * @returns path to the Electron app binary */ async function getBinaryPath(packageJsonPath, appBuildInfo, electronVersion, p = process) { if (!isSupportedPlatform(p.platform)) { throw new Error(`Unsupported platform: ${p.platform}`); } if (!appBuildInfo.isForge && !appBuildInfo.isBuilder) { throw new Error('Configurations that are neither Forge nor Builder are not supported.'); } const options = { buildTool: appBuildInfo.isForge ? SUPPORTED_BUILD_TOOL.forge : SUPPORTED_BUILD_TOOL.builder, platform: p.platform, appName: appBuildInfo.appName, config: appBuildInfo.config, electronVersion, projectDir: path.dirname(packageJsonPath), }; const outDirs = getOutDir(options); const binaryName = getBinaryName(options); const binaryPaths = outDirs.map((dir) => getPlatformBinaryPath(dir, binaryName, options.platform)); return selectExecutable(binaryPaths); } function isSupportedPlatform(p) { return p in SUPPORTED_PLATFORM; } export { getAppBuildInfo, getBinaryPath, getElectronVersion }; //# sourceMappingURL=index.js.map