diff --git a/lib/commands/find.js b/lib/commands/find.js index 4dc084b30..ad59c0052 100644 --- a/lib/commands/find.js +++ b/lib/commands/find.js @@ -89,8 +89,8 @@ export default { if (rewroteSelector) { this.log.info( `Rewrote incoming selector from '${initSelector}' to ` + - `'${selector}' to match XCUI type. You should consider ` + - `updating your tests to use the new selectors directly`, + `'${selector}' to match XCUI type. You should consider ` + + `updating your tests to use the new selectors directly`, ); } @@ -122,7 +122,10 @@ export default { els = /** @type {Element[]|undefined} */ ( await this.proxyCommand(endpoint, method, body) ); - } catch { + } catch (err) { + if (err.message && err.message.match(/socket hang up/)) { + throw err; + } els = []; } // we succeed if we get some elements @@ -132,6 +135,9 @@ export default { if (err.message && err.message.match(/Condition unmet/)) { // condition was not met setting res to empty array els = []; + } + else if (err.message && err.message.match(/socket hang up/)) { + throw err; } else { throw err; } @@ -193,7 +199,7 @@ function rewriteMagicScrollable(mult, log = null) { } log?.info( 'Rewrote request for scrollable descendants to class chain ' + - `format with selector '${selector}'`, + `format with selector '${selector}'`, ); return [strategy, selector]; } diff --git a/lib/desired-caps.js b/lib/desired-caps.js index 5c5666156..807bb3b52 100644 --- a/lib/desired-caps.js +++ b/lib/desired-caps.js @@ -383,7 +383,11 @@ const desiredCapConstraints = /** @type {const} */ ({ pageLoadStrategy: { isString: true, inclusionCaseInsensitive: ['none', 'eager', 'normal'] - } + }, + enableWdaRestart: { + presence: false, + isBoolean: true, + }, }); export {desiredCapConstraints, PLATFORM_NAME_IOS, PLATFORM_NAME_TVOS}; diff --git a/lib/driver.js b/lib/driver.js index 568bc9741..868bfacac 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -325,6 +325,10 @@ export class XCUITestDriver extends BaseDriver { this.appInfosCache = new AppInfosCache(this.log); this.remote = null; this.doesSupportBidi = true; + this._isRecovering = false; + if (_.isNil(this.opts.enableWdaRestart)) { + this.opts.enableWdaRestart = false; + } } async onSettingsUpdate(key, value) { @@ -515,15 +519,15 @@ export class XCUITestDriver extends BaseDriver { if (_.isUndefined(this.opts.mjpegServerPort)) { this.log.warn( `Cannot forward the device port ${DEFAULT_MJPEG_SERVER_PORT} to the local port ${DEFAULT_MJPEG_SERVER_PORT}. ` + - `Certain features, like MJPEG-based screen recording, will be unavailable during this session. ` + - `Try to customize the value of 'mjpegServerPort' capability as a possible solution`, + `Certain features, like MJPEG-based screen recording, will be unavailable during this session. ` + + `Try to customize the value of 'mjpegServerPort' capability as a possible solution`, ); } else { this.log.debug(error.stack); throw new Error( `Cannot ensure MJPEG broadcast functionality by forwarding the local port ${mjpegServerPort} ` + - `requested by the 'mjpegServerPort' capability to the device port ${mjpegServerPort}. ` + - `Original error: ${error}`, + `requested by the 'mjpegServerPort' capability to the device port ${mjpegServerPort}. ` + + `Original error: ${error}`, ); } } @@ -689,7 +693,7 @@ export class XCUITestDriver extends BaseDriver { if (_.isBoolean(this.opts.calendarAccessAuthorized)) { this.log.warn( `The 'calendarAccessAuthorized' capability is deprecated and will be removed soon. ` + - `Consider using 'permissions' one instead with 'calendar' key`, + `Consider using 'permissions' one instead with 'calendar' key`, ); const methodName = `${ this.opts.calendarAccessAuthorized ? 'enable' : 'disable' @@ -811,7 +815,7 @@ export class XCUITestDriver extends BaseDriver { if (SHARED_RESOURCES_GUARD.isBusy() && !this.opts.derivedDataPath && !this.opts.bootstrapPath) { this.log.debug( `Consider setting a unique 'derivedDataPath' capability value for each parallel driver instance ` + - `to avoid conflicts and speed up the building process`, + `to avoid conflicts and speed up the building process`, ); } @@ -888,7 +892,7 @@ export class XCUITestDriver extends BaseDriver { await this.preparePreinstalledWda(); } - this.cachedWdaStatus = await this.wda.launch(/** @type {string} */ (this.sessionId)); + this.cachedWdaStatus = await this.wda.launch(/** @type {string} */(this.sessionId)); } catch (err) { this.logEvent('wdaStartFailed'); this.log.debug(err.stack); @@ -902,7 +906,7 @@ export class XCUITestDriver extends BaseDriver { // In case the bundle id process start got failed because of // auth popup in the device. Then, the bundle id process itself started. It is safe to stop it here. await this.mobileKillApp(this.wda.bundleIdForXctest); - } catch {}; + } catch { }; // Mostly it failed to start the WDA process as no the bundle id // e.g. ' not found on device ' @@ -1075,10 +1079,131 @@ export class XCUITestDriver extends BaseDriver { } /** - * - * @param {string} cmd - * @param {...any} args - * @returns {Promise} + * Checks if an error is related to WDA connection issues + * @param {Error|null|undefined} err - The error to check + * @returns {boolean} - True if the error is a WDA connection error + */ + isWdaConnectionError(err) { + if (!err) { + return false; + } + const errorMessage = err.message || ''; + const CONNECTION_ERRORS = ['ECONNREFUSED', 'socket hang up']; + + return CONNECTION_ERRORS.some((errStr) => errorMessage.includes(errStr)); + } + + /** + * Reconnects to WDA within a specified timeout + * @returns {Promise} - The WDA status + * @throws {Error} - If WDA does not become reachable within the timeout + */ + async reconnectWda() { + const timeout = this.opts.wdaConnectionTimeout || 15000; + const interval = this.opts.wdaStartupRetryInterval || 1000; + const startTime = Date.now(); + + this.log.info(`Attempting to reconnect to WDA with ${timeout}ms timeout`); + + while (Date.now() - startTime < timeout) { + try { + const isRunning = await this.wda.isRunning(); + if (isRunning) { + this.log.info(`WDA is now reachable with status: ${isRunning}}`); + return isRunning; + } else { + this.log.debug(`WDA still not reachable, status: ${isRunning}. Retrying in ${interval}ms...`); + await this.delay(interval); + } + } catch (err) { + this.log.debug(`WDA still not reachable: ${err.message}. Retrying in ${interval}ms...`); + await this.delay(interval); + } + } + + throw new Error(`Failed to reconnect to WDA within ${timeout}ms`); + } + + /** + * Creates a promise that resolves after the specified time + * @param {number} ms - Delay time in milliseconds + * @returns {Promise} + */ + async delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Reinitializes the WDA session + * @returns {Promise} - The session ID + * @throws {Error} - If session reinitialization fails + */ + async reinitializeSession() { + this.log.info('Reinitializing WDA session mapping...'); + const originalSessionId = this.sessionId; + + try { + const isRunning = await this.wda.isRunning(); + + if (!isRunning) { + this.log.info('WDA process is not running. Restarting WDA process...'); + if (this.wda?.fullyStarted) { + try { + await this.wda.quit(); + } catch (quitErr) { + this.log.warn(`Error quitting stale WDA: ${quitErr.message}`); + } + } + await this.startWda(); + } else { + this.log.info('WDA process is still running'); + } + + await this.startWdaSession(this.opts.bundleId, this.opts.processArguments); + this.proxyReqRes = this.wda.proxyReqRes.bind(this.wda); + this.jwpProxyActive = true; + this.sessionId = originalSessionId; + + this.log.info(`WDA session reinitialized with ID: ${this.sessionId}`); + await this.restoreContext(); + + if (!this.sessionId) { + throw new Error('Session ID is unexpectedly null after reinitialization'); + } + + this.log.info('WDA session reinitialization completed successfully'); + return this.sessionId; + } catch (err) { + this.log.error(`Failed to reinitialize WDA session: ${err.message}`); + throw err; + } finally { + this._isRecovering = false; + } + } + + /** + * Attempts to restore the previous webview context + * @private + * @returns {Promise} + */ + async restoreContext() { + if (this._currentContext && this._currentContext !== 'NATIVE_APP') { + try { + this.log.info(`Attempting to restore webview context: ${this._currentContext}`); + await this.activateRecentWebview(); + } catch (contextErr) { + this.log.warn(`Failed to restore webview context: ${contextErr.message}`); + this._currentContext = 'NATIVE_APP'; + } + } + } + + /** + * Executes a command with automatic WDA recovery on failure + * @param {string} cmd - Command to execute + * @param {...any} args - Command arguments + * @returns {Promise} - Command result + * @throws {Error} - If command execution fails after recovery attempts */ async executeCommand(cmd, ...args) { this.log.debug(`Executing command '${cmd}'`); @@ -1090,7 +1215,100 @@ export class XCUITestDriver extends BaseDriver { if (cmd === 'getStatus') { return await this.getStatus(); } - return await super.executeCommand(cmd, ...args); + + if (cmd === 'createSession') { + return await super.executeCommand(cmd, ...args); + } + + const wdaRestartEnabled = Boolean(this.opts.enableWdaRestart); + + if (!wdaRestartEnabled) { + return await super.executeCommand(cmd, ...args); + } + + const maxAttempts = this.opts.wdaStartupRetries || 2; + let attempts = 0; + + while (true) { + try { + return await super.executeCommand(cmd, ...args); + } catch (err) { + this.log.debug(`Error executing command '${cmd}': ${err.message}`); + attempts++; + + const isWdaCrash = this.isWdaCrashOrSessionInvalid(err); + + if (!isWdaCrash || attempts > maxAttempts) { + throw err; + } + + this._isRecovering = true; + this.log.warn(`WDA issue detected. Recovery attempt ${attempts}/${maxAttempts}...`); + + try { + await this.handleWdaRecovery(); + this.log.info('Recovery successful, retrying original command'); + } catch (recoveryErr) { + this.log.error(`Recovery attempt failed: ${recoveryErr.message}`); + if (attempts >= maxAttempts) { + throw new Error( + `Failed to recover WDA after ${maxAttempts} attempts: ${recoveryErr.message}`, + ); + } + } + } + } + } + + /** + * Checks if an error indicates a WDA crash or invalid session + * @private + * @param {Error} err - The error to check + * @returns {boolean} - True if the error indicates WDA crash or invalid session + */ + isWdaCrashOrSessionInvalid(err) { + if (this.isWdaConnectionError(err)) { + return true; + } + + return Boolean( + err.message && + (err.message.includes('invalid session id') || + err.message.includes('Session does not exist')), + ); + } + + /** + * Handles WDA recovery by attempting reconnection or reinitialization + * @private + * @returns {Promise} + */ + async handleWdaRecovery() { + try { + this.log.info('Attempting to reconnect to WDA...'); + await this.reconnectWda(); + + try { + // Verify session is still valid + // This is important because in case the WDA was not killed and it was only an intermittent connection issue, + // and the session is still valid then this would prevent us restarting it unnecesarilly + await this.proxyCommand(`/session/${this.sessionId}/url`, 'GET'); + this.log.info('WDA connection restored, session still valid'); + } catch (sessionErr) { + if (this.isWdaCrashOrSessionInvalid(sessionErr)) { + this.log.info('WDA session is invalid, reinitializing session...'); + await this.reinitializeSession(); + } else { + throw sessionErr; + } + } + } catch (reconnectErr) { + this.log.warn(`Reconnection failed: ${reconnectErr.message}`); + this.log.info('Attempting full WDA reinitialization...'); + await this.reinitializeSession(); + } finally { + this._isRecovering = false; + } } async configureApp() { @@ -1145,7 +1363,7 @@ export class XCUITestDriver extends BaseDriver { if (!this.opts.platformVersion && this._iosSdkVersion) { this.log.info( `No platformVersion specified. Using the latest version Xcode supports: '${this._iosSdkVersion}'. ` + - `This may cause problems if a simulator does not exist for this platform version.`, + `This may cause problems if a simulator does not exist for this platform version.`, ); this.opts.platformVersion = normalizePlatformVersion(this._iosSdkVersion); } @@ -1208,7 +1426,7 @@ export class XCUITestDriver extends BaseDriver { this.log.info( `No real device udid has been provided in capabilities. ` + - `Will select a matching simulator to run the test.`, + `Will select a matching simulator to run the test.`, ); await setupVersionCaps(); if (this.opts.enforceFreshSimulatorCreation) { @@ -1282,14 +1500,14 @@ export class XCUITestDriver extends BaseDriver { if (!_.isArray(args)) { throw new Error( `processArguments.args capability is expected to be an array. ` + - `${JSON.stringify(args)} is given instead`, + `${JSON.stringify(args)} is given instead`, ); } const env = processArguments ? _.cloneDeep(processArguments.env) || {} : {}; if (!_.isPlainObject(env)) { throw new Error( `processArguments.env capability is expected to be a dictionary. ` + - `${JSON.stringify(env)} is given instead`, + `${JSON.stringify(env)} is given instead`, ); } @@ -1331,8 +1549,8 @@ export class XCUITestDriver extends BaseDriver { // @ts-expect-error - do not assign arbitrary properties to `this.opts` elementResponseFields: this.opts.elementResponseFields, disableAutomaticScreenshots: this.opts.disableAutomaticScreenshots, - shouldTerminateApp: this.opts.shouldTerminateApp ?? true, - forceAppLaunch: this.opts.forceAppLaunch ?? true, + shouldTerminateApp: this._isRecovering ? false : (this.opts.shouldTerminateApp ?? true), + forceAppLaunch: this._isRecovering ? false : (this.opts.forceAppLaunch ?? true), appLaunchStateTimeoutSec: this.opts.appLaunchStateTimeoutSec, useNativeCachingStrategy: this.opts.useNativeCachingStrategy ?? true, forceSimulatorSoftwareKeyboardPresence: @@ -1416,14 +1634,14 @@ export class XCUITestDriver extends BaseDriver { if (_.toLower(caps.browserName) !== 'safari' && !caps.app && !caps.bundleId) { this.log.info( 'The desired capabilities include neither an app nor a bundleId. ' + - 'WebDriverAgent will be started without the default app', + 'WebDriverAgent will be started without the default app', ); } if (!util.coerceVersion(String(caps.platformVersion), false)) { this.log.warn( `'platformVersion' capability ('${caps.platformVersion}') is not a valid version number. ` + - `Consider fixing it or be ready to experience an inconsistent driver behavior.`, + `Consider fixing it or be ready to experience an inconsistent driver behavior.`, ); } @@ -1449,7 +1667,7 @@ export class XCUITestDriver extends BaseDriver { } catch (err) { throw this.log.errorWithException( `processArguments must be a JSON format or an object with format {args : [], env : {a:b, c:d}}. ` + - `Both environment and argument can be null. Error: ${err}`, + `Both environment and argument can be null. Error: ${err}`, ); } } else if (_.isPlainObject(caps.processArguments)) { @@ -1457,7 +1675,7 @@ export class XCUITestDriver extends BaseDriver { } else { throw this.log.errorWithException( `'processArguments must be an object, or a string JSON object with format {args : [], env : {a:b, c:d}}. ` + - `Both environment and argument can be null.`, + `Both environment and argument can be null.`, ); } } @@ -1486,7 +1704,7 @@ export class XCUITestDriver extends BaseDriver { if (_.isEmpty(protocol) || _.isEmpty(host)) { throw this.log.errorWithException( `'webDriverAgentUrl' capability is expected to contain a valid WebDriverAgent server URL. ` + - `'${caps.webDriverAgentUrl}' is given instead`, + `'${caps.webDriverAgentUrl}' is given instead`, ); } } @@ -1519,7 +1737,7 @@ export class XCUITestDriver extends BaseDriver { } catch (e) { throw this.log.errorWithException( `'${caps.permissions}' is expected to be a valid object with format ` + - `{"": {"": "", ...}, ...}. Original error: ${e.message}`, + `{"": {"": "", ...}, ...}. Original error: ${e.message}`, ); } } @@ -1527,7 +1745,7 @@ export class XCUITestDriver extends BaseDriver { if (caps.platformVersion && !util.coerceVersion(caps.platformVersion, false)) { throw this.log.errorWithException( `'platformVersion' must be a valid version number. ` + - `'${caps.platformVersion}' is given instead.`, + `'${caps.platformVersion}' is given instead.`, ); } @@ -1605,12 +1823,12 @@ export class XCUITestDriver extends BaseDriver { if (shouldUpgrade) { this.log.info( `The installed version of ${bundleId} is lower than the candidate one ` + - `(${candidateBundleVersion} > ${appBundleVersion}). The app will be upgraded.`, + `(${candidateBundleVersion} > ${appBundleVersion}). The app will be upgraded.`, ); } else { this.log.info( `The candidate version of ${bundleId} is lower than the installed one ` + - `(${candidateBundleVersion} <= ${appBundleVersion}). The app won't be reinstalled.`, + `(${candidateBundleVersion} <= ${appBundleVersion}). The app won't be reinstalled.`, ); } return { @@ -1710,7 +1928,7 @@ export class XCUITestDriver extends BaseDriver { if (!SUPPORTED_ORIENATIONS.includes(dstOrientation)) { this.log.debug( `The initial orientation value '${orientation}' is unknown. ` + - `Only ${JSON.stringify(SUPPORTED_ORIENATIONS)} are supported.`, + `Only ${JSON.stringify(SUPPORTED_ORIENATIONS)} are supported.`, ); return; } @@ -1742,7 +1960,7 @@ export class XCUITestDriver extends BaseDriver { async reset() { throw new Error( `The reset API has been deprecated and is not supported anymore. ` + - `Consider using corresponding 'mobile:' extensions to manage the state of the app under test.`, + `Consider using corresponding 'mobile:' extensions to manage the state of the app under test.`, ); } diff --git a/test/unit/driver-specs.js b/test/unit/driver-specs.js index f13b17a22..39c213e00 100644 --- a/test/unit/driver-specs.js +++ b/test/unit/driver-specs.js @@ -460,4 +460,378 @@ describe('XCUITestDriver', function () { }); } }); + describe('executeCommand with WDA restart flow', function () { + let driver; + let superExecuteCommandStub; + let isWdaConnectionErrorStub; + let reconnectWdaStub; + let reinitializeSessionStub; + let proxyCommandStub; + let logStub; + + beforeEach(function () { + driver = new XCUITestDriver(); + driver.sessionId = 'test-session-id'; + + superExecuteCommandStub = sandbox.stub(); + sandbox.stub(XCUITestDriver.prototype, 'executeCommand').callsFake(function (cmd, ...args) { + return driver.executeCommand.wrappedMethod.call(this, cmd, ...args); + }); + + isWdaConnectionErrorStub = sandbox.stub(driver, 'isWdaConnectionError'); + reconnectWdaStub = sandbox.stub(driver, 'reconnectWda'); + reinitializeSessionStub = sandbox.stub(driver, 'reinitializeSession'); + proxyCommandStub = sandbox.stub(driver, 'proxyCommand'); + + logStub = { + debug: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }; + + sandbox.stub(driver, 'log').get(() => logStub); + Object.defineProperty(Object.getPrototypeOf(XCUITestDriver.prototype), 'executeCommand', { + value: superExecuteCommandStub, + writable: true, + }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should bypass recovery flow when enableWdaRestart is not set', async function () { + driver.opts = {enableWdaRestart: false}; + superExecuteCommandStub.resolves('success'); + + const result = await driver.executeCommand('test-command', 'arg1'); + + expect(result).to.equal('success'); + expect(superExecuteCommandStub.calledOnce).to.be.true; + expect(superExecuteCommandStub.calledWith('test-command', 'arg1')).to.be.true; + expect(reconnectWdaStub.called).to.be.false; + expect(reinitializeSessionStub.called).to.be.false; + }); + + it('should bypass recovery flow for special commands even with enableWdaRestart true', async function () { + driver.opts = {enableWdaRestart: true}; + driver.getStatus = sandbox.stub().resolves({status: 'ok'}); + await driver.executeCommand('getStatus'); + expect(driver.getStatus.calledOnce).to.be.true; + expect(superExecuteCommandStub.called).to.be.false; + driver.getStatus.reset(); + superExecuteCommandStub.resolves({sessionId: 'new-session'}); + await driver.executeCommand('createSession'); + expect(superExecuteCommandStub.calledWith('createSession')).to.be.true; + }); + + it('should attempt recovery when WDA connection error occurs and enableWdaRestart is true', async function () { + driver.opts = { + enableWdaRestart: true, + wdaStartupRetries: 2, + }; + + const connectionError = new Error('socket hang up'); + superExecuteCommandStub.onFirstCall().rejects(connectionError); + superExecuteCommandStub.onSecondCall().resolves('success after recovery'); + + isWdaConnectionErrorStub.withArgs(connectionError).returns(true); + reconnectWdaStub.resolves({status: 0}); + proxyCommandStub.resolves(); + + const result = await driver.executeCommand('findElement', 'accessibility id', 'submit'); + + expect(result).to.equal('success after recovery'); + expect(superExecuteCommandStub.callCount).to.equal(2); + expect(reconnectWdaStub.calledOnce).to.be.true; + expect(logStub.warn.calledWith(sandbox.match(/WDA issue detected/))).to.be.true; + expect(logStub.info.calledWith('Recovery successful, retrying original command')).to.be.true; + }); + + it('should reinitialize session when proxyCommand fails with invalid session error', async function () { + driver.opts = { + enableWdaRestart: true, + wdaStartupRetries: 2, + }; + + const connectionError = new Error('socket hang up'); + const sessionError = new Error('invalid session id'); + + superExecuteCommandStub.onFirstCall().rejects(connectionError); + superExecuteCommandStub.onSecondCall().resolves('success after recovery'); + + isWdaConnectionErrorStub.withArgs(connectionError).returns(true); + reconnectWdaStub.resolves({status: 0}); + proxyCommandStub.rejects(sessionError); + isWdaConnectionErrorStub.withArgs(sessionError).returns(false); + reinitializeSessionStub.resolves('new-session-id'); + + const result = await driver.executeCommand('findElement', 'accessibility id', 'submit'); + + expect(result).to.equal('success after recovery'); + expect(superExecuteCommandStub.callCount).to.equal(2); + expect(reconnectWdaStub.calledOnce).to.be.true; + expect(reinitializeSessionStub.calledOnce).to.be.true; + expect(logStub.info.calledWith(sandbox.match(/WDA session is invalid/))).to.be.true; + }); + + it('should skip directly to reinitializeSession when reconnectWda fails', async function () { + driver.opts = { + enableWdaRestart: true, + wdaStartupRetries: 2, + }; + + const connectionError = new Error('socket hang up'); + const reconnectError = new Error('reconnect failed'); + + superExecuteCommandStub.onFirstCall().rejects(connectionError); + superExecuteCommandStub.onSecondCall().resolves('success after recovery'); + + isWdaConnectionErrorStub.withArgs(connectionError).returns(true); + reconnectWdaStub.rejects(reconnectError); + reinitializeSessionStub.resolves('new-session-id'); + + const result = await driver.executeCommand('findElement', 'accessibility id', 'submit'); + + expect(result).to.equal('success after recovery'); + expect(superExecuteCommandStub.callCount).to.equal(2); + expect(reconnectWdaStub.calledOnce).to.be.true; + expect(reinitializeSessionStub.calledOnce).to.be.true; + expect(logStub.warn.calledWith(sandbox.match(/Reconnection failed/))).to.be.true; + expect(logStub.info.calledWith('Attempting full WDA reinitialization...')).to.be.true; + }); + + it('should give up after max attempts and throw the original error', async function () { + driver.opts = { + enableWdaRestart: true, + wdaStartupRetries: 2, + }; + + const connectionError = new Error('socket hang up'); + + superExecuteCommandStub.rejects(connectionError); + isWdaConnectionErrorStub.withArgs(connectionError).returns(true); + reconnectWdaStub.resolves({status: 0}); + proxyCommandStub.resolves(); + + try { + await driver.executeCommand('findElement', 'accessibility id', 'submit'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).to.equal(connectionError); + expect(superExecuteCommandStub.callCount).to.equal(3); // Initial + 2 retries + expect(reconnectWdaStub.callCount).to.equal(2); + expect(logStub.warn.calledWith(sandbox.match(/WDA issue detected. Recovery attempt 2\/2/))) + .to.be.true; + } + }); + + it('should not attempt recovery for non-connection errors when enableWdaRestart is true', async function () { + driver.opts = { + enableWdaRestart: true, + wdaStartupRetries: 2, + }; + + const nonConnectionError = new Error('element not found'); + superExecuteCommandStub.rejects(nonConnectionError); + isWdaConnectionErrorStub.withArgs(nonConnectionError).returns(false); + + try { + await driver.executeCommand('findElement', 'accessibility id', 'submit'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).to.equal(nonConnectionError); + expect(superExecuteCommandStub.calledOnce).to.be.true; + expect(reconnectWdaStub.called).to.be.false; + expect(reinitializeSessionStub.called).to.be.false; + } + }); + + it('should detect session invalid errors and treat them as recoverable', async function () { + driver.opts = { + enableWdaRestart: true, + wdaStartupRetries: 2, + }; + + const sessionError = new Error('Session does not exist'); + superExecuteCommandStub.onFirstCall().rejects(sessionError); + superExecuteCommandStub.onSecondCall().resolves('success after recovery'); + + isWdaConnectionErrorStub.withArgs(sessionError).returns(false); + + reconnectWdaStub.resolves({status: 0}); + proxyCommandStub.rejects(new Error('invalid session id')); + reinitializeSessionStub.resolves('new-session-id'); + + const result = await driver.executeCommand('findElement', 'accessibility id', 'submit'); + + expect(result).to.equal('success after recovery'); + expect(superExecuteCommandStub.callCount).to.equal(2); + expect(reinitializeSessionStub.calledOnce).to.be.true; + }); + }); + + describe('isWdaConnectionError', function () { + let driver; + + beforeEach(function () { + driver = new XCUITestDriver(); + }); + + it('should return false for null or undefined error', function () { + expect(driver.isWdaConnectionError(null)).to.be.false; + expect(driver.isWdaConnectionError(undefined)).to.be.false; + }); + + it('should return true for ECONNREFUSED error', function () { + const error = new Error('Failed to connect: ECONNREFUSED'); + expect(driver.isWdaConnectionError(error)).to.be.true; + }); + + it('should return true for socket hang up error', function () { + const error = new Error('socket hang up'); + expect(driver.isWdaConnectionError(error)).to.be.true; + }); + + it('should return false for non-connection errors', function () { + const error = new Error('element not found'); + expect(driver.isWdaConnectionError(error)).to.be.false; + }); + }); + + describe('XCUITestDriver - reconnectWda', function () { + let driver; + let sandbox; + let clock; + let logStub; + + beforeEach(function () { + sandbox = createSandbox(); + clock = sandbox.useFakeTimers(); + + driver = new XCUITestDriver(); + + driver.wda = { + isRunning: sandbox.stub(), + }; + + // Stub logging + logStub = { + debug: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }; + sandbox.stub(driver, 'log').get(() => logStub); + sandbox.stub(driver, 'delay').callsFake((ms) => { + clock.tick(ms); + return Promise.resolve(); + }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should successfully reconnect when WDA responds immediately', async function () { + driver.wda.isRunning.resolves(true); + + const result = await driver.reconnectWda(); + + expect(result).to.be.true; + expect(driver.wda.isRunning.calledOnce).to.be.true; + expect(logStub.info.calledWith(sandbox.match('WDA is now reachable'))).to.be.true; + }); + + it('should retry until successful within timeout period', async function () { + driver.wda.isRunning + .onFirstCall() + .resolves(false) + .onSecondCall() + .resolves(false) + .onThirdCall() + .resolves(true); + + const result = await driver.reconnectWda(); + + expect(result).to.be.true; + expect(driver.wda.isRunning.callCount).to.equal(3); + expect(driver.delay.callCount).to.be.at.least(2); + expect(logStub.info.calledWith(sandbox.match('WDA is now reachable'))).to.be.true; + }); + + it('should throw error if reconnection fails within timeout period', async function () { + driver.wda.isRunning.resolves(false); + const timeoutMs = 1000; + + await expect(driver.reconnectWda(timeoutMs)).to.be.rejectedWith( + /Failed to reconnect to WDA within.*ms/, + ); + expect(driver.wda.isRunning.called).to.be.true; + }); + }); + + describe('XCUITestDriver - reinitializeSession', function () { + let driver; + let sandbox; + let wdaStub; + let logStub; + let startWdaStub; + + beforeEach(function () { + sandbox = createSandbox(); + + driver = new XCUITestDriver(); + driver.wda = { + isRunning: sandbox.stub(), + }; + + logStub = { + debug: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }; + sandbox.stub(driver, 'log').get(() => logStub); + driver._isRecovering = true; + driver.opts = {bundleId: 'com.example'}; + + wdaStub = { + isRunning: sandbox.stub(), + quit: sandbox.stub().resolves(), + fullyStarted: true, + proxyReqRes: sandbox.stub(), + }; + driver.wda = wdaStub; + + startWdaStub = sandbox.stub(driver, 'startWda'); + sandbox.stub(driver, 'startWdaSession').resolves(); + sandbox.stub(driver, 'restoreContext').resolves(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should throw error if starting WDA fails and reset _isRecovering', async function () { + wdaStub.isRunning.resolves(false); + startWdaStub.rejects(new Error('Start WDA failed')); + + await expect(driver.reinitializeSession()).to.be.rejectedWith('Start WDA failed'); + expect(driver._isRecovering).to.be.false; + expect(logStub.error.calledWith(sandbox.match('Failed to reinitialize WDA session'))).to.be + .true; + expect(startWdaStub.calledOnce).to.be.true; + }); + + it('should reset _isRecovering even if isRunning throws an error', async function () { + wdaStub.isRunning.rejects(new Error('WDA check failed')); + + await expect(driver.reinitializeSession()).to.be.rejectedWith('WDA check failed'); + expect(driver._isRecovering).to.be.false; + expect(logStub.error.calledWith(sandbox.match('Failed to reinitialize WDA session'))).to.be + .true; + }); + }); });