wdio-electron-service
Version:
WebdriverIO service to enable Electron testing
1,088 lines (1,061 loc) • 44.4 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var globals = require('@wdio/globals');
var webdriverio = require('webdriverio');
var log = require('@wdio/electron-utils/log');
var constants = require('./constants-DkSg7z4n.js');
var spy = require('@vitest/spy');
var recast = require('recast');
var babelParser = require('@babel/parser');
var os = require('node:os');
var cdpBridge = require('@wdio/cdp-bridge');
var util = require('node:util');
var path = require('node:path');
var getPort = require('get-port');
var readPackageUp = require('read-package-up');
var electronUtils = require('@wdio/electron-utils');
var compareVersions = require('compare-versions');
var electronToChromium = require('electron-to-chromium');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var babelParser__namespace = /*#__PURE__*/_interopNamespaceDefault(babelParser);
class ElectronServiceMockStore {
#mockFns;
constructor() {
this.#mockFns = new Map();
}
setMock(mock) {
this.#mockFns.set(mock.getMockName(), mock);
return mock;
}
getMock(mockId) {
const mock = this.#mockFns.get(mockId);
if (!mock) {
throw new Error(`No mock registered for "${mockId}"`);
}
return mock;
}
getMocks() {
return Array.from(this.#mockFns.entries());
}
}
const mockStore = new ElectronServiceMockStore();
const puppeteerSessionManager = new Map();
const getActiveWindowHandle = async (puppeteerBrowser, currentHandle) => {
log.trace('Getting active window handle');
if (!puppeteerBrowser) {
log.trace('Puppeteer is not initialized.');
return undefined;
}
const handles = puppeteerBrowser
.targets()
.filter((target) => target.type() === 'page')
.map((target) => target._targetId);
// No windows available
if (handles.length === 0) {
log.trace('No windows found');
return undefined;
}
// If we have a current window handle and it's still valid, keep using it
if (currentHandle && handles.includes(currentHandle)) {
log.trace(`Keeping current window handle: ${currentHandle}`);
return currentHandle;
}
// Otherwise return first available window handle
log.trace(`Selecting first available window handle: ${handles[0]}`);
return handles[0];
};
const switchToActiveWindow = async (browser, puppeteerBrowser) => {
const activeHandle = await getActiveWindowHandle(puppeteerBrowser, browser.electron.windowHandle);
if (activeHandle && activeHandle !== browser.electron.windowHandle) {
log.debug('The active window has changed. Switching...', `New window handle: ${activeHandle}`, `Previous window handle: ${browser.electron.windowHandle}`);
await browser.switchToWindow(activeHandle);
browser.electron.windowHandle = activeHandle;
}
};
const ensureActiveWindowFocus = async (browser, commandName) => {
log.trace(`Ensuring active window focus before command: ${commandName}`);
if (browser.isMultiremote) {
const mrBrowser = browser;
for (const instance of mrBrowser.instances) {
const mrInstance = mrBrowser.getInstance(instance);
const mrPuppeteer = await getPuppeteer(mrInstance);
await switchToActiveWindow(mrInstance, mrPuppeteer);
}
}
else {
const puppeteer = await getPuppeteer(browser);
await switchToActiveWindow(browser, puppeteer);
}
log.trace(`Window focus check completed for command: ${commandName}`);
};
async function getPuppeteer(browser) {
const sessionId = browser.sessionId;
const puppeteer = puppeteerSessionManager.get(sessionId);
if (puppeteer) {
log.trace(`Use cached puppeteer browser.`);
return puppeteer;
}
else {
log.trace(`Get puppeteer browser.`);
const puppeteer = await browser.getPuppeteer();
puppeteerSessionManager.set(sessionId, puppeteer);
return puppeteer;
}
}
function clearPuppeteerSessions() {
log.trace(`Remove all puppeteer sessions`);
puppeteerSessionManager.clear();
}
function isMockFunction(fn) {
return (typeof fn === 'function' && '__isElectronMock' in fn && fn.__isElectronMock);
}
async function restoreElectronFunctionality(apiName, funcName) {
await browser.electron.execute((electron, apiName, funcName) => {
const electronApi = electron[apiName];
const originalApi = globalThis.originalApi;
const originalApiMethod = originalApi[apiName][funcName];
electronApi[funcName].mockImplementation(originalApiMethod);
}, apiName, funcName, { internal: true });
}
async function createMock(apiName, funcName) {
const outerMock = spy.fn();
const outerMockImplementation = outerMock.mockImplementation;
const outerMockImplementationOnce = outerMock.mockImplementationOnce;
const outerMockClear = outerMock.mockClear;
const outerMockReset = outerMock.mockReset;
outerMock.mockName(`electron.${apiName}.${funcName}`);
const mock = outerMock;
mock.__isElectronMock = true;
// initialise inner (Electron) mock
await browser.electron.execute(async (electron, apiName, funcName) => {
const electronApi = electron[apiName];
const spy = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespaceDefault(require('@vitest/spy')); });
const mockFn = spy.fn();
// replace target API with mock
electronApi[funcName] = mockFn;
}, apiName, funcName, { internal: true });
mock.update = async () => {
// synchronises inner and outer mocks
const calls = await browser.electron.execute((electron, apiName, funcName) => {
const mockObj = electron[apiName][funcName];
return mockObj.mock?.calls ? JSON.parse(JSON.stringify(mockObj.mock?.calls)) : [];
}, apiName, funcName, { internal: true });
// re-apply calls from the electron main process mock to the outer one
if (mock.mock.calls.length < calls.length) {
calls.forEach((call, index) => {
if (!mock.mock.calls[index]) {
mock?.apply(mock, call);
}
});
}
return mock;
};
mock.mockImplementation = async (implFn) => {
await browser.electron.execute((electron, apiName, funcName, mockImplementationStr) => {
const electronApi = electron[apiName];
const mockImpl = new Function(`return ${mockImplementationStr}`)();
electronApi[funcName].mockImplementation(mockImpl);
}, apiName, funcName, implFn.toString(), { internal: true });
outerMockImplementation(implFn);
return mock;
};
mock.mockImplementationOnce = async (implFn) => {
await browser.electron.execute((electron, apiName, funcName, mockImplementationStr) => {
const electronApi = electron[apiName];
const mockImpl = new Function(`return ${mockImplementationStr}`)();
electronApi[funcName].mockImplementationOnce(mockImpl);
}, apiName, funcName, implFn.toString(), { internal: true });
outerMockImplementationOnce(implFn);
return mock;
};
mock.mockReturnValue = async (value) => {
await browser.electron.execute((electron, apiName, funcName, returnValue) => {
const electronApi = electron[apiName];
electronApi[funcName].mockReturnValue(returnValue);
}, apiName, funcName, value, { internal: true });
return mock;
};
mock.mockReturnValueOnce = async (value) => {
await browser.electron.execute((electron, apiName, funcName, returnValue) => {
const electronApi = electron[apiName];
electronApi[funcName].mockReturnValueOnce(returnValue);
}, apiName, funcName, value, { internal: true });
return mock;
};
mock.mockResolvedValue = async (value) => {
await browser.electron.execute((electron, apiName, funcName, resolvedValue) => {
const electronApi = electron[apiName];
electronApi[funcName].mockResolvedValue(resolvedValue);
}, apiName, funcName, value, { internal: true });
return mock;
};
mock.mockResolvedValueOnce = async (value) => {
await browser.electron.execute((electron, apiName, funcName, resolvedValue) => {
const electronApi = electron[apiName];
electronApi[funcName].mockResolvedValueOnce(resolvedValue);
}, apiName, funcName, value, { internal: true });
return mock;
};
mock.mockRejectedValue = async (value) => {
await browser.electron.execute((electron, apiName, funcName, rejectedValue) => {
const electronApi = electron[apiName];
electronApi[funcName].mockRejectedValue(rejectedValue);
}, apiName, funcName, value, { internal: true });
return mock;
};
mock.mockRejectedValueOnce = async (value) => {
await browser.electron.execute((electron, apiName, funcName, rejectedValue) => {
const electronApi = electron[apiName];
electronApi[funcName].mockRejectedValueOnce(rejectedValue);
}, apiName, funcName, value, { internal: true });
return mock;
};
mock.mockClear = async () => {
// clears mock history
await browser.electron.execute((electron, apiName, funcName) => {
electron[apiName][funcName].mockClear();
}, apiName, funcName, { internal: true });
outerMockClear();
return mock;
};
mock.mockReset = async () => {
// resets inner implementation to an empty function and clears mock history
await browser.electron.execute((electron, apiName, funcName) => {
electron[apiName][funcName].mockReset();
}, apiName, funcName, { internal: true });
outerMockReset();
// vitest mockReset doesn't clear mock history so we need to explicitly clear both mocks
await mock.mockClear();
return mock;
};
mock.mockRestore = async () => {
// restores inner mock implementation to the original function
await restoreElectronFunctionality(apiName, funcName);
// clear mocks
outerMockClear();
await mock.mockClear();
return mock;
};
mock.mockReturnThis = async () => {
return await browser.electron.execute((electron, apiName, funcName) => {
electron[apiName][funcName].mockReturnThis();
}, apiName, funcName, { internal: true });
};
mock.withImplementation = async (implFn, callbackFn) => {
return await browser.electron.execute(async (electron, apiName, funcName, implFnStr, callbackFnStr) => {
const callback = new Function(`return ${callbackFnStr}`)();
const impl = new Function(`return ${implFnStr}`)();
let result;
electron[apiName][funcName].withImplementation(impl, () => {
result = callback(electron);
});
return result?.then ? await result : result;
}, apiName, funcName, implFn.toString(), callbackFn.toString(), { internal: true });
};
return mock;
}
async function mock(apiName, funcName) {
try {
// retrieve an existing mock from the store
const existingMock = mockStore.getMock(`electron.${apiName}.${funcName}`);
await existingMock.mockReset();
return existingMock;
}
catch (_e) {
// mock doesn't exist, create a new one and store it
const newMock = await createMock(apiName, funcName);
mockStore.setMock(newMock);
return newMock;
}
}
async function mockAll(apiName) {
const apiFnNames = await browser.electron.execute((electron, apiName) => Object.keys(electron[apiName]).toString(), apiName, { internal: true });
const mockedApis = apiFnNames
.split(',')
.reduce((a, funcName) => ({ ...a, [funcName]: () => undefined }), {});
for (const funcName in mockedApis) {
mockedApis[funcName] = await mock(apiName, funcName);
}
return mockedApis;
}
async function clearAllMocks(apiName) {
for (const [mockName, mock] of mockStore.getMocks()) {
if (!apiName || mockName.match(new RegExp(`^electron.${apiName}`))) {
await mock.mockClear();
}
}
}
async function resetAllMocks(apiName) {
for (const [mockName, mock] of mockStore.getMocks()) {
if (!apiName || mockName.match(new RegExp(`^electron.${apiName}`))) {
await mock.mockReset();
}
}
}
async function restoreAllMocks(apiName) {
for (const [mockName, mock] of mockStore.getMocks()) {
if (!apiName || mockName.match(new RegExp(`^electron.${apiName}`))) {
await mock.mockRestore();
}
}
}
async function execute$1(browser, script, ...args) {
/**
* parameter check
*/
if (typeof script !== 'string' && typeof script !== 'function') {
throw new Error('Expecting script to be type of "string" or "function"');
}
if (!browser) {
throw new Error('WDIO browser is not yet initialised');
}
if (!browser.electron.bridgeActive) {
const errMessage = 'Electron context bridge not available! ' +
'Did you import the service hook scripts into your application via e.g. ' +
"`import('wdio-electron-service/main')` and `import('wdio-electron-service/preload')`?\n\n" +
'Find more information at https://webdriver.io/docs/desktop-testing/electron#api-configuration';
throw new Error(errMessage);
}
const returnValue = await browser.execute(function executeWithinElectron(script, ...args) {
return window.wdioElectron.execute(script, args);
}, `${script}`, ...args);
return returnValue ?? undefined;
}
class ServiceConfig {
#globalOptions;
#cdpOptions;
#clearMocks = false;
#resetMocks = false;
#restoreMocks = false;
#browser;
#useCdpBridge = true;
constructor(globalOptions = {}, capabilities) {
this.#globalOptions = globalOptions;
const { clearMocks, resetMocks, restoreMocks } = Object.assign({}, this.#globalOptions, capabilities[constants.CUSTOM_CAPABILITY_NAME]);
this.#clearMocks = clearMocks ?? false;
this.#resetMocks = resetMocks ?? false;
this.#restoreMocks = restoreMocks ?? false;
const { useCdpBridge } = globalOptions;
if (typeof useCdpBridge === 'boolean' && !useCdpBridge) {
this.#useCdpBridge = useCdpBridge;
}
this.#cdpOptions = {
...(globalOptions.cdpBridgeTimeout && { timeout: globalOptions.cdpBridgeTimeout }),
...(globalOptions.cdpBridgeWaitInterval && { waitInterval: globalOptions.cdpBridgeWaitInterval }),
...(globalOptions.cdpBridgeRetryCount && { connectionRetryCount: globalOptions.cdpBridgeRetryCount }),
};
}
get globalOptions() {
return this.#globalOptions;
}
get browser() {
return this.#browser;
}
set browser(browser) {
this.#browser = browser;
}
get cdpOptions() {
return this.#cdpOptions;
}
get clearMocks() {
return this.#clearMocks;
}
get resetMocks() {
return this.#resetMocks;
}
get restoreMocks() {
return this.#restoreMocks;
}
get useCdpBridge() {
return this.#useCdpBridge;
}
}
async function execute(browser, cdpBridge, script, ...args) {
/**
* parameter check
*/
if (typeof script !== 'string' && typeof script !== 'function') {
throw new Error('Expecting script to be type of "string" or "function"');
}
if (!browser) {
throw new Error('WDIO browser is not yet initialised');
}
if (browser.isMultiremote) {
const mrBrowser = browser;
return await Promise.all(mrBrowser.instances.map(async (instance) => {
const mrInstance = mrBrowser.getInstance(instance);
return mrInstance.electron.execute(script, ...args);
}));
}
if (!cdpBridge) {
throw new Error('CDP Bridge is not yet initialised');
}
const functionDeclaration = removeFirstArg(script.toString());
const argsArray = args.map((arg) => ({ value: arg }));
const scriptLength = Buffer.byteLength(functionDeclaration, 'utf-8');
if (scriptLength > 500) {
log.debug('Script for the execute:', `${functionDeclaration.split('\n')[0]} ... [${scriptLength} bytes]`);
}
else {
log.debug('Script for the execute:', functionDeclaration);
}
const result = await cdpBridge.send('Runtime.callFunctionOn', {
functionDeclaration,
arguments: argsArray,
awaitPromise: true,
returnByValue: true,
executionContextId: cdpBridge.contextId,
});
log.debug('Return of the script:', result.result.value);
await syncMockStatus(args);
return result.result.value ?? undefined;
}
const syncMockStatus = async (args) => {
const isInternalCommand = () => Boolean(args.at(-1)?.internal);
const mocks = mockStore.getMocks();
if (mocks.length > 0 && !isInternalCommand()) {
await Promise.all(mocks.map(async ([_mockId, mock]) => mock.update()));
}
};
//remove first arg `electron`. electron can be access as global scope.
const removeFirstArg = (funcStr) => {
// generate ATS
const ast = recast.parse(funcStr, {
parser: {
parse: (source) => babelParser__namespace.parse(source, {
sourceType: 'module',
plugins: ['typescript'],
}),
},
});
let funcNode = null;
const topLevelNode = ast.program.body[0];
if (topLevelNode.type === 'ExpressionStatement') {
// Arrow function
funcNode = topLevelNode.expression;
}
else if (topLevelNode.type === 'FunctionDeclaration') {
// Function declaration
funcNode = topLevelNode;
}
if (!funcNode) {
throw new Error('Unsupported function type');
}
// Remove first args `electron` if exists
if ('params' in funcNode && Array.isArray(funcNode.params)) {
funcNode.params.shift();
}
return recast.print(ast).code;
};
const getDebuggerEndpoint = (capabilities) => {
log.trace('Try to detect the node debugger endpoint');
const debugArg = capabilities['goog:chromeOptions']?.args?.find((item) => item.startsWith('--inspect='));
log.trace(`Detected debugger args: ${debugArg}`);
const debugUrl = debugArg ? debugArg.split('=')[1] : undefined;
const [host, strPort] = debugUrl ? debugUrl.split(':') : [];
const result = { host, port: Number(strPort) };
if (!result.host || !result.port) {
throw new webdriverio.SevereServiceError(`Failed to detect the debugger endpoint.`);
}
log.trace(`Detected the node debugger endpoint: `, result);
return result;
};
class ElectronCdpBridge extends cdpBridge.CdpBridge {
#contextId = 0;
get contextId() {
return this.#contextId;
}
async connect() {
log.debug('CdpBridge options:', this.options);
await super.connect();
const contextHandler = this.#getContextIdHandler();
await this.send('Runtime.enable');
await this.send('Runtime.disable');
this.#contextId = await contextHandler;
await this.send('Runtime.evaluate', {
expression: getInitializeScript(),
includeCommandLineAPI: true,
replMode: true,
contextId: this.#contextId,
});
}
#getContextIdHandler() {
return new Promise((resolve, reject) => {
this.on('Runtime.executionContextCreated', (params) => {
if (params.context.auxData.isDefault) {
resolve(params.context.id);
}
});
setTimeout(() => {
const err = new Error('Timeout exceeded to get the ContextId.');
log.error(err.message);
reject(err);
}, this.options.timeout);
});
}
}
function getInitializeScript() {
const scripts = [
// Add __name to the global object to work around issue with function serialization
// This enables browser.execute to work with scripts which declare functions (affects TS specs only)
// https://github.com/webdriverio-community/wdio-electron-service/issues/756
// https://github.com/privatenumber/tsx/issues/113
`globalThis.__name = globalThis.__name ?? ((func) => func);`,
// Add electron to the global object
`globalThis.electron = require('electron');`,
];
// add because windows is not exposed the process object to global scope
if (os.type().match('Windows')) {
scripts.push(`globalThis.process = require('node:process');`);
}
return scripts.join('\n');
}
async function before(capabilities, instance) {
this.browser = instance;
const cdpBridge = this.browser.isMultiremote ? undefined : await initCdpBridge(this.cdpOptions, capabilities);
/**
* Add electron API to browser object
*/
this.browser.electron = getElectronAPI.call(this, this.browser, cdpBridge);
if (isMultiremote(instance)) {
const mrBrowser = instance;
for (const instance of mrBrowser.instances) {
const mrInstance = mrBrowser.getInstance(instance);
const caps = mrInstance.requestedCapabilities.alwaysMatch ||
mrInstance.requestedCapabilities;
if (!caps[constants.CUSTOM_CAPABILITY_NAME]) {
continue;
}
log.debug('Adding Electron API to browser object instance named: ', instance);
const mrCdpBridge = await initCdpBridge(this.cdpOptions, caps);
mrInstance.electron = getElectronAPI.call(this, mrInstance, mrCdpBridge);
const mrPuppeteer = await getPuppeteer(mrInstance);
mrInstance.electron.windowHandle = await getActiveWindowHandle(mrPuppeteer);
// wait until an Electron BrowserWindow is available
await waitUntilWindowAvailable(mrInstance);
await copyOriginalApi(mrInstance);
}
}
else {
const puppeteer = await getPuppeteer(this.browser);
this.browser.electron.windowHandle = await getActiveWindowHandle(puppeteer);
// wait until an Electron BrowserWindow is available
await waitUntilWindowAvailable(this.browser);
await copyOriginalApi(this.browser);
}
}
function isMultiremote(browser) {
return browser.isMultiremote;
}
async function initCdpBridge(cdpOptions, capabilities) {
const options = Object.assign({}, cdpOptions, getDebuggerEndpoint(capabilities));
const cdpBridge = new ElectronCdpBridge(options);
await cdpBridge.connect();
return cdpBridge;
}
const waitUntilWindowAvailable = async (browser) => {
await browser.waitUntil(async () => {
const numWindows = (await browser.getWindowHandles()).length;
return numWindows > 0;
});
};
const copyOriginalApi = async (browser) => {
await browser.electron.execute(async (electron) => {
const { default: copy } = await Promise.resolve().then(function () { return require('./index-CxQA7zL-.js'); });
globalThis.originalApi = {};
for (const api in electron) {
const apiName = api;
globalThis.originalApi[apiName] = {};
for (const apiElement in electron[apiName]) {
const apiElementName = apiElement;
globalThis.originalApi[apiName][apiElementName] = copy(electron[apiName][apiElementName]);
}
}
}, { internal: true });
};
function getElectronAPI(browser, cdpBridge) {
const api = {
clearAllMocks: clearAllMocks.bind(this),
execute: (script, ...args) => execute.apply(this, [browser, cdpBridge, script, ...args]),
isMockFunction: isMockFunction.bind(this),
mock: mock.bind(this),
mockAll: mockAll.bind(this),
resetAllMocks: resetAllMocks.bind(this),
restoreAllMocks: restoreAllMocks.bind(this),
};
return Object.assign({}, api);
}
// TODO: This file should be remove at V9
/* v8 ignore start */
const yellow = '\u001b[33m';
const reset = '\u001b[0m';
const LINE00 = '* WARNING -------------------------------------------------------------------- *';
const LINE01 = '| You can remove importing main/preload scripts provided by this service. |';
const LINE02 = '| Because the wdio-electron-service no longer requires the IPC-Bridge. |';
const LINE03 = '| Those scripts will be completely removed with v9. |';
const LINE05 = '* ---------------------------------------------------------------------------- *';
const LINE11 = '| The IPC-Bridge is deprecated. |';
const LINE12 = '| Please consider migrating to the `CDP-Bridge` |';
const LINE13 = '| by changing the value of `useCdpBridge` to `true` or removing it. |';
const LINE14 = '| The legacy IPC-Bridge and related functionality will be removed with v9. |';
const colourise = (str) => {
if (str === LINE00 || str === LINE05) {
return `${yellow}${str}${reset}`;
}
else {
return str.replace(/\|/g, `${yellow}|${reset}`);
}
};
async function isActive(browser) {
return await browser.execute(function executeWithinElectron() {
return window.wdioElectron !== undefined;
});
}
async function ipcBridgeCheck(browser) {
const result = browser.isMultiremote
? (await Promise.all(browser.instances.map(async (mrBrowser) => await isActive(browser.getInstance(mrBrowser))))).filter((result) => result).length > 0
: await isActive(browser);
if (result) {
// for the log file
log.warn(LINE00);
log.warn(LINE01);
log.warn(LINE02);
log.warn(LINE03);
log.warn(LINE05);
// for the console
console.log(colourise(LINE00));
console.log(colourise(LINE01));
console.log(colourise(LINE02));
console.log(colourise(LINE03));
console.log(colourise(LINE05));
console.log();
}
}
function ipcBridgeWarning() {
// for the log file
log.warn(LINE00);
log.warn(LINE11);
log.warn(LINE12);
log.warn(LINE13);
log.warn(LINE14);
log.warn(LINE05);
// for the console
console.log(colourise(LINE00));
console.log(colourise(LINE11));
console.log(colourise(LINE12));
console.log(colourise(LINE13));
console.log(colourise(LINE14));
console.log(colourise(LINE05));
console.log();
}
/* v8 ignore stop */
const isBridgeActive = async (browser) => await browser.execute(function executeWithinElectron() {
return window.wdioElectron !== undefined;
});
const initSerializationWorkaround = async (browser) => {
// Add __name to the global object to work around issue with function serialization
// This enables browser.execute to work with scripts which declare functions (affects TS specs only)
// https://github.com/webdriverio-community/wdio-electron-service/issues/756
// https://github.com/privatenumber/tsx/issues/113
await browser.execute(() => {
globalThis.__name = globalThis.__name ?? ((func) => func);
});
await browser.electron.execute(() => {
globalThis.__name = globalThis.__name ?? ((func) => func);
});
};
const isInternalCommand = (args) => Boolean(args.at(-1)?.internal);
class ElectronWorkerService extends ServiceConfig {
constructor(globalOptions = {}, capabilities, _config) {
super(globalOptions, capabilities);
}
#getElectronAPI(browserInstance) {
const browser = (browserInstance || this.browser);
const api = {
clearAllMocks: clearAllMocks.bind(this),
execute: (script, ...args) => execute$1.apply(this, [browser, script, ...args]),
isMockFunction: isMockFunction.bind(this),
mock: mock.bind(this),
mockAll: mockAll.bind(this),
resetAllMocks: resetAllMocks.bind(this),
restoreAllMocks: restoreAllMocks.bind(this),
};
return Object.assign({}, api);
}
async before(capabilities, _specs, instance) {
if (this.useCdpBridge) {
log.debug('Using CDP bridge');
await ipcBridgeCheck(instance);
await before.call(this, capabilities, instance);
return;
}
log.debug('Using IPC bridge');
this.browser = instance;
/**
* Add electron API to browser object
*/
this.browser.electron = this.#getElectronAPI();
this.browser.electron.bridgeActive = await isBridgeActive(this.browser);
if (this.browser.electron.bridgeActive) {
await initSerializationWorkaround(this.browser);
}
if (this.browser.isMultiremote) {
const mrBrowser = instance;
for (const instance of mrBrowser.instances) {
const mrInstance = mrBrowser.getInstance(instance);
const caps = mrInstance.requestedCapabilities.alwaysMatch ||
mrInstance.requestedCapabilities;
if (!caps[constants.CUSTOM_CAPABILITY_NAME]) {
continue;
}
log.debug('Adding Electron API to browser object instance named: ', instance);
mrInstance.electron = this.#getElectronAPI(mrInstance);
const mrPuppeteer = await getPuppeteer(mrInstance);
mrInstance.electron.windowHandle = await getActiveWindowHandle(mrPuppeteer);
mrInstance.electron.bridgeActive = await isBridgeActive(mrInstance);
if (mrInstance.electron.bridgeActive) {
await initSerializationWorkaround(mrInstance);
}
// wait until an Electron BrowserWindow is available
await waitUntilWindowAvailable(mrInstance);
}
}
else {
const puppeteer = await getPuppeteer(this.browser);
this.browser.electron.windowHandle = await getActiveWindowHandle(puppeteer);
// wait until an Electron BrowserWindow is available
await waitUntilWindowAvailable(this.browser);
}
}
async beforeTest() {
if (this.clearMocks) {
await clearAllMocks();
}
if (this.resetMocks) {
await resetAllMocks();
}
if (this.restoreMocks) {
await restoreAllMocks();
}
}
async beforeCommand(commandName, args) {
const excludeCommands = ['getWindowHandle', 'getWindowHandles', 'switchToWindow', 'execute'];
if (!this.browser || excludeCommands.includes(commandName) || isInternalCommand(args)) {
return;
}
await ensureActiveWindowFocus(this.browser, commandName);
}
async afterCommand(commandName, args) {
// ensure mocks are updated
const mocks = mockStore.getMocks();
// White list of command which will input user actions to electron app.
const inputCommands = [
'addValue',
'clearValue',
'click',
'doubleClick',
'dragAndDrop',
'execute',
'executeAsync',
'moveTo',
'scrollIntoView',
'selectByAttribute',
'selectByIndex',
'selectByVisibleText',
'setValue',
'touchAction',
'action',
'actions',
'emulate',
'keys',
'scroll',
'setWindowSize',
'uploadFile',
];
if (inputCommands.includes(commandName) && mocks.length > 0 && !isInternalCommand(args)) {
await Promise.all(mocks.map(async ([_mockId, mock]) => await mock.update()));
}
}
after() {
clearPuppeteerSessions();
}
}
function getChromeOptions(options, cap) {
const existingOptions = cap['goog:chromeOptions'] || {};
return {
binary: options.appBinaryPath,
windowTypes: ['app', 'webview'],
...existingOptions,
args: [...(existingOptions.args || []), ...(options.appArgs || [])],
};
}
function getChromedriverOptions(cap) {
const existingOptions = cap['wdio:chromedriverOptions'] || {};
return existingOptions;
}
const isElectron = (cap) => cap?.browserName?.toLowerCase() === 'electron';
const isConvertedElectron = (cap) => {
return constants.CUSTOM_CAPABILITY_NAME in (cap || {});
};
function getElectronCapabilities(caps) {
return getCapabilities(caps, isElectron);
}
function getConvertedElectronCapabilities(caps) {
return getCapabilities(caps, isConvertedElectron);
}
/**
* Get capability independent of which type of capabilities is set
*/
function getCapabilities(caps, filter) {
/**
* Standard capabilities, e.g.:
* ```
* {
* browserName: 'chrome'
* }
* ```
*/
const standardCaps = caps;
if (typeof standardCaps.browserName === 'string' && filter(standardCaps)) {
return [caps];
}
/**
* W3C specific capabilities, e.g.:
* ```
* {
* alwaysMatch: {
* browserName: 'chrome'
* }
* }
* ```
*/
const w3cCaps = caps.alwaysMatch;
if (w3cCaps && typeof w3cCaps.browserName === 'string' && filter(w3cCaps)) {
return [w3cCaps];
}
/**
* Multiremote capabilities, e.g.:
* ```
* {
* instanceA: {
* capabilities: {
* browserName: 'chrome'
* }
* },
* instanceB: {
* capabilities: {
* browserName: 'chrome'
* }
* }
* }
* ```
*/
return Object.values(caps)
.map((options) => options.capabilities?.alwaysMatch ||
options.capabilities)
.filter((caps) => filter(caps));
}
const electronChromiumVersionMap = {};
const getChromiumVersion = async (electronVersion) => {
log.debug('Updating Electron - Chromium version map...');
try {
// get the electron releases list and construct the version map
const body = await fetch('https://electronjs.org/headers/index.json');
const allElectronVersions = (await body.json());
allElectronVersions
.sort(({ version: a }, { version: b }) => compareVersions.compareVersions(a, b))
.forEach(({ chrome, version }) => {
electronChromiumVersionMap[version] = chrome;
});
return electronChromiumVersionMap[electronVersion];
}
catch (e) {
// fall back to the locally installed electron-to-chromium version map
log.debug('Map update failed: ', e);
log.debug('Falling back to locally installed map...');
return electronToChromium.fullVersions[electronVersion];
}
};
class ElectronLaunchService {
#globalOptions;
#projectRoot;
constructor(globalOptions, _caps, config) {
this.#globalOptions = globalOptions;
this.#projectRoot = globalOptions.rootDir || config.rootDir || process.cwd();
if (typeof globalOptions.useCdpBridge === 'boolean' && !globalOptions.useCdpBridge) {
ipcBridgeWarning();
}
}
async onPrepare(_config, capabilities) {
const capsList = Array.isArray(capabilities)
? capabilities
: Object.values(capabilities).map((multiremoteOption) => multiremoteOption.capabilities);
const caps = capsList.flatMap((cap) => getElectronCapabilities(cap));
const pkg = (await readPackageUp.readPackageUp({ cwd: this.#projectRoot })) ||
{ packageJson: { dependencies: {}, devDependencies: {} } };
if (!caps.length) {
const noElectronCapabilityError = new Error('No Electron browser found in capabilities');
log.error(noElectronCapabilityError);
throw noElectronCapabilityError;
}
const localElectronVersion = await electronUtils.getElectronVersion(pkg);
await Promise.all(caps.map(async (cap) => {
const electronVersion = cap.browserVersion || localElectronVersion || '';
const chromiumVersion = await getChromiumVersion(electronVersion);
log.info(`Found Electron v${electronVersion} with Chromedriver v${chromiumVersion}`);
if (Number.parseInt(electronVersion.split('.')[0]) < 26 && !cap['wdio:chromedriverOptions']?.binary) {
const invalidElectronVersionError = new webdriverio.SevereServiceError('Electron version must be 26 or higher for auto-configuration of Chromedriver. If you want to use an older version of Electron, you must configure Chromedriver manually using the wdio:chromedriverOptions capability');
log.error(invalidElectronVersionError.message);
throw invalidElectronVersionError;
}
let { appBinaryPath, appEntryPoint, appArgs = ['--no-sandbox'], } = Object.assign({}, this.#globalOptions, cap[constants.CUSTOM_CAPABILITY_NAME]);
if (appEntryPoint) {
if (appBinaryPath) {
log.warn('Both appEntryPoint and appBinaryPath are set, appBinaryPath will be ignored');
}
const electronBinary = process.platform === 'win32' ? 'electron.CMD' : 'electron';
appBinaryPath = path.join(this.#projectRoot, 'node_modules', '.bin', electronBinary);
appArgs = [`--app=${appEntryPoint}`, ...appArgs];
log.debug('App entry point: ', appEntryPoint, appBinaryPath, appArgs);
}
else if (!appBinaryPath) {
log.info('No app binary specified, attempting to detect one...');
try {
const appBuildInfo = await electronUtils.getAppBuildInfo(pkg);
try {
appBinaryPath = await electronUtils.getBinaryPath(pkg.path, appBuildInfo, electronVersion);
log.info(`Detected app binary at ${appBinaryPath}`);
}
catch (_e) {
const buildToolName = appBuildInfo.isForge ? 'Electron Forge' : 'electron-builder';
const suggestedCompileCommand = `npx ${appBuildInfo.isForge ? 'electron-forge make' : 'electron-builder build'}`;
throw new Error(util.format(constants.APP_NOT_FOUND_ERROR, appBinaryPath, buildToolName, suggestedCompileCommand));
}
}
catch (e) {
log.error(e);
throw new webdriverio.SevereServiceError(e.message);
}
}
cap.browserName = 'chrome';
cap['goog:chromeOptions'] = getChromeOptions({ appBinaryPath, appArgs }, cap);
// disable WebDriver Bidi session
cap['wdio:enforceWebDriverClassic'] = true;
const chromedriverOptions = getChromedriverOptions(cap);
if (!chromiumVersion && Object.keys(chromedriverOptions).length > 0) {
cap['wdio:chromedriverOptions'] = chromedriverOptions;
}
const browserVersion = chromiumVersion || cap.browserVersion;
if (browserVersion) {
cap.browserVersion = browserVersion;
}
else if (!cap['wdio:chromedriverOptions']?.binary) {
const invalidBrowserVersionOptsError = new Error('You must install Electron locally, or provide a custom Chromedriver path / browserVersion value for each Electron capability');
log.error(invalidBrowserVersionOptsError);
throw invalidBrowserVersionOptsError;
}
/**
* attach custom capability to be able to identify Electron instances
* in the worker process
*/
cap[constants.CUSTOM_CAPABILITY_NAME] = cap[constants.CUSTOM_CAPABILITY_NAME] || {};
log.debug('Setting capability at onPrepare', cap);
})).catch((err) => {
const msg = `Failed setting up Electron session: ${err.stack}`;
log.error(msg);
throw new webdriverio.SevereServiceError(msg);
});
}
/**
* Assigns unique debugging ports to each Electron instance to prevent port conflicts
* when running multiple Electron instances concurrently.
*
* This method runs at the beginning of each worker process and:
* 1. Dynamically finds available ports using get-port
* 2. Adds the --inspect flag with the assigned port to each Electron instance
* 3. Ensures each Electron instance has a unique debugging port
*
* This allows for reliable parallel debugging of multiple Electron instances.
*/
async onWorkerStart(_cid, capabilities) {
try {
const capsList = Array.isArray(capabilities) ? capabilities : [capabilities];
const caps = capsList.flatMap((cap) => getConvertedElectronCapabilities(cap));
const portList = await getDebuggerPorts(caps.length);
await Promise.all(caps.map(async (cap, index) => {
setInspectArg(cap, portList[index]);
}));
log.debug('Setting capability at onWorkerStart', caps);
}
catch (error) {
const errorMessage = error instanceof Error ? error.stack || error.message : String(error);
const msg = `Failed to assign debugging ports to Electron instances: ${errorMessage}`;
log.error(msg);
throw new webdriverio.SevereServiceError(msg);
}
}
}
/**
* Dynamically allocates available ports for Electron debugger instances
*
* @param quantity Number of ports needed (one per Electron instance)
* @returns Array of available port numbers
*/
const getDebuggerPorts = async (quantity) => {
return Promise.all(Array.from({ length: quantity }, () => getPort()));
};
/**
* Configures an Electron capability with the necessary debugging arguments
* by adding the --inspect flag with the assigned port to chrome options
*
* @param cap WebdriverIO capability to modify
* @param debuggerPort Port number to use for the Node inspector
*/
const setInspectArg = (cap, debuggerPort) => {
if (!('goog:chromeOptions' in cap)) {
cap['goog:chromeOptions'] = { args: [] };
}
const chromeOptions = cap['goog:chromeOptions'];
if (!('args' in chromeOptions)) {
chromeOptions.args = [];
}
chromeOptions.args.push(`--inspect=localhost:${debuggerPort}`);
};
async function init(capabilities, globalOptions) {
const testRunnerOpts = globalOptions?.rootDir ? { rootDir: globalOptions.rootDir } : {};
const launcher = new ElectronLaunchService(globalOptions || {}, capabilities, testRunnerOpts);
await launcher.onPrepare(testRunnerOpts, capabilities);
await launcher.onWorkerStart('', capabilities);
log.debug('Session capabilities:', JSON.stringify(capabilities, null, 2));
const capability = Array.isArray(capabilities) ? capabilities[0] : capabilities;
const service = new ElectronWorkerService(globalOptions, capability);
// initialise session
const browser = await webdriverio.remote({
capabilities: capability,
});
await service.before(capability, [], browser);
return browser;
}
const launcher = ElectronLaunchService;
const browser$1 = globals.browser;
const startWdioSession = init;
exports.browser = browser$1;
exports.default = ElectronWorkerService;
exports.launcher = launcher;
exports.startWdioSession = startWdioSession;
//# sourceMappingURL=index.js.map