UNPKG

wdio-electron-service

Version:

WebdriverIO service to enable Electron testing

1,088 lines (1,061 loc) 44.4 kB
'use strict'; 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