@wdio/electron-utils
Version:
Utilities for WebdriverIO Electron Service
370 lines (359 loc) • 15.5 kB
JavaScript
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