diff --git a/Scripts/build-webdriveragent.js b/Scripts/build-webdriveragent.mjs similarity index 83% rename from Scripts/build-webdriveragent.js rename to Scripts/build-webdriveragent.mjs index b37bcbbc9..3e1f6e2c7 100644 --- a/Scripts/build-webdriveragent.js +++ b/Scripts/build-webdriveragent.mjs @@ -1,8 +1,13 @@ -const path = require('path'); -const { asyncify } = require('asyncbox'); -const { logger, fs } = require('@appium/support'); -const { exec } = require('teen_process'); -const xcode = require('appium-xcode'); +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { asyncify } from 'asyncbox'; +import { logger, fs } from '@appium/support'; +import { exec } from 'teen_process'; +import * as xcode from 'appium-xcode'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === __filename; const LOG = new logger.getLogger('WDABuild'); const ROOT_DIR = path.resolve(__dirname, '..'); @@ -72,8 +77,9 @@ async function buildWebDriverAgent (xcodeVersion) { LOG.info(`Zip bundled at "${appBundleZipPath}"`); } -if (require.main === module) { +if (isMainModule) { asyncify(buildWebDriverAgent); } -module.exports = buildWebDriverAgent; +export default buildWebDriverAgent; + diff --git a/Scripts/fetch-prebuilt-wda.js b/Scripts/fetch-prebuilt-wda.mjs similarity index 70% rename from Scripts/fetch-prebuilt-wda.js rename to Scripts/fetch-prebuilt-wda.mjs index 4bd6840fa..850e71757 100644 --- a/Scripts/fetch-prebuilt-wda.js +++ b/Scripts/fetch-prebuilt-wda.mjs @@ -1,14 +1,21 @@ -const path = require('path'); -const axios = require('axios'); -const { asyncify } = require('asyncbox'); -const { logger, fs, mkdirp, net } = require('@appium/support'); -const _ = require('lodash'); -const B = require('bluebird'); +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import axios from 'axios'; +import { asyncify } from 'asyncbox'; +import { logger, fs, mkdirp, net } from '@appium/support'; +import _ from 'lodash'; +import B from 'bluebird'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === __filename; const log = logger.getLogger('WDA'); async function fetchPrebuiltWebDriverAgentAssets () { - const tag = require('../package.json').version; + const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')); + const tag = packageJson.version; log.info(`Getting links to webdriveragent release ${tag}`); const downloadUrl = `https://api.github.com/repos/appium/webdriveragent/releases/tags/v${tag}`; log.info(`Getting WDA release ${downloadUrl}`); @@ -54,8 +61,9 @@ async function fetchPrebuiltWebDriverAgentAssets () { return await B.all(agentsDownloading); } -if (require.main === module) { +if (isMainModule) { asyncify(fetchPrebuiltWebDriverAgentAssets); } -module.exports = fetchPrebuiltWebDriverAgentAssets; +export default fetchPrebuiltWebDriverAgentAssets; + diff --git a/Scripts/update-wda-version.js b/Scripts/update-wda-version.mjs similarity index 90% rename from Scripts/update-wda-version.js rename to Scripts/update-wda-version.mjs index 142efdce3..cc74c7fa3 100644 --- a/Scripts/update-wda-version.js +++ b/Scripts/update-wda-version.mjs @@ -1,6 +1,6 @@ -const {plist, logger} = require('@appium/support'); -const path = require('node:path'); -const semver = require('semver'); +import {plist, logger} from '@appium/support'; +import path from 'node:path'; +import semver from 'semver'; const log = logger.getLogger('Versioner'); @@ -39,3 +39,4 @@ async function updateWdaVersion() { } (async () => await updateWdaVersion())(); + diff --git a/index.ts b/index.ts index d8e3996bd..8a28be3e2 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -export { checkForDependencies, bundleWDASim } from './lib/check-dependencies'; +export { bundleWDASim } from './lib/check-dependencies'; export { NoSessionProxy } from './lib/no-session-proxy'; export { WebDriverAgent } from './lib/webdriveragent'; export { WDA_BASE_URL, WDA_RUNNER_BUNDLE_ID, PROJECT_FILE } from './lib/constants'; diff --git a/lib/check-dependencies.js b/lib/check-dependencies.ts similarity index 74% rename from lib/check-dependencies.js rename to lib/check-dependencies.ts index 40fd689a1..9540eb963 100644 --- a/lib/check-dependencies.js +++ b/lib/check-dependencies.ts @@ -5,9 +5,9 @@ import { WDA_SCHEME, SDK_SIMULATOR, WDA_RUNNER_APP } from './constants'; import { BOOTSTRAP_PATH } from './utils'; -import log from './logger'; +import type { XcodeBuild } from './xcodebuild'; -async function buildWDASim () { +async function buildWDASim (): Promise { const args = [ '-project', path.join(BOOTSTRAP_PATH, 'WebDriverAgent.xcodeproj'), '-scheme', WDA_SCHEME, @@ -19,17 +19,7 @@ async function buildWDASim () { await exec('xcodebuild', args); } -export async function checkForDependencies () { - log.debug('Dependencies are up to date'); - return false; -} - -/** - * - * @param {import('./xcodebuild').XcodeBuild} xcodebuild - * @returns {Promise} - */ -export async function bundleWDASim (xcodebuild) { +export async function bundleWDASim (xcodebuild: XcodeBuild): Promise { const derivedDataPath = await xcodebuild.retrieveDerivedDataPath(); if (!derivedDataPath) { throw new Error('Cannot retrieve the path to the Xcode derived data folder'); @@ -41,3 +31,4 @@ export async function bundleWDASim (xcodebuild) { await buildWDASim(); return wdaBundlePath; } + diff --git a/lib/constants.js b/lib/constants.js deleted file mode 100644 index fd6ed4803..000000000 --- a/lib/constants.js +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'path'; - -const DEFAULT_TEST_BUNDLE_SUFFIX = '.xctrunner'; -const WDA_RUNNER_BUNDLE_ID = 'com.facebook.WebDriverAgentRunner'; -const WDA_RUNNER_BUNDLE_ID_FOR_XCTEST = `${WDA_RUNNER_BUNDLE_ID}${DEFAULT_TEST_BUNDLE_SUFFIX}`; -const WDA_RUNNER_APP = 'WebDriverAgentRunner-Runner.app'; -const WDA_SCHEME = 'WebDriverAgentRunner'; -const PROJECT_FILE = 'project.pbxproj'; -const WDA_BASE_URL = 'http://127.0.0.1'; - -const PLATFORM_NAME_TVOS = 'tvOS'; -const PLATFORM_NAME_IOS = 'iOS'; - -const SDK_SIMULATOR = 'iphonesimulator'; -const SDK_DEVICE = 'iphoneos'; - -const WDA_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent', 'upgrade.time'); - -export { - WDA_RUNNER_BUNDLE_ID, WDA_RUNNER_APP, PROJECT_FILE, - WDA_SCHEME, PLATFORM_NAME_TVOS, PLATFORM_NAME_IOS, - SDK_SIMULATOR, SDK_DEVICE, WDA_BASE_URL, WDA_UPGRADE_TIMESTAMP_PATH, - WDA_RUNNER_BUNDLE_ID_FOR_XCTEST, DEFAULT_TEST_BUNDLE_SUFFIX -}; diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 000000000..42c761df0 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; + +export const DEFAULT_TEST_BUNDLE_SUFFIX = '.xctrunner'; +export const WDA_RUNNER_BUNDLE_ID = 'com.facebook.WebDriverAgentRunner'; +export const WDA_RUNNER_BUNDLE_ID_FOR_XCTEST = `${WDA_RUNNER_BUNDLE_ID}${DEFAULT_TEST_BUNDLE_SUFFIX}`; +export const WDA_RUNNER_APP = 'WebDriverAgentRunner-Runner.app'; +export const WDA_SCHEME = 'WebDriverAgentRunner'; +export const PROJECT_FILE = 'project.pbxproj'; +export const WDA_BASE_URL = 'http://127.0.0.1'; + +export const PLATFORM_NAME_TVOS = 'tvOS'; +export const PLATFORM_NAME_IOS = 'iOS'; + +export const SDK_SIMULATOR = 'iphonesimulator'; +export const SDK_DEVICE = 'iphoneos'; + +export const WDA_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent', 'upgrade.time'); + diff --git a/lib/logger.js b/lib/logger.js deleted file mode 100644 index 4727be0ff..000000000 --- a/lib/logger.js +++ /dev/null @@ -1,5 +0,0 @@ -import { logger } from '@appium/support'; - -const log = logger.getLogger('WebDriverAgent'); - -export default log; diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 000000000..aecf4e22a --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,3 @@ +import { logger } from '@appium/support'; + +export const log = logger.getLogger('WebDriverAgent'); diff --git a/lib/no-session-proxy.js b/lib/no-session-proxy.ts similarity index 72% rename from lib/no-session-proxy.js rename to lib/no-session-proxy.ts index f0760c701..d16e68ac2 100644 --- a/lib/no-session-proxy.js +++ b/lib/no-session-proxy.ts @@ -1,12 +1,13 @@ import { JWProxy } from '@appium/base-driver'; +import type { ProxyOptions } from '@appium/types'; -class NoSessionProxy extends JWProxy { - constructor (opts = {}) { +export class NoSessionProxy extends JWProxy { + constructor (opts: ProxyOptions = {}) { super(opts); } - getUrlForProxy (url) { + override getUrlForProxy (url: string): string { if (url === '') { url = '/'; } @@ -22,5 +23,3 @@ class NoSessionProxy extends JWProxy { } } -export { NoSessionProxy }; -export default NoSessionProxy; diff --git a/lib/utils.js b/lib/utils.ts similarity index 71% rename from lib/utils.js rename to lib/utils.ts index c5ccea6f5..1345b2035 100644 --- a/lib/utils.js +++ b/lib/utils.ts @@ -1,24 +1,34 @@ import { fs, plist } from '@appium/support'; -import { exec } from 'teen_process'; -import path from 'path'; -import log from './logger'; +import { exec, SubProcess } from 'teen_process'; +import path, { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { log } from './logger'; import _ from 'lodash'; import { WDA_RUNNER_BUNDLE_ID, PLATFORM_NAME_TVOS } from './constants'; import B from 'bluebird'; -import _fs from 'fs'; +import _fs from 'node:fs'; import { waitForCondition } from 'asyncbox'; -import { arch } from 'os'; +import { arch } from 'node:os'; +import type { DeviceInfo } from './types'; const PROJECT_FILE = 'project.pbxproj'; +// Get current filename - works in both CommonJS and ESM +const currentFilename = + typeof __filename !== 'undefined' + ? __filename + : fileURLToPath(new Function('return import.meta.url')()); + +const currentDirname = dirname(currentFilename); + /** * Calculates the path to the current module's root folder * * @returns {string} The full path to module root * @throws {Error} If the current module root folder cannot be determined */ -const getModuleRoot = _.memoize(function getModuleRoot () { - let currentDir = path.dirname(path.resolve(__filename)); +const getModuleRoot = _.memoize(function getModuleRoot (): string { + let currentDir = currentDirname; let isAtFsRoot = false; while (!isAtFsRoot) { const manifestPath = path.join(currentDir, 'package.json'); @@ -36,24 +46,7 @@ const getModuleRoot = _.memoize(function getModuleRoot () { export const BOOTSTRAP_PATH = getModuleRoot(); -async function getPIDsUsingPattern (pattern) { - const args = [ - '-if', // case insensitive, full cmdline match - pattern, - ]; - try { - const {stdout} = await exec('pgrep', args); - return stdout.split(/\s+/) - .map((x) => parseInt(x, 10)) - .filter(_.isInteger) - .map((x) => `${x}`); - } catch (err) { - log.debug(`'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`); - return []; - } -} - -async function killAppUsingPattern (pgrepPattern) { +export async function killAppUsingPattern (pgrepPattern: string): Promise { const signals = [2, 15, 9]; for (const signal of signals) { const matchedPids = await getPIDsUsingPattern(pgrepPattern); @@ -63,7 +56,7 @@ async function killAppUsingPattern (pgrepPattern) { const args = [`-${signal}`, ...matchedPids]; try { await exec('kill', args); - } catch (err) { + } catch (err: any) { log.debug(`kill ${args.join(' ')} -> ${err.message}`); } if (signal === _.last(signals)) { @@ -94,36 +87,27 @@ async function killAppUsingPattern (pgrepPattern) { /** * Return true if the platformName is tvOS - * @param {string} platformName The name of the platorm - * @returns {boolean} Return true if the platformName is tvOS + * @param platformName The name of the platorm + * @returns Return true if the platformName is tvOS */ -function isTvOS (platformName) { +export function isTvOS (platformName: string): boolean { return _.toLower(platformName) === _.toLower(PLATFORM_NAME_TVOS); } -async function replaceInFile (file, find, replace) { - let contents = await fs.readFile(file, 'utf8'); - - let newContents = contents.replace(find, replace); - if (newContents !== contents) { - await fs.writeFile(file, newContents, 'utf8'); - } -} - /** * Update WebDriverAgentRunner project bundle ID with newBundleId. * This method assumes project file is in the correct state. - * @param {string} agentPath - Path to the .xcodeproj directory. - * @param {string} newBundleId the new bundle ID used to update. + * @param agentPath - Path to the .xcodeproj directory. + * @param newBundleId the new bundle ID used to update. */ -async function updateProjectFile (agentPath, newBundleId) { - let projectFilePath = path.resolve(agentPath, PROJECT_FILE); +export async function updateProjectFile (agentPath: string, newBundleId: string): Promise { + const projectFilePath = path.resolve(agentPath, PROJECT_FILE); try { // Assuming projectFilePath is in the correct state, create .old from projectFilePath await fs.copyFile(projectFilePath, `${projectFilePath}.old`); await replaceInFile(projectFilePath, new RegExp(_.escapeRegExp(WDA_RUNNER_BUNDLE_ID), 'g'), newBundleId); log.debug(`Successfully updated '${projectFilePath}' with bundle id '${newBundleId}'`); - } catch (err) { + } catch (err: any) { log.debug(`Error updating project file: ${err.message}`); log.warn(`Unable to update project file '${projectFilePath}' with ` + `bundle id '${newBundleId}'. WebDriverAgent may not start`); @@ -132,9 +116,9 @@ async function updateProjectFile (agentPath, newBundleId) { /** * Reset WebDriverAgentRunner project bundle ID to correct state. - * @param {string} agentPath - Path to the .xcodeproj directory. + * @param agentPath - Path to the .xcodeproj directory. */ -async function resetProjectFile (agentPath) { +export async function resetProjectFile (agentPath: string): Promise { const projectFilePath = path.join(agentPath, PROJECT_FILE); try { // restore projectFilePath from .old file @@ -143,7 +127,7 @@ async function resetProjectFile (agentPath) { } await fs.mv(`${projectFilePath}.old`, projectFilePath); log.debug(`Successfully reset '${projectFilePath}' with bundle id '${WDA_RUNNER_BUNDLE_ID}'`); - } catch (err) { + } catch (err: any) { log.debug(`Error resetting project file: ${err.message}`); log.warn(`Unable to reset project file '${projectFilePath}' with ` + `bundle id '${WDA_RUNNER_BUNDLE_ID}'. WebDriverAgent has been ` + @@ -151,27 +135,24 @@ async function resetProjectFile (agentPath) { } } -async function setRealDeviceSecurity (keychainPath, keychainPassword) { +export async function setRealDeviceSecurity (keychainPath: string, keychainPassword: string): Promise { log.debug('Setting security for iOS device'); await exec('security', ['-v', 'list-keychains', '-s', keychainPath]); await exec('security', ['-v', 'unlock-keychain', '-p', keychainPassword, keychainPath]); await exec('security', ['set-keychain-settings', '-t', '3600', '-l', keychainPath]); } -/** - * Information of the device under test - * @typedef {import('./types').DeviceInfo} DeviceInfo - */ - /** * Arguments for setting xctestrun file - * @typedef {Object} XctestrunFileArgs - * @property {DeviceInfo} deviceInfo - Information of the device under test - * @property {string} sdkVersion - The Xcode SDK version of OS. - * @property {string} bootstrapPath - The folder path containing xctestrun file. - * @property {number|string} wdaRemotePort - The remote port WDA is listening on. - * @property {string} [wdaBindingIP] - The IP address to bind to. If not given, it binds to all interfaces. */ +export interface XctestrunFileArgs { + deviceInfo: DeviceInfo; + sdkVersion: string; + bootstrapPath: string; + wdaRemotePort: number | string; + wdaBindingIP?: string; +} + /** * Creates xctestrun file per device & platform version. * We expects to have WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device @@ -180,13 +161,13 @@ async function setRealDeviceSecurity (keychainPath, keychainPassword) { * e.g. Xcode which has iOS SDK Version 12.2 on an intel Mac host machine generates WebDriverAgentRunner_iphonesimulator.2-x86_64.xctestrun * even if the cap has platform version 11.4 * - * @param {XctestrunFileArgs} args - * @return {Promise} returns xctestrunFilePath for given device + * @param args + * @return returns xctestrunFilePath for given device * @throws if WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device * or WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-x86_64.xctestrun for simulator is not found @bootstrapPath, * then it will throw a file not found exception */ -async function setXctestrunFile (args) { +export async function setXctestrunFile (args: XctestrunFileArgs): Promise { const {deviceInfo, sdkVersion, bootstrapPath, wdaRemotePort, wdaBindingIP} = args; const xctestrunFilePath = await getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath); const xctestRunContent = await plist.parsePlistFile(xctestrunFilePath); @@ -199,12 +180,12 @@ async function setXctestrunFile (args) { /** * Return the WDA object which appends existing xctest runner content - * @param {string} platformName - The name of the platform - * @param {number|string} wdaRemotePort - The remote port number - * @param {string} [wdaBindingIP] - The IP address to bind to. If not given, it binds to all interfaces. - * @return {object} returns a runner object which has USE_PORT and optionally USE_IP + * @param platformName - The name of the platform + * @param wdaRemotePort - The remote port number + * @param wdaBindingIP - The IP address to bind to. If not given, it binds to all interfaces. + * @return returns a runner object which has USE_PORT and optionally USE_IP */ -function getAdditionalRunContent (platformName, wdaRemotePort, wdaBindingIP) { +export function getAdditionalRunContent (platformName: string, wdaRemotePort: number | string, wdaBindingIP?: string): Record { const runner = `WebDriverAgentRunner${isTvOS(platformName) ? '_tvOS' : ''}`; return { [runner]: { @@ -219,19 +200,18 @@ function getAdditionalRunContent (platformName, wdaRemotePort, wdaBindingIP) { /** * Return the path of xctestrun if it exists - * @param {DeviceInfo} deviceInfo - * @param {string} sdkVersion - The Xcode SDK version of OS. - * @param {string} bootstrapPath - The folder path containing xctestrun file. - * @returns {Promise} + * @param deviceInfo + * @param sdkVersion - The Xcode SDK version of OS. + * @param bootstrapPath - The folder path containing xctestrun file. */ -async function getXctestrunFilePath (deviceInfo, sdkVersion, bootstrapPath) { +export async function getXctestrunFilePath (deviceInfo: DeviceInfo, sdkVersion: string, bootstrapPath: string): Promise { // First try the SDK path, for Xcode 10 (at least) - const sdkBased = [ + const sdkBased: [string, string] = [ path.resolve(bootstrapPath, `${deviceInfo.udid}_${sdkVersion}.xctestrun`), sdkVersion, ]; // Next try Platform path, for earlier Xcode versions - const platformBased = [ + const platformBased: [string, string] = [ path.resolve(bootstrapPath, `${deviceInfo.udid}_${deviceInfo.platformVersion}.xctestrun`), deviceInfo.platformVersion, ]; @@ -261,11 +241,11 @@ async function getXctestrunFilePath (deviceInfo, sdkVersion, bootstrapPath) { /** * Return the name of xctestrun file - * @param {DeviceInfo} deviceInfo - * @param {string} version - The Xcode SDK version of OS. - * @return {string} returns xctestrunFilePath for given device + * @param deviceInfo + * @param version - The Xcode SDK version of OS. + * @return returns xctestrunFilePath for given device */ -function getXctestrunFileName (deviceInfo, version) { +export function getXctestrunFileName (deviceInfo: DeviceInfo, version: string): string { const archSuffix = deviceInfo.isRealDevice ? `os${version}-arm64` : `simulator${version}-${arch() === 'arm64' ? 'arm64' : 'x86_64'}`; @@ -274,12 +254,8 @@ function getXctestrunFileName (deviceInfo, version) { /** * Ensures the process is killed after the timeout - * - * @param {string} name - * @param {import('teen_process').SubProcess} proc - * @returns {Promise} */ -async function killProcess (name, proc) { +export async function killProcess (name: string, proc: SubProcess | null | undefined): Promise { if (!proc || !proc.isRunning) { return; } @@ -290,7 +266,7 @@ async function killProcess (name, proc) { try { await proc.stop('SIGTERM', 1000); return; - } catch (err) { + } catch (err: any) { if (!err.message.includes(`Process didn't end after`)) { throw err; } @@ -300,7 +276,7 @@ async function killProcess (name, proc) { log.info(`Sending 'SIGKILL'...`); try { await proc.stop('SIGKILL'); - } catch (err) { + } catch (err: any) { if (err.message.includes('not currently running')) { // the process ended but for some reason we were not informed return; @@ -310,21 +286,16 @@ async function killProcess (name, proc) { } /** - * Generate a random integer. - * - * @return {number} A random integer number in range [low, hight). `low`` is inclusive and `high` is exclusive. + * Generate a random integer in range [low, high). `low` is inclusive and `high` is exclusive. */ -function randomInt (low, high) { +export function randomInt (low: number, high: number): number { return Math.floor(Math.random() * (high - low) + low); } /** - * Retrieves WDA upgrade timestamp - * - * @return {Promise} The UNIX timestamp of the package manifest. The manifest only gets modified on - * package upgrade. + * Retrieves WDA upgrade timestamp. The manifest only gets modified on package upgrade. */ -async function getWDAUpgradeTimestamp () { +export async function getWDAUpgradeTimestamp (): Promise { const packageManifest = path.resolve(getModuleRoot(), 'package.json'); if (!await fs.exists(packageManifest)) { return null; @@ -335,11 +306,8 @@ async function getWDAUpgradeTimestamp () { /** * Kills running XCTest processes for the particular device. - * - * @param {string} udid - The device UDID. - * @param {boolean} isSimulator - Equals to true if the current device is a Simulator */ -async function resetTestProcesses (udid, isSimulator) { +export async function resetTestProcesses (udid: string, isSimulator: boolean): Promise { const processPatterns = [`xcodebuild.*${udid}`]; if (isSimulator) { processPatterns.push(`${udid}.*XCTRunner`); @@ -355,21 +323,21 @@ async function resetTestProcesses (udid, isSimulator) { * It is also possible to apply additional filtering based on the * process command line. * - * @param {string|number} port - The port number. - * @param {?Function} filteringFunc - Optional lambda function, which + * @param port - The port number. + * @param filteringFunc - Optional lambda function, which * receives command line string of the particular process * listening on given port, and is expected to return * either true or false to include/exclude the corresponding PID * from the resulting array. - * @returns {Promise} - the list of matched process ids. + * @returns - the list of matched process ids. */ -async function getPIDsListeningOnPort (port, filteringFunc = null) { - const result = []; +export async function getPIDsListeningOnPort (port: string | number, filteringFunc: ((cmdline: string) => boolean | Promise) | null = null): Promise { + const result: string[] = []; try { // This only works since Mac OS X El Capitan const {stdout} = await exec('lsof', ['-ti', `tcp:${port}`]); result.push(...(stdout.trim().split(/\n+/))); - } catch (e) { + } catch (e: any) { if (e.code !== 1) { // code 1 means no processes. Other errors need reporting log.debug(`Error getting processes listening on port '${port}': ${e.stderr || e.message}`); @@ -381,10 +349,10 @@ async function getPIDsListeningOnPort (port, filteringFunc = null) { return result; } return await B.filter(result, async (pid) => { - let stdout; + let stdout: string; try { ({stdout} = await exec('ps', ['-p', pid, '-o', 'command'])); - } catch (e) { + } catch (e: any) { if (e.code === 1) { // The process does not exist anymore, there's nothing to filter return false; @@ -395,9 +363,31 @@ async function getPIDsListeningOnPort (port, filteringFunc = null) { }); } -export { updateProjectFile, resetProjectFile, setRealDeviceSecurity, - getAdditionalRunContent, getXctestrunFileName, - setXctestrunFile, getXctestrunFilePath, killProcess, randomInt, - getWDAUpgradeTimestamp, resetTestProcesses, - getPIDsListeningOnPort, killAppUsingPattern, isTvOS -}; +// Private functions + +async function getPIDsUsingPattern (pattern: string): Promise { + const args = [ + '-if', // case insensitive, full cmdline match + pattern, + ]; + try { + const {stdout} = await exec('pgrep', args); + return stdout.split(/\s+/) + .map((x) => parseInt(x, 10)) + .filter(_.isInteger) + .map((x) => `${x}`); + } catch (err: any) { + log.debug(`'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`); + return []; + } +} + +async function replaceInFile (file: string, find: string | RegExp, replace: string): Promise { + const contents = await fs.readFile(file, 'utf8'); + + const newContents = contents.replace(find, replace); + if (newContents !== contents) { + await fs.writeFile(file, newContents, 'utf8'); + } +} + diff --git a/lib/webdriveragent.js b/lib/webdriveragent.ts similarity index 75% rename from lib/webdriveragent.js rename to lib/webdriveragent.ts index e9e92d872..ba8dcf36c 100644 --- a/lib/webdriveragent.js +++ b/lib/webdriveragent.ts @@ -1,11 +1,12 @@ import { waitForCondition } from 'asyncbox'; import _ from 'lodash'; -import path from 'path'; -import url from 'url'; +import path from 'node:path'; +import url from 'node:url'; import B from 'bluebird'; import { JWProxy } from '@appium/base-driver'; import { fs, util, plist } from '@appium/support'; -import defaultLogger from './logger'; +import type { AppiumLogger, StringRecord } from '@appium/types'; +import { log as defaultLogger } from './logger'; import { NoSessionProxy } from './no-session-proxy'; import { getWDAUpgradeTimestamp, resetTestProcesses, getPIDsListeningOnPort, BOOTSTRAP_PATH @@ -20,6 +21,7 @@ import { } from './constants'; import {Xctest} from 'appium-ios-device'; import {strongbox} from '@appium/strongbox'; +import type { WebDriverAgentArgs, AppleDevice } from './types'; const WDA_LAUNCH_TIMEOUT = 60 * 1000; const WDA_AGENT_PORT = 8100; @@ -28,23 +30,46 @@ const SHARED_RESOURCES_GUARD = new AsyncLock(); const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion'; export class WebDriverAgent { - /** @type {string} */ - bootstrapPath; - - /** @type {string} */ - agentPath; - - /** @type {any} @deprecated for removal */ - idb; + bootstrapPath: string; + agentPath: string; + readonly args: WebDriverAgentArgs; + private readonly log: AppiumLogger; + readonly device: AppleDevice; + readonly platformVersion?: string; + readonly platformName?: string; + readonly iosSdkVersion?: string; + readonly host?: string; + readonly isRealDevice: boolean; + private readonly wdaBundlePath?: string; + private readonly wdaLocalPort?: number; + readonly wdaRemotePort: number; + readonly wdaBaseUrl: string; + readonly wdaBindingIP?: string; + private readonly prebuildWDA?: boolean; + webDriverAgentUrl?: string; + started: boolean; + private readonly wdaConnectionTimeout?: number; + private readonly useXctestrunFile?: boolean; + private readonly usePrebuiltWDA?: boolean; + private readonly derivedDataPath?: string; + private readonly mjpegServerPort?: number; + updatedWDABundleId?: string; + private readonly wdaLaunchTimeout: number; + private readonly usePreinstalledWDA?: boolean; + private xctestApiClient?: Xctest | null; + private readonly updatedWDABundleIdSuffix: string; + private _xcodebuild?: XcodeBuild | null; + noSessionProxy?: NoSessionProxy; + jwproxy?: JWProxy; + proxyReqRes?: any; + private _url?: url.UrlWithStringQuery; /** - * @param {import('appium-xcode').XcodeVersion | undefined} xcodeVersion @deprecated Will be removed as no actual usage. - * @param {import('./types').WebDriverAgentArgs} args - * @param {import('@appium/types').AppiumLogger?} [log=null] + * Creates a new WebDriverAgent instance. + * @param args - Configuration arguments for WebDriverAgent + * @param log - Optional logger instance */ - constructor (xcodeVersion, args, log = null) { - this.xcodeVersion = xcodeVersion; - + constructor (args: WebDriverAgentArgs, log: AppiumLogger | null = null) { this.args = _.clone(args); this.log = log ?? defaultLogger; @@ -54,8 +79,6 @@ export class WebDriverAgent { this.iosSdkVersion = args.iosSdkVersion; this.host = args.host; this.isRealDevice = !!args.realDevice; - /** @deprecated We'll stop supporting idb */ - this.idb = args.device.idb; this.wdaBundlePath = args.wdaBundlePath; this.setWDAPaths(args.bootstrapPath, args.agentPath); @@ -88,9 +111,9 @@ export class WebDriverAgent { this.xctestApiClient = null; this.updatedWDABundleIdSuffix = args.updatedWDABundleIdSuffix ?? DEFAULT_TEST_BUNDLE_SUFFIX; - this.xcodebuild = this.canSkipXcodebuild + this._xcodebuild = this.canSkipXcodebuild ? null - : new XcodeBuild(this.xcodeVersion, this.device, { + : new XcodeBuild(this.device, { platformVersion: this.platformVersion, platformName: this.platformName, iosSdkVersion: this.iosSdkVersion, @@ -120,46 +143,44 @@ export class WebDriverAgent { /** * Return true if the session does not need xcodebuild. - * @returns {boolean} Whether the session needs/has xcodebuild. + * @returns Whether the session needs/has xcodebuild. */ - get canSkipXcodebuild () { + get canSkipXcodebuild (): boolean { // Use this.args.webDriverAgentUrl to guarantee // the capabilities set gave the `appium:webDriverAgentUrl`. return this.usePreinstalledWDA || !!this.args.webDriverAgentUrl; } + /** + * Get the xcodebuild instance. Throws if not initialized. + * @returns The XcodeBuild instance + * @throws Error if xcodebuild is not available + */ + get xcodebuild (): XcodeBuild { + if (!this._xcodebuild) { + throw new Error('xcodebuild is not available'); + } + return this._xcodebuild; + } + /** * Return bundle id for WebDriverAgent to launch the WDA. * The primary usage is with 'this.usePreinstalledWDA'. * It adds `.xctrunner` as suffix by default but 'this.updatedWDABundleIdSuffix' * lets skip it. * - * @returns {string} Bundle ID for Xctest. + * @returns Bundle ID for Xctest. */ - get bundleIdForXctest () { + get bundleIdForXctest (): string { return `${this.updatedWDABundleId ? this.updatedWDABundleId : WDA_RUNNER_BUNDLE_ID}${this.updatedWDABundleIdSuffix}`; } /** - * @param {string} [bootstrapPath] - * @param {string} [agentPath] - */ - setWDAPaths (bootstrapPath, agentPath) { - // allow the user to specify a place for WDA. This is undocumented and - // only here for the purposes of testing development of WDA - this.bootstrapPath = bootstrapPath || BOOTSTRAP_PATH; - this.log.info(`Using WDA path: '${this.bootstrapPath}'`); - - // for backward compatibility we need to be able to specify agentPath too - this.agentPath = agentPath || path.resolve(this.bootstrapPath, 'WebDriverAgent.xcodeproj'); - this.log.info(`Using WDA agent: '${this.agentPath}'`); - } - - /** - * @returns {Promise} + * Cleans up obsolete cached processes from previous WDA sessions + * that are listening on the same port but belong to different devices. */ - async cleanupObsoleteProcesses () { - const obsoletePids = await getPIDsListeningOnPort(/** @type {string} */ (this.url.port), + async cleanupObsoleteProcesses (): Promise { + const obsoletePids = await getPIDsListeningOnPort(this.url.port as string, (cmdLine) => cmdLine.includes('/WebDriverAgentRunner') && !cmdLine.toLowerCase().includes(this.device.udid.toLowerCase())); @@ -173,268 +194,23 @@ export class WebDriverAgent { `from previous WDA sessions. Cleaning them up`); try { await exec('kill', obsoletePids); - } catch (e) { + } catch (e: any) { this.log.warn(`Failed to kill obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} '${obsoletePids}'. ` + `Original error: ${e.message}`); } } /** - * Return boolean if WDA is running or not - * @return {Promise} True if WDA is running - * @throws {Error} If there was invalid response code or body + * Gets the base path for the WebDriverAgent URL. + * @returns The base path (empty string if root path) */ - async isRunning () { - return !!(await this.getStatus()); - } - - /** - * @returns {string} - */ - get basePath () { + get basePath (): string { if (this.url.path === '/') { return ''; } return this.url.path || ''; } - /** - * Return current running WDA's status like below - * { - * "state": "success", - * "os": { - * "name": "iOS", - * "version": "11.4", - * "sdkVersion": "11.3" - * }, - * "ios": { - * "simulatorVersion": "11.4", - * "ip": "172.254.99.34" - * }, - * "build": { - * "time": "Jun 24 2018 17:08:21", - * "productBundleIdentifier": "com.facebook.WebDriverAgentRunner" - * } - * } - * - * @param {number} [timeoutMs=0] If the given timeoutMs is zero or negative number, - * this function will return the response of `/status` immediately. If the given timeoutMs, - * this function will try to get the response of `/status` up to the timeoutMs. - * @return {Promise} State Object - * @throws {Error} If there was an error within timeoutMs timeout. - * No error is raised if zero or negative number for the timeoutMs. - */ - async getStatus (timeoutMs = 0) { - const noSessionProxy = new NoSessionProxy({ - server: this.url.hostname, - port: this.url.port, - base: this.basePath, - timeout: 3000, - }); - - const sendGetStatus = async () => await /** @type import('@appium/types').StringRecord */ (noSessionProxy.command('/status', 'GET')); - - if (_.isNil(timeoutMs) || timeoutMs <= 0) { - try { - return await sendGetStatus(); - } catch (err) { - this.log.debug(`WDA is not listening at '${this.url.href}'. Original error:: ${err.message}`); - return null; - } - } - - let lastError = null; - let status = null; - try { - await waitForCondition(async () => { - try { - status = await sendGetStatus(); - return true; - } catch (err) { - lastError = err; - } - return false; - }, { - waitMs: timeoutMs, - intervalMs: 300, - }); - } catch (err) { - this.log.debug(`Failed to get the status endpoint in ${timeoutMs} ms. ` + - `The last error while accessing ${this.url.href}: ${lastError}. Original error:: ${err.message}.`); - throw new Error(`WDA was not ready in ${timeoutMs} ms.`); - } - return status; - } - - /** - * Uninstall WDAs from the test device. - * Over Xcode 11, multiple WDA can be in the device since Xcode 11 generates different WDA. - * Appium does not expect multiple WDAs are running on a device. - * - * @returns {Promise} - */ - async uninstall () { - try { - const bundleIds = await this.device.getUserInstalledBundleIdsByBundleName(WDA_CF_BUNDLE_NAME); - if (_.isEmpty(bundleIds)) { - this.log.debug('No WDAs on the device.'); - return; - } - - this.log.debug(`Uninstalling WDAs: '${bundleIds}'`); - for (const bundleId of bundleIds) { - await this.device.removeApp(bundleId); - } - } catch (e) { - this.log.debug(e); - this.log.warn(`WebDriverAgent uninstall failed. Perhaps, it is already uninstalled? ` + - `Original error: ${e.message}`); - } - } - - async _cleanupProjectIfFresh () { - if (this.canSkipXcodebuild) { - return; - } - - const packageInfo = JSON.parse(await fs.readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8')); - const box = strongbox(packageInfo.name); - let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME); - if (!boxItem) { - const timestampPath = path.resolve(process.env.HOME ?? '', WDA_UPGRADE_TIMESTAMP_PATH); - if (await fs.exists(timestampPath)) { - // TODO: It is probably a bit ugly to hardcode the recent version string, - // TODO: hovewer it should do the job as a temporary transition trick - // TODO: to switch from a hardcoded file path to the strongbox usage. - try { - boxItem = await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, '5.0.0'); - } catch (e) { - this.log.warn(`The actual module version cannot be persisted: ${e.message}`); - return; - } - } else { - this.log.info('There is no need to perform the project cleanup. A fresh install has been detected'); - try { - await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, packageInfo.version); - } catch (e) { - this.log.warn(`The actual module version cannot be persisted: ${e.message}`); - } - return; - } - } - - let recentModuleVersion = await boxItem.read(); - try { - recentModuleVersion = util.coerceVersion(recentModuleVersion, true); - } catch (e) { - this.log.warn(`The persisted module version string has been damaged: ${e.message}`); - this.log.info(`Updating it to '${packageInfo.version}' assuming the project clenup is not needed`); - await boxItem.write(packageInfo.version); - return; - } - - if (util.compareVersions(recentModuleVersion, '>=', packageInfo.version)) { - this.log.info( - `WebDriverAgent does not need a cleanup. The project sources are up to date ` + - `(${recentModuleVersion} >= ${packageInfo.version})` - ); - return; - } - - this.log.info( - `Cleaning up the WebDriverAgent project after the module upgrade has happened ` + - `(${recentModuleVersion} < ${packageInfo.version})` - ); - try { - // @ts-ignore xcodebuild should be set - await this.xcodebuild.cleanProject(); - await boxItem.write(packageInfo.version); - } catch (e) { - this.log.warn(`Cannot perform WebDriverAgent project cleanup. Original error: ${e.message}`); - } - } - - - /** - * @typedef {Object} LaunchWdaViaDeviceCtlOptions - * @property {Record} [env] environment variables for the launching WDA process - */ - - /** - * Launch WDA with preinstalled package with 'xcrun devicectl device process launch'. - * The WDA package must be prepared properly like published via - * https://github.com/appium/WebDriverAgent/releases - * with proper sign for this case. - * - * When we implement launching XCTest service via appium-ios-device, - * this implementation can be replaced with it. - * - * @param {LaunchWdaViaDeviceCtlOptions} [opts={}] launching WDA with devicectl command options. - * @return {Promise} - */ - async _launchViaDevicectl(opts = {}) { - const {env} = opts; - - await this.device.devicectl.launchApp( - this.bundleIdForXctest, { env, terminateExisting: true } - ); - } - - /** - * Launch WDA with preinstalled package without xcodebuild. - * @param {string} sessionId Launch WDA and establish the session with this sessionId - * @return {Promise} State Object - * @throws {Error} If there was an error within timeoutMs timeout. - * No error is raised if zero or negative number for the timeoutMs. - */ - async launchWithPreinstalledWDA(sessionId) { - const xctestEnv = { - USE_PORT: this.wdaLocalPort || WDA_AGENT_PORT, - WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest - }; - if (this.mjpegServerPort) { - xctestEnv.MJPEG_SERVER_PORT = this.mjpegServerPort; - } - if (this.wdaBindingIP) { - xctestEnv.USE_IP = this.wdaBindingIP; - } - this.log.info('Launching WebDriverAgent on the device without xcodebuild'); - if (this.isRealDevice) { - // Current method to launch WDA process can be done via 'xcrun devicectl', - // but it has limitation about the WDA preinstalled package. - // https://github.com/appium/appium/issues/19206#issuecomment-2014182674 - if (this.platformVersion && util.compareVersions(this.platformVersion, '>=', '17.0')) { - await this._launchViaDevicectl({env: xctestEnv}); - } else { - this.xctestApiClient = new Xctest(this.device.udid, this.bundleIdForXctest, null, {env: xctestEnv}); - await this.xctestApiClient.start(); - } - } else { - await this.device.simctl.exec('launch', { - args: [ - '--terminate-running-process', - this.device.udid, - this.bundleIdForXctest, - ], - env: xctestEnv, - }); - } - - this.setupProxies(sessionId); - let status; - try { - status = await this.getStatus(this.wdaLaunchTimeout); - } catch { - throw new Error( - `Failed to start the preinstalled WebDriverAgent in ${this.wdaLaunchTimeout} ms. ` + - `The WebDriverAgent might not be properly built or the device might be locked. ` + - `The 'appium:wdaLaunchTimeout' capability modifies the timeout.` - ); - } - this.started = true; - return status; - } - /** * Return current running WDA's status like below after launching WDA * { @@ -454,11 +230,9 @@ export class WebDriverAgent { * } * } * - * @param {string} sessionId Launch WDA and establish the session with this sessionId - * @return {Promise} State Object - * @throws {Error} If there was invalid response code or body + * @param sessionId Launch WDA and establish the session with this sessionId */ - async launch (sessionId) { + async launch (sessionId: string): Promise { if (this.webDriverAgentUrl) { this.log.info(`Using provided WebdriverAgent at '${this.webDriverAgentUrl}'`); this.url = this.webDriverAgentUrl; @@ -481,7 +255,7 @@ export class WebDriverAgent { // useXctestrunFile and usePrebuiltWDA use existing dependencies // It depends on user side - if (this.idb || this.useXctestrunFile || this.usePrebuiltWDA) { + if (this.useXctestrunFile || this.usePrebuiltWDA) { this.log.info('Skipped WDA project cleanup according to the provided capabilities'); } else { const synchronizationKey = path.normalize(this.bootstrapPath); @@ -492,77 +266,43 @@ export class WebDriverAgent { // We need to provide WDA local port, because it might be occupied await resetTestProcesses(this.device.udid, !this.isRealDevice); - if (this.idb) { - return await this.startWithIDB(); + if (!this.noSessionProxy) { + throw new Error('noSessionProxy is not available'); } - - // @ts-ignore xcodebuild should be set await this.xcodebuild.init(this.noSessionProxy); // Start the xcodebuild process if (this.prebuildWDA) { - // @ts-ignore xcodebuild should be set await this.xcodebuild.prebuild(); } - // @ts-ignore xcodebuild should be set - return await this.xcodebuild.start(); + return await this.xcodebuild.start() as StringRecord | null; } /** - * @deprecated We'll stop supporting idb. Deprecated for removal. - * @returns {Promise} + * Checks if the WebDriverAgent source is fresh by verifying + * that required resource files exist. + * @returns `true` if source is fresh (all required files exist), `false` otherwise */ - async startWithIDB () { - this.log.info('Will launch WDA with idb instead of xcodebuild since the corresponding flag is enabled'); - const {wdaBundleId, testBundleId} = await this.prepareWDA(); - const env = { - USE_PORT: this.wdaRemotePort, - WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest, - }; - if (this.mjpegServerPort) { - env.MJPEG_SERVER_PORT = this.mjpegServerPort; - } - if (this.wdaBindingIP) { - env.USE_IP = this.wdaBindingIP; - } - - return await this.idb.runXCUITest(wdaBundleId, wdaBundleId, testBundleId, {env}); + async isSourceFresh (): Promise { + const existsPromises = [ + 'Resources', + `Resources${path.sep}WebDriverAgent.bundle`, + ].map((subPath) => fs.exists(path.resolve(this.bootstrapPath, subPath))); + return (await B.all(existsPromises)).some((v) => v === false); } - /** - * - * @param {string} wdaBundlePath - * @returns {Promise} - */ - async parseBundleId (wdaBundlePath) { + private async parseBundleId (wdaBundlePath: string): Promise { const infoPlistPath = path.join(wdaBundlePath, 'Info.plist'); const infoPlist = await plist.parsePlist(await fs.readFile(infoPlistPath)); if (!infoPlist.CFBundleIdentifier) { throw new Error(`Could not find bundle id in '${infoPlistPath}'`); } - return infoPlist.CFBundleIdentifier; + return infoPlist.CFBundleIdentifier as string; } - /** - * @deprecated We'll stop using idb - * @returns {Promise<{wdaBundleId: string, testBundleId: string, wdaBundlePath: string}>} - */ - async prepareWDA () { - const wdaBundlePath = this.wdaBundlePath || await this.fetchWDABundle(); - const wdaBundleId = await this.parseBundleId(wdaBundlePath); - if (!await this.device.isAppInstalled(wdaBundleId)) { - await this.device.installApp(wdaBundlePath); - } - const testBundleId = await this.idb.installXCTestBundle(path.join(wdaBundlePath, 'PlugIns', 'WebDriverAgentRunner.xctest')); - return {wdaBundleId, testBundleId, wdaBundlePath}; - } - - /** - * @returns {Promise} - */ - async fetchWDABundle () { + private async fetchWDABundle (): Promise { if (!this.derivedDataPath) { - return await bundleWDASim(/** @type {XcodeBuild} */ (this.xcodebuild)); + return await bundleWDASim(this.xcodebuild); } const wdaBundlePaths = await fs.glob(`${this.derivedDataPath}/**/*${WDA_RUNNER_APP}/`, { absolute: true, @@ -573,23 +313,8 @@ export class WebDriverAgent { return wdaBundlePaths[0]; } - /** - * @returns {Promise} - */ - async isSourceFresh () { - const existsPromises = [ - 'Resources', - `Resources${path.sep}WebDriverAgent.bundle`, - ].map((subPath) => fs.exists(path.resolve(/** @type {String} */ (this.bootstrapPath), subPath))); - return (await B.all(existsPromises)).some((v) => v === false); - } - - /** - * @param {string} sessionId - * @returns {void} - */ - setupProxies (sessionId) { - const proxyOpts = { + private setupProxies (sessionId: string): void { + const proxyOpts: any = { log: this.log, server: this.url.hostname ?? undefined, port: parseInt(this.url.port ?? '', 10) || undefined, @@ -610,9 +335,10 @@ export class WebDriverAgent { } /** - * @returns {Promise} + * Stops the WebDriverAgent session and cleans up resources. + * Handles both preinstalled WDA and xcodebuild-based sessions. */ - async quit () { + async quit (): Promise { if (this.usePreinstalledWDA) { this.log.info('Stopping the XCTest session'); if (this.xctestApiClient) { @@ -621,14 +347,16 @@ export class WebDriverAgent { } else { try { await this.device.simctl.terminateApp(this.bundleIdForXctest); - } catch (e) { + } catch (e: any) { this.log.warn(e.message); } } } else if (!this.args.webDriverAgentUrl) { this.log.info('Shutting down sub-processes'); - await this.xcodebuild?.quit(); - await this.xcodebuild?.reset(); + if (this._xcodebuild) { + await this.xcodebuild.quit(); + await this.xcodebuild.reset(); + } } else { this.log.debug('Do not stop xcodebuild nor XCTest session ' + 'since the WDA session is managed by outside this driver.'); @@ -648,9 +376,12 @@ export class WebDriverAgent { } /** - * @returns {import('url').UrlWithStringQuery} + * Gets the WebDriverAgent URL. + * Constructs the URL from webDriverAgentUrl if provided, otherwise + * builds it from wdaBaseUrl, wdaBindingIP, and wdaLocalPort. + * @returns The parsed URL object */ - get url () { + get url (): url.UrlWithStringQuery { if (!this._url) { if (this.webDriverAgentUrl) { this._url = url.parse(this.webDriverAgentUrl); @@ -664,46 +395,47 @@ export class WebDriverAgent { } /** - * @param {string} _url - * @returns {void} + * Sets the WebDriverAgent URL. + * @param _url - The URL string to parse and set */ - set url (_url) { + set url (_url: string) { this._url = url.parse(_url); } /** - * @returns {boolean} + * Gets whether WebDriverAgent has fully started. + * @returns `true` if WDA has started, `false` otherwise */ - get fullyStarted () { + get fullyStarted (): boolean { return this.started; } /** - * @param {boolean} started - * @returns {void}s + * Sets whether WebDriverAgent has fully started. + * @param started - `true` if WDA has started, `false` otherwise */ - set fullyStarted (started) { + set fullyStarted (started: boolean) { this.started = started ?? false; } /** - * @returns {Promise} + * Retrieves the Xcode derived data path for WebDriverAgent. + * @returns The derived data path, or `undefined` if xcodebuild is skipped */ - async retrieveDerivedDataPath () { + async retrieveDerivedDataPath (): Promise { if (this.canSkipXcodebuild) { return; } - return await /** @type {XcodeBuild} */ (this.xcodebuild).retrieveDerivedDataPath(); + return await this.xcodebuild.retrieveDerivedDataPath(); } /** * Reuse running WDA if it has the same bundle id with updatedWDABundleId. * Or reuse it if it has the default id without updatedWDABundleId. * Uninstall it if the method faces an exception for the above situation. - * @returns {Promise} */ - async setupCaching () { - const status = await this.getStatus(); + async setupCaching (): Promise { + const status = await this.getStatus(0); if (!status || !status.build) { this.log.debug('WDA is currently not running. There is nothing to cache'); return; @@ -712,7 +444,7 @@ export class WebDriverAgent { const { productBundleIdentifier, upgradedAt, - } = status.build; + } = status.build as any; // for real device if (util.hasValue(productBundleIdentifier) && util.hasValue(this.updatedWDABundleId) && this.updatedWDABundleId !== productBundleIdentifier) { this.log.info(`Will uninstall running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`); @@ -742,12 +474,243 @@ export class WebDriverAgent { /** * Quit and uninstall running WDA. - * @returns {Promise} */ - async quitAndUninstall () { + async quitAndUninstall (): Promise { await this.quit(); await this.uninstall(); } -} -export default WebDriverAgent; + private setWDAPaths (bootstrapPath?: string, agentPath?: string): void { + // allow the user to specify a place for WDA. This is undocumented and + // only here for the purposes of testing development of WDA + this.bootstrapPath = bootstrapPath || BOOTSTRAP_PATH; + this.log.info(`Using WDA path: '${this.bootstrapPath}'`); + + // for backward compatibility we need to be able to specify agentPath too + this.agentPath = agentPath || path.resolve(this.bootstrapPath, 'WebDriverAgent.xcodeproj'); + this.log.info(`Using WDA agent: '${this.agentPath}'`); + } + + private async isRunning (): Promise { + return !!(await this.getStatus()); + } + + /** + * Return current running WDA's status like below + * { + * "state": "success", + * "os": { + * "name": "iOS", + * "version": "11.4", + * "sdkVersion": "11.3" + * }, + * "ios": { + * "simulatorVersion": "11.4", + * "ip": "172.254.99.34" + * }, + * "build": { + * "time": "Jun 24 2018 17:08:21", + * "productBundleIdentifier": "com.facebook.WebDriverAgentRunner" + * } + * } + * + * @param timeoutMs If zero or negative, returns immediately. Otherwise, waits up to timeoutMs. + */ + private async getStatus (timeoutMs: number = 0): Promise { + const noSessionProxy = new NoSessionProxy({ + server: this.url.hostname ?? undefined, + port: parseInt(this.url.port ?? '', 10) || undefined, + base: this.basePath, + timeout: 3000, + }); + + const sendGetStatus = async () => await noSessionProxy.command('/status', 'GET') as StringRecord; + + if (_.isNil(timeoutMs) || timeoutMs <= 0) { + try { + return await sendGetStatus(); + } catch (err: any) { + this.log.debug(`WDA is not listening at '${this.url.href}'. Original error:: ${err.message}`); + return null; + } + } + + let lastError: any = null; + let status: StringRecord | null = null; + try { + await waitForCondition(async () => { + try { + status = await sendGetStatus(); + return true; + } catch (err) { + lastError = err; + } + return false; + }, { + waitMs: timeoutMs, + intervalMs: 300, + }); + } catch (err: any) { + this.log.debug(`Failed to get the status endpoint in ${timeoutMs} ms. ` + + `The last error while accessing ${this.url.href}: ${lastError}. Original error:: ${err.message}.`); + throw new Error(`WDA was not ready in ${timeoutMs} ms.`); + } + return status; + } + + /** + * Uninstall WDAs from the test device. + * Over Xcode 11, multiple WDA can be in the device since Xcode 11 generates different WDA. + * Appium does not expect multiple WDAs are running on a device. + */ + private async uninstall (): Promise { + try { + const bundleIds = await this.device.getUserInstalledBundleIdsByBundleName(WDA_CF_BUNDLE_NAME); + if (_.isEmpty(bundleIds)) { + this.log.debug('No WDAs on the device.'); + return; + } + + this.log.debug(`Uninstalling WDAs: '${bundleIds}'`); + for (const bundleId of bundleIds) { + await this.device.removeApp(bundleId); + } + } catch (e: any) { + this.log.debug(e); + this.log.warn(`WebDriverAgent uninstall failed. Perhaps, it is already uninstalled? ` + + `Original error: ${e.message}`); + } + } + + private async _cleanupProjectIfFresh (): Promise { + if (this.canSkipXcodebuild) { + return; + } + + const packageInfo = JSON.parse(await fs.readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8')); + const box = strongbox(packageInfo.name); + let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME); + if (!boxItem) { + const timestampPath = path.resolve(process.env.HOME ?? '', WDA_UPGRADE_TIMESTAMP_PATH); + if (await fs.exists(timestampPath)) { + // TODO: It is probably a bit ugly to hardcode the recent version string, + // TODO: hovewer it should do the job as a temporary transition trick + // TODO: to switch from a hardcoded file path to the strongbox usage. + try { + boxItem = await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, '5.0.0'); + } catch (e: any) { + this.log.warn(`The actual module version cannot be persisted: ${e.message}`); + return; + } + } else { + this.log.info('There is no need to perform the project cleanup. A fresh install has been detected'); + try { + await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, packageInfo.version); + } catch (e: any) { + this.log.warn(`The actual module version cannot be persisted: ${e.message}`); + } + return; + } + } + + let recentModuleVersion = await boxItem.read(); + try { + recentModuleVersion = util.coerceVersion(recentModuleVersion, true); + } catch (e: any) { + this.log.warn(`The persisted module version string has been damaged: ${e.message}`); + this.log.info(`Updating it to '${packageInfo.version}' assuming the project clenup is not needed`); + await boxItem.write(packageInfo.version); + return; + } + + if (util.compareVersions(recentModuleVersion, '>=', packageInfo.version)) { + this.log.info( + `WebDriverAgent does not need a cleanup. The project sources are up to date ` + + `(${recentModuleVersion} >= ${packageInfo.version})` + ); + return; + } + + this.log.info( + `Cleaning up the WebDriverAgent project after the module upgrade has happened ` + + `(${recentModuleVersion} < ${packageInfo.version})` + ); + try { + await this.xcodebuild.cleanProject(); + await boxItem.write(packageInfo.version); + } catch (e: any) { + this.log.warn(`Cannot perform WebDriverAgent project cleanup. Original error: ${e.message}`); + } + } + + /** + * Launch WDA with preinstalled package with 'xcrun devicectl device process launch'. + * The WDA package must be prepared properly like published via + * https://github.com/appium/WebDriverAgent/releases + * with proper sign for this case. + * + * When we implement launching XCTest service via appium-ios-device, + * this implementation can be replaced with it. + * + * @param opts launching WDA with devicectl command options. + */ + private async _launchViaDevicectl(opts: {env?: Record} = {}): Promise { + const {env} = opts; + + await this.device.devicectl.launchApp( + this.bundleIdForXctest, { env, terminateExisting: true } + ); + } + + /** + * Launch WDA with preinstalled package without xcodebuild. + * @param sessionId Launch WDA and establish the session with this sessionId + */ + private async launchWithPreinstalledWDA(sessionId: string): Promise { + const xctestEnv: Record = { + USE_PORT: this.wdaLocalPort || WDA_AGENT_PORT, + WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest + }; + if (this.mjpegServerPort) { + xctestEnv.MJPEG_SERVER_PORT = this.mjpegServerPort; + } + if (this.wdaBindingIP) { + xctestEnv.USE_IP = this.wdaBindingIP; + } + this.log.info('Launching WebDriverAgent on the device without xcodebuild'); + if (this.isRealDevice) { + // Current method to launch WDA process can be done via 'xcrun devicectl', + // but it has limitation about the WDA preinstalled package. + // https://github.com/appium/appium/issues/19206#issuecomment-2014182674 + if (this.platformVersion && util.compareVersions(this.platformVersion, '>=', '17.0')) { + await this._launchViaDevicectl({env: xctestEnv}); + } else { + this.xctestApiClient = new Xctest(this.device.udid, this.bundleIdForXctest, null, {env: xctestEnv}); + await this.xctestApiClient.start(); + } + } else { + await this.device.simctl.exec('launch', { + args: [ + '--terminate-running-process', + this.device.udid, + this.bundleIdForXctest, + ], + env: xctestEnv, + }); + } + + this.setupProxies(sessionId); + let status: StringRecord | null; + try { + status = await this.getStatus(this.wdaLaunchTimeout); + } catch { + throw new Error( + `Failed to start the preinstalled WebDriverAgent in ${this.wdaLaunchTimeout} ms. ` + + `The WebDriverAgent might not be properly built or the device might be locked. ` + + `The 'appium:wdaLaunchTimeout' capability modifies the timeout.` + ); + } + this.started = true; + return status; + } +} diff --git a/lib/xcodebuild.js b/lib/xcodebuild.ts similarity index 73% rename from lib/xcodebuild.js rename to lib/xcodebuild.ts index 6e12f65e9..fde24528a 100644 --- a/lib/xcodebuild.js +++ b/lib/xcodebuild.ts @@ -1,7 +1,8 @@ import { retryInterval } from 'asyncbox'; import { SubProcess, exec } from 'teen_process'; import { logger, timing } from '@appium/support'; -import defaultLogger from './logger'; +import type { AppiumLogger, StringRecord } from '@appium/types'; +import { log as defaultLogger } from './logger'; import B from 'bluebird'; import { setRealDeviceSecurity, setXctestrunFile, @@ -11,6 +12,8 @@ import { import _ from 'lodash'; import path from 'path'; import { WDA_RUNNER_BUNDLE_ID } from './constants'; +import type { AppleDevice, XcodeBuildArgs } from './types'; +import type { NoSessionProxy } from './no-session-proxy'; const DEFAULT_SIGNING_ID = 'iPhone Developer'; @@ -42,18 +45,48 @@ const xcodeLog = logger.getLogger('Xcode'); export class XcodeBuild { - /** @type {SubProcess} */ - xcodebuild; + xcodebuild?: SubProcess; + readonly device: AppleDevice; + private readonly log: AppiumLogger; + readonly realDevice: boolean; + readonly agentPath: string; + readonly bootstrapPath: string; + readonly platformVersion?: string; + readonly platformName?: string; + readonly iosSdkVersion?: string; + private readonly showXcodeLog?: boolean; + private readonly xcodeConfigFile?: string; + private readonly xcodeOrgId?: string; + readonly xcodeSigningId: string; + private readonly keychainPath?: string; + private readonly keychainPassword?: string; + usePrebuiltWDA?: boolean; + private readonly useSimpleBuildTest?: boolean; + private readonly useXctestrunFile?: boolean; + private readonly launchTimeout?: number; + private readonly wdaRemotePort?: number; + private readonly wdaBindingIP?: string; + private readonly updatedWDABundleId?: string; + derivedDataPath?: string; + private readonly mjpegServerPort?: number; + private readonly prebuildDelay: number; + private readonly allowProvisioningDeviceRegistration?: boolean; + private readonly resultBundlePath?: string; + private readonly resultBundleVersion?: string; + private _didBuildFail: boolean; + private _didProcessExit: boolean; + private _derivedDataPathPromise?: Promise; + private noSessionProxy?: NoSessionProxy; + private xctestrunFilePath?: string; + agentUrl?: string; /** - * @param {import('appium-xcode').XcodeVersion | undefined} xcodeVersion @deprecated Will be removed as no actual usage. - * @param {import('./types').AppleDevice} device - * @param {import('./types').XcodeBuildArgs} args - * @param {import('@appium/types').AppiumLogger | null} [log=null] + * Creates a new XcodeBuild instance. + * @param device - The Apple device to build for + * @param args - Configuration arguments for xcodebuild + * @param log - Optional logger instance */ - constructor (xcodeVersion, device, args, log = null) { - this.xcodeVersion = xcodeVersion; - + constructor (device: AppleDevice, args: XcodeBuildArgs, log: AppiumLogger | null = null) { this.device = device; this.log = log ?? defaultLogger; @@ -74,7 +107,6 @@ export class XcodeBuild { this.keychainPath = args.keychainPath; this.keychainPassword = args.keychainPassword; - this.prebuildWDA = args.prebuildWDA; this.usePrebuiltWDA = args.usePrebuiltWDA; this.useSimpleBuildTest = args.useSimpleBuildTest; @@ -102,15 +134,14 @@ export class XcodeBuild { } /** - * - * @param {any} noSessionProxy - * @returns {Promise} + * Initializes the XcodeBuild instance with a no-session proxy. + * Sets up xctestrun file if needed, or updates project bundle ID for real devices. + * @param noSessionProxy - The proxy instance for WDA communication */ - async init (noSessionProxy) { + async init (noSessionProxy: NoSessionProxy): Promise { this.noSessionProxy = noSessionProxy; if (this.useXctestrunFile) { - /** @type {import('./types').DeviceInfo} */ const deviceInfo = { isRealDevice: !!this.realDevice, udid: this.device.udid, @@ -142,9 +173,11 @@ export class XcodeBuild { } /** - * @returns {Promise} + * Retrieves the Xcode derived data path for the build. + * Uses cached value if available, otherwise queries xcodebuild for BUILD_DIR. + * @returns The derived data path, or `undefined` if it cannot be determined */ - async retrieveDerivedDataPath () { + async retrieveDerivedDataPath (): Promise { if (this.derivedDataPath) { return this.derivedDataPath; } @@ -155,10 +188,10 @@ export class XcodeBuild { } this._derivedDataPathPromise = (async () => { - let stdout; + let stdout: string; try { ({stdout} = await exec('xcodebuild', ['-project', this.agentPath, '-showBuildSettings'])); - } catch (err) { + } catch (err: any) { this.log.warn(`Cannot retrieve WDA build settings. Original error: ${err.message}`); return; } @@ -179,9 +212,10 @@ export class XcodeBuild { } /** - * @returns {Promise} + * Resets the project file to its original state. + * Restores the bundle ID to the original value for real devices if it was modified. */ - async reset () { + async reset (): Promise { // if necessary, reset the bundleId to original value if (this.realDevice && this.updatedWDABundleId) { await resetProjectFile(this.agentPath); @@ -189,9 +223,10 @@ export class XcodeBuild { } /** - * @returns {Promise} + * Pre-builds WebDriverAgent before launching tests. + * Performs a build-only operation and sets usePrebuiltWDA flag. */ - async prebuild () { + async prebuild (): Promise { // first do a build phase this.log.debug('Pre-building WDA before launching test'); this.usePrebuiltWDA = true; @@ -204,9 +239,10 @@ export class XcodeBuild { } /** - * @returns {Promise} + * Cleans the Xcode project to remove leftovers from previous installs. + * Cleans both the library and runner schemes for the appropriate platform. */ - async cleanProject () { + async cleanProject (): Promise { const libScheme = isTvOS(this.platformName || '') ? LIB_SCHEME_TV : LIB_SCHEME_IOS; const runnerScheme = isTvOS(this.platformName || '') ? RUNNER_SCHEME_TV : RUNNER_SCHEME_IOS; @@ -221,14 +257,74 @@ export class XcodeBuild { } /** - * - * @param {boolean} [buildOnly=false] - * @returns {{cmd: string, args: string[]}} + * Starts the xcodebuild process to build and/or test WebDriverAgent. + * @param buildOnly - If `true`, only builds without running tests. Defaults to `false`. + * @returns The WDA status record if tests are run, `void` if build-only + * @throws Error if xcodebuild fails or cannot start + */ + async start (buildOnly: boolean = false): Promise { + this.xcodebuild = await this.createSubProcess(buildOnly); + + // wrap the start procedure in a promise so that we can catch, and report, + // any startup errors that are thrown as events + if (!this.xcodebuild) { + throw new Error('xcodebuild subprocess was not created'); + } + const xcodebuild = this.xcodebuild; + return await new B((resolve, reject) => { + xcodebuild.once('exit', (code, signal) => { + xcodeLog.error(`xcodebuild exited with code '${code}' and signal '${signal}'`); + xcodebuild.removeAllListeners(); + this._didProcessExit = true; + if (this._didBuildFail || (!signal && code !== 0)) { + let errorMessage = `xcodebuild failed with code ${code}.` + + ` This usually indicates an issue with the local Xcode setup or WebDriverAgent` + + ` project configuration or the driver-to-platform version mismatch.`; + if (!this.showXcodeLog) { + errorMessage += ` Consider setting 'showXcodeLog' capability to true in` + + ` order to check the Appium server log for build-related error messages.`; + } else if (this.realDevice) { + errorMessage += ` Consider checking the WebDriverAgent configuration guide` + + ` for real iOS devices at ${REAL_DEVICES_CONFIG_DOCS_LINK}.`; + } + return reject(new Error(errorMessage)); + } + // in the case of just building, the process will exit and that is our finish + if (buildOnly) { + return resolve(); + } + }); + + return (async () => { + try { + const timer = new timing.Timer().start(); + if (!xcodebuild) { + throw new Error('xcodebuild subprocess was not created'); + } + await xcodebuild.start(true); + if (!buildOnly) { + const result = await this.waitForStart(timer); + resolve(result ?? undefined); + } + } catch (err: any) { + const msg = `Unable to start WebDriverAgent: ${err}`; + this.log.error(msg); + reject(new Error(msg)); + } + })(); + }); + } + + /** + * Stops the xcodebuild process and cleans up resources. */ - getCommand (buildOnly = false) { + async quit (): Promise { + await killProcess('xcodebuild', this.xcodebuild); + } + + private getCommand (buildOnly: boolean = false): {cmd: string; args: string[]} { const cmd = 'xcodebuild'; - /** @type {string[]} */ - const args = []; + const args: string[] = []; // figure out the targets for xcodebuild const [buildCmd, testCmd] = this.useSimpleBuildTest ? ['build', 'test'] : ['build-for-testing', 'test-without-building']; @@ -301,11 +397,7 @@ export class XcodeBuild { return {cmd, args}; } - /** - * @param {boolean} [buildOnly=false] - * @returns {Promise} - */ - async createSubProcess (buildOnly = false) { + private async createSubProcess (buildOnly: boolean = false): Promise { if (!this.useXctestrunFile && this.realDevice) { if (this.keychainPath && this.keychainPassword) { await setRealDeviceSecurity(this.keychainPath, this.keychainPassword); @@ -315,8 +407,7 @@ export class XcodeBuild { const {cmd, args} = this.getCommand(buildOnly); this.log.debug(`Beginning ${buildOnly ? 'build' : 'test'} with command '${cmd} ${args.join(' ')}' ` + `in directory '${this.bootstrapPath}'`); - /** @type {Record} */ - const env = Object.assign({}, process.env, { + const env: Record = Object.assign({}, process.env, { USE_PORT: this.wdaRemotePort, WDA_PRODUCT_BUNDLE_IDENTIFIER: this.updatedWDABundleId || WDA_RUNNER_BUNDLE_ID, }); @@ -345,7 +436,7 @@ export class XcodeBuild { : 'Output from xcodebuild will only be logged if any errors are present there'; this.log.debug(`${logMsg}. To change this, use 'showXcodeLog' desired capability`); - const onStreamLine = (/** @type {string} */ line) => { + const onStreamLine = (line: string) => { if (this.showXcodeLog === false || IGNORED_ERRORS_PATTERN.test(line)) { return; } @@ -368,87 +459,36 @@ export class XcodeBuild { return xcodebuild; } - - /** - * @param {boolean} [buildOnly=false] - * @returns {Promise} - */ - async start (buildOnly = false) { - this.xcodebuild = await this.createSubProcess(buildOnly); - - // wrap the start procedure in a promise so that we can catch, and report, - // any startup errors that are thrown as events - return await new B((resolve, reject) => { - this.xcodebuild.once('exit', (code, signal) => { - xcodeLog.error(`xcodebuild exited with code '${code}' and signal '${signal}'`); - this.xcodebuild.removeAllListeners(); - this.didProcessExit = true; - if (this._didBuildFail || (!signal && code !== 0)) { - let errorMessage = `xcodebuild failed with code ${code}.` + - ` This usually indicates an issue with the local Xcode setup or WebDriverAgent` + - ` project configuration or the driver-to-platform version mismatch.`; - if (!this.showXcodeLog) { - errorMessage += ` Consider setting 'showXcodeLog' capability to true in` + - ` order to check the Appium server log for build-related error messages.`; - } else if (this.realDevice) { - errorMessage += ` Consider checking the WebDriverAgent configuration guide` + - ` for real iOS devices at ${REAL_DEVICES_CONFIG_DOCS_LINK}.`; - } - return reject(new Error(errorMessage)); - } - // in the case of just building, the process will exit and that is our finish - if (buildOnly) { - return resolve(); - } - }); - - return (async () => { - try { - const timer = new timing.Timer().start(); - await this.xcodebuild.start(true); - if (!buildOnly) { - resolve(/** @type {import('@appium/types').StringRecord} */ (await this.waitForStart(timer))); - } - } catch (err) { - let msg = `Unable to start WebDriverAgent: ${err}`; - this.log.error(msg); - reject(new Error(msg)); - } - })(); - }); - } - - /** - * - * @param {any} timer - * @returns {Promise} - */ - async waitForStart (timer) { + private async waitForStart (timer: timing.Timer): Promise { // try to connect once every 0.5 seconds, until `launchTimeout` is up const timeout = this.launchTimeout || 60000; // Default to 60 seconds if not set this.log.debug(`Waiting up to ${timeout}ms for WebDriverAgent to start`); - let currentStatus = null; + let currentStatus: StringRecord | null = null; try { const retries = Math.trunc(timeout / 500); + if (!this.noSessionProxy) { + throw new Error('noSessionProxy was not initialized'); + } + const noSessionProxy = this.noSessionProxy; await retryInterval(retries, 1000, async () => { if (this._didProcessExit) { // there has been an error elsewhere and we need to short-circuit return currentStatus; } - const proxyTimeout = this.noSessionProxy.timeout; - this.noSessionProxy.timeout = 1000; + const proxyTimeout = noSessionProxy.timeout; + noSessionProxy.timeout = 1000; try { - currentStatus = await this.noSessionProxy.command('/status', 'GET'); - if (currentStatus && currentStatus.ios && currentStatus.ios.ip) { - this.agentUrl = currentStatus.ios.ip; + currentStatus = await noSessionProxy.command('/status', 'GET') as StringRecord; + if (currentStatus && currentStatus.ios && (currentStatus.ios as any).ip) { + this.agentUrl = (currentStatus.ios as any).ip; } this.log.debug(`WebDriverAgent information:`); this.log.debug(JSON.stringify(currentStatus, null, 2)); - } catch (err) { + } catch (err: any) { throw new Error(`Unable to connect to running WebDriverAgent: ${err.message}`); } finally { - this.noSessionProxy.timeout = proxyTimeout; + noSessionProxy.timeout = proxyTimeout; } }); @@ -458,7 +498,7 @@ export class XcodeBuild { } this.log.debug(`WebDriverAgent successfully started after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); - } catch (err) { + } catch (err: any) { this.log.debug(err.stack); throw new Error( `We were not able to retrieve the /status response from the WebDriverAgent server after ${timeout}ms timeout.` + @@ -467,13 +507,4 @@ export class XcodeBuild { } return currentStatus; } - - /** - * @returns {Promise} - */ - async quit () { - await killProcess('xcodebuild', this.xcodebuild); - } } - -export default XcodeBuild; diff --git a/package.json b/package.json index 318a64eb2..9a6d46170 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.ts\"", "e2e-test": "mocha --exit --timeout 10m \"./test/functional/**/*-specs.ts\"", "bundle": "npm run bundle:ios && npm run bundle:tv", - "bundle:ios": "TARGET=runner SDK=sim node ./Scripts/build-webdriveragent.js", - "bundle:tv": "TARGET=tv_runner SDK=tv_sim node ./Scripts/build-webdriveragent.js", - "fetch-prebuilt-wda": "node ./Scripts/fetch-prebuilt-wda.js", - "sync-wda-version": "node ./scripts/update-wda-version.js --package-version=${npm_package_version} && git add WebDriverAgentLib/Info.plist" + "bundle:ios": "TARGET=runner SDK=sim node ./Scripts/build-webdriveragent.mjs", + "bundle:tv": "TARGET=tv_runner SDK=tv_sim node ./Scripts/build-webdriveragent.mjs", + "fetch-prebuilt-wda": "node ./Scripts/fetch-prebuilt-wda.mjs", + "sync-wda-version": "node ./Scripts/update-wda-version.mjs --package-version=${npm_package_version} && git add WebDriverAgentLib/Info.plist" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0", @@ -86,11 +86,9 @@ "files": [ "index.ts", "lib", - "build/index.*", - "build/lib", + "build", "Scripts/build.sh", - "Scripts/fetch-prebuilt-wda.js", - "Scripts/build-webdriveragent.js", + "Scripts/*.mjs", "Configurations", "PrivateHeaders", "WebDriverAgent.xcodeproj", diff --git a/test/functional/helpers/simulator.ts b/test/functional/helpers/simulator.ts index 699d9405f..b79828c5d 100644 --- a/test/functional/helpers/simulator.ts +++ b/test/functional/helpers/simulator.ts @@ -6,10 +6,6 @@ import { resetTestProcesses } from '../../../lib/utils'; import type { AppleDevice } from '../../../lib/types'; export async function killAllSimulators (): Promise { - if (process.env.CLOUD) { - return; - } - const simctl = new Simctl(); const allDevices = _.flatMap(_.values(await simctl.getDevices())); const bootedDevices = allDevices.filter((device) => device.state === 'Booted'); diff --git a/test/functional/webdriveragent-e2e-specs.ts b/test/functional/webdriveragent-e2e-specs.ts index 70a0361d8..3e2f8b5d7 100644 --- a/test/functional/webdriveragent-e2e-specs.ts +++ b/test/functional/webdriveragent-e2e-specs.ts @@ -1,8 +1,6 @@ import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { Simctl } from 'node-simctl'; -import { getVersion } from 'appium-xcode'; -import type { XcodeVersion } from 'appium-xcode'; import { getSimulator } from 'appium-ios-simulator'; import { killAllSimulators, shutdownSimulator } from './helpers/simulator'; import { SubProcess } from 'teen_process'; @@ -36,16 +34,7 @@ function getStartOpts (device: AppleDevice) { describe('WebDriverAgent', function () { this.timeout(MOCHA_TIMEOUT_MS); - let xcodeVersion: XcodeVersion | undefined; - before(async function () { - // Don't do these tests on Sauce Labs - if (process.env.CLOUD) { - this.skip(); - } - - xcodeVersion = await getVersion(true); - }); describe('with fresh sim', function () { let device: AppleDevice; let simctl: Simctl; @@ -60,7 +49,7 @@ describe('WebDriverAgent', function () { device = await getSimulator(simctl.udid); // Prebuild WDA - const wda = new WebDriverAgent(xcodeVersion, { + const wda = new WebDriverAgent({ iosSdkVersion: PLATFORM_VERSION, platformVersion: PLATFORM_VERSION, showXcodeLog: true, @@ -94,7 +83,7 @@ describe('WebDriverAgent', function () { }); it('should launch agent on a sim', async function () { - const agent = new WebDriverAgent(xcodeVersion, getStartOpts(device)); + const agent = new WebDriverAgent(getStartOpts(device)); await agent.launch('sessionId'); await expect(axios({url: testUrl})).to.be.rejected; @@ -105,12 +94,8 @@ describe('WebDriverAgent', function () { // short timeout this.timeout(35 * 1000); - const agent = new WebDriverAgent(xcodeVersion, getStartOpts(device)); - - if (!agent.xcodebuild) { - throw new Error('xcodebuild is null'); - } - agent.xcodebuild.createSubProcess = async function () { + const agent = new WebDriverAgent(getStartOpts(device)); + (agent.xcodebuild as any).createSubProcess = async function () { const args = [ '-workspace', `${this.agentPath}dfgs`, diff --git a/test/unit/webdriveragent-specs.ts b/test/unit/webdriveragent-specs.ts index 0d4f80f5c..eb3bb3640 100644 --- a/test/unit/webdriveragent-specs.ts +++ b/test/unit/webdriveragent-specs.ts @@ -32,19 +32,19 @@ describe('WebDriverAgent', function () { describe('Constructor', function () { it('should have a default wda agent if not specified', function () { - const agent = new WebDriverAgent(undefined, fakeConstructorArgs); + const agent = new WebDriverAgent(fakeConstructorArgs); expect(agent.bootstrapPath).to.eql(BOOTSTRAP_PATH); expect(agent.agentPath).to.eql(defaultAgentPath); }); it('should have custom wda bootstrap and default agent if only bootstrap specified', function () { - const agent = new WebDriverAgent(undefined, _.defaults({ + const agent = new WebDriverAgent(_.defaults({ bootstrapPath: customBootstrapPath, }, fakeConstructorArgs)); expect(agent.bootstrapPath).to.eql(customBootstrapPath); expect(agent.agentPath).to.eql(path.resolve(customBootstrapPath, 'WebDriverAgent.xcodeproj')); }); it('should have custom wda bootstrap and agent if both specified', function () { - const agent = new WebDriverAgent(undefined, _.defaults({ + const agent = new WebDriverAgent(_.defaults({ bootstrapPath: customBootstrapPath, agentPath: customAgentPath, }, fakeConstructorArgs)); @@ -52,7 +52,7 @@ describe('WebDriverAgent', function () { expect(agent.agentPath).to.eql(customAgentPath); }); it('should have custom derivedDataPath if specified', function () { - const agent = new WebDriverAgent(undefined, _.defaults({ + const agent = new WebDriverAgent(_.defaults({ derivedDataPath: customDerivedDataPath }, fakeConstructorArgs)); if (agent.xcodebuild) { @@ -66,8 +66,8 @@ describe('launch', function () { const override = 'http://mockurl:8100/'; const args = Object.assign({}, fakeConstructorArgs); args.webDriverAgentUrl = override; - const agent = new WebDriverAgent(undefined, args); - const wdaStub = sinon.stub(agent, 'getStatus'); + const agent = new WebDriverAgent(args); + const wdaStub = sinon.stub(agent as any, 'getStatus'); wdaStub.callsFake(function () { return {build: 'data'}; }); @@ -95,8 +95,8 @@ describe('use wda proxy url', function () { const override = 'http://127.0.0.1:8100/aabbccdd'; const args = Object.assign({}, fakeConstructorArgs); args.webDriverAgentUrl = override; - const agent = new WebDriverAgent(undefined, args); - const wdaStub = sinon.stub(agent, 'getStatus'); + const agent = new WebDriverAgent(args); + const wdaStub = sinon.stub(agent as any, 'getStatus'); wdaStub.callsFake(function () { return {build: 'data'}; }); @@ -124,9 +124,9 @@ describe('use wda proxy url', function () { describe('get url', function () { it('should use default WDA listening url', function () { const args = Object.assign({}, fakeConstructorArgs); - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.url.href).to.eql('http://127.0.0.1:8100/'); - agent.setupProxies('mysession'); + (agent as any).setupProxies('mysession'); if (agent.jwproxy) { expect(agent.jwproxy.scheme).to.eql('http'); } @@ -142,9 +142,9 @@ describe('get url', function () { args.wdaBaseUrl = wdaBaseUrl; args.wdaLocalPort = parseInt(wdaLocalPort, 10); - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.url.href).to.eql('http://127.0.0.1:9100/'); - agent.setupProxies('mysession'); + (agent as any).setupProxies('mysession'); if (agent.jwproxy) { expect(agent.jwproxy.scheme).to.eql('http'); } @@ -160,9 +160,9 @@ describe('get url', function () { args.wdaBaseUrl = wdaBaseUrl; args.wdaLocalPort = parseInt(wdaLocalPort, 10); - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.url.href).to.eql('http://mockurl:9100/'); - agent.setupProxies('mysession'); + (agent as any).setupProxies('mysession'); if (agent.jwproxy) { expect(agent.jwproxy.scheme).to.eql('http'); } @@ -178,9 +178,9 @@ describe('get url', function () { args.wdaBaseUrl = wdaBaseUrl; args.wdaLocalPort = parseInt(wdaLocalPort, 10); - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.url.href).to.eql('http://mockurl:9100/'); - agent.setupProxies('mysession'); + (agent as any).setupProxies('mysession'); if (agent.jwproxy) { expect(agent.jwproxy.scheme).to.eql('http'); } @@ -194,14 +194,14 @@ describe('get url', function () { args.wdaLocalPort = 9100; args.webDriverAgentUrl = 'https://127.0.0.1:8100/'; - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.url.href).to.eql('https://127.0.0.1:8100/'); }); it('should set scheme to https for https webDriverAgentUrl', function () { const args = Object.assign({}, fakeConstructorArgs); args.webDriverAgentUrl = 'https://127.0.0.1:8100/'; - const agent = new WebDriverAgent(undefined, args); - agent.setupProxies('mysession'); + const agent = new WebDriverAgent(args); + (agent as any).setupProxies('mysession'); if (agent.jwproxy) { expect(agent.jwproxy.scheme).to.eql('https'); } @@ -218,9 +218,9 @@ describe('setupCaching()', function () { const getTimestampStub = sinon.stub(utils, 'getWDAUpgradeTimestamp'); beforeEach(function () { - wda = new WebDriverAgent(undefined, fakeConstructorArgs); + wda = new WebDriverAgent(fakeConstructorArgs); wdaStub = sinon.stub(wda, 'getStatus'); - wdaStubUninstall = sinon.stub(wda, 'uninstall'); + wdaStubUninstall = sinon.stub(wda as any, 'uninstall'); }); afterEach(function () { @@ -281,9 +281,9 @@ describe('setupCaching()', function () { }); it('should not call uninstall since bundle id is equal to updatedWDABundleId capability', async function () { - wda = new WebDriverAgent(undefined, { ...fakeConstructorArgs, updatedWDABundleId: 'com.example.WebDriverAgent' }); + wda = new WebDriverAgent({ ...fakeConstructorArgs, updatedWDABundleId: 'com.example.WebDriverAgent' }); wdaStub = sinon.stub(wda, 'getStatus'); - wdaStubUninstall = sinon.stub(wda, 'uninstall'); + wdaStubUninstall = sinon.stub(wda as any, 'uninstall'); wdaStub.callsFake(function () { return {build: { time: 'Jun 24 2018 17:08:21', productBundleIdentifier: 'com.example.WebDriverAgent' }}; @@ -356,7 +356,7 @@ describe('setupCaching()', function () { getUserInstalledBundleIdsByBundleName: () => {}, removeApp: () => {} } as any; - wda = new WebDriverAgent(undefined, {device} as WebDriverAgentArgs); + wda = new WebDriverAgent({device} as WebDriverAgentArgs); deviceGetBundleIdsStub = sinon.stub(device, 'getUserInstalledBundleIdsByBundleName'); deviceRemoveAppStub = sinon.stub(device, 'removeApp'); }); @@ -372,7 +372,7 @@ describe('setupCaching()', function () { it('should not call uninstall', async function () { deviceGetBundleIdsStub.callsFake(() => []); - await wda.uninstall(); + await (wda as any).uninstall(); expect(deviceGetBundleIdsStub.calledOnce).to.be.true; expect(deviceRemoveAppStub.notCalled).to.be.true; }); @@ -382,7 +382,7 @@ describe('setupCaching()', function () { deviceGetBundleIdsStub.callsFake(() => ['com.appium.WDA1']); deviceRemoveAppStub.callsFake((id: string) => uninstalledBundIds.push(id)); - await wda.uninstall(); + await (wda as any).uninstall(); expect(deviceGetBundleIdsStub.calledOnce).to.be.true; expect(deviceRemoveAppStub.calledOnce).to.be.true; expect(uninstalledBundIds).to.eql(['com.appium.WDA1']); @@ -393,7 +393,7 @@ describe('setupCaching()', function () { deviceGetBundleIdsStub.callsFake(() => ['com.appium.WDA1', 'com.appium.WDA2']); deviceRemoveAppStub.callsFake((id: string) => uninstalledBundIds.push(id)); - await wda.uninstall(); + await (wda as any).uninstall(); expect(deviceGetBundleIdsStub.calledOnce).to.be.true; expect(deviceRemoveAppStub.calledTwice).to.be.true; expect(uninstalledBundIds).to.eql(['com.appium.WDA1', 'com.appium.WDA2']); @@ -407,13 +407,13 @@ describe('usePreinstalledWDA related functions', function () { it('should have xctrunner automatically', function () { const args = Object.assign({}, fakeConstructorArgs); args.updatedWDABundleId = 'io.appium.wda'; - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.bundleIdForXctest).to.equal('io.appium.wda.xctrunner'); }); it('should have xctrunner automatically with default bundle id', function () { const args = Object.assign({}, fakeConstructorArgs); - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.bundleIdForXctest).to.equal('com.facebook.WebDriverAgentRunner.xctrunner'); }); @@ -421,14 +421,14 @@ describe('usePreinstalledWDA related functions', function () { const args = Object.assign({}, fakeConstructorArgs); args.updatedWDABundleId = 'io.appium.wda'; args.updatedWDABundleIdSuffix = ''; - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.bundleIdForXctest).to.equal('io.appium.wda'); }); it('should allow an empty string as xctrunner suffix with default bundle id', function () { const args = Object.assign({}, fakeConstructorArgs); args.updatedWDABundleIdSuffix = ''; - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.bundleIdForXctest).to.equal('com.facebook.WebDriverAgentRunner'); }); @@ -436,7 +436,7 @@ describe('usePreinstalledWDA related functions', function () { const args = Object.assign({}, fakeConstructorArgs); args.updatedWDABundleId = 'io.appium.wda'; args.updatedWDABundleIdSuffix = '.customsuffix'; - const agent = new WebDriverAgent(undefined, args); + const agent = new WebDriverAgent(args); expect(agent.bundleIdForXctest).to.equal('io.appium.wda.customsuffix'); });