utils.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { fs, plist } from '@appium/support';
  2. import { exec } from 'teen_process';
  3. import path from 'path';
  4. import log from './logger';
  5. import _ from 'lodash';
  6. import { WDA_RUNNER_BUNDLE_ID, PLATFORM_NAME_TVOS } from './constants';
  7. import B from 'bluebird';
  8. import _fs from 'fs';
  9. import { waitForCondition } from 'asyncbox';
  10. import { arch } from 'os';
  11. const PROJECT_FILE = 'project.pbxproj';
  12. /**
  13. * Calculates the path to the current module's root folder
  14. *
  15. * @returns {string} The full path to module root
  16. * @throws {Error} If the current module root folder cannot be determined
  17. */
  18. const getModuleRoot = _.memoize(function getModuleRoot () {
  19. let currentDir = path.dirname(path.resolve(__filename));
  20. let isAtFsRoot = false;
  21. while (!isAtFsRoot) {
  22. const manifestPath = path.join(currentDir, 'package.json');
  23. try {
  24. if (_fs.existsSync(manifestPath) &&
  25. JSON.parse(_fs.readFileSync(manifestPath, 'utf8')).name === 'appium-webdriveragent') {
  26. return currentDir;
  27. }
  28. } catch (ign) {}
  29. currentDir = path.dirname(currentDir);
  30. isAtFsRoot = currentDir.length <= path.dirname(currentDir).length;
  31. }
  32. throw new Error('Cannot find the root folder of the appium-webdriveragent Node.js module');
  33. });
  34. export const BOOTSTRAP_PATH = getModuleRoot();
  35. async function getPIDsUsingPattern (pattern) {
  36. const args = [
  37. '-if', // case insensitive, full cmdline match
  38. pattern,
  39. ];
  40. try {
  41. const {stdout} = await exec('pgrep', args);
  42. return stdout.split(/\s+/)
  43. .map((x) => parseInt(x, 10))
  44. .filter(_.isInteger)
  45. .map((x) => `${x}`);
  46. } catch (err) {
  47. log.debug(`'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`);
  48. return [];
  49. }
  50. }
  51. async function killAppUsingPattern (pgrepPattern) {
  52. const signals = [2, 15, 9];
  53. for (const signal of signals) {
  54. const matchedPids = await getPIDsUsingPattern(pgrepPattern);
  55. if (_.isEmpty(matchedPids)) {
  56. return;
  57. }
  58. const args = [`-${signal}`, ...matchedPids];
  59. try {
  60. await exec('kill', args);
  61. } catch (err) {
  62. log.debug(`kill ${args.join(' ')} -> ${err.message}`);
  63. }
  64. if (signal === _.last(signals)) {
  65. // there is no need to wait after SIGKILL
  66. return;
  67. }
  68. try {
  69. await waitForCondition(async () => {
  70. const pidCheckPromises = matchedPids
  71. .map((pid) => exec('kill', ['-0', pid])
  72. // the process is still alive
  73. .then(() => false)
  74. // the process is dead
  75. .catch(() => true)
  76. );
  77. return (await B.all(pidCheckPromises))
  78. .every((x) => x === true);
  79. }, {
  80. waitMs: 1000,
  81. intervalMs: 100,
  82. });
  83. return;
  84. } catch (ign) {
  85. // try the next signal
  86. }
  87. }
  88. }
  89. /**
  90. * Return true if the platformName is tvOS
  91. * @param {string} platformName The name of the platorm
  92. * @returns {boolean} Return true if the platformName is tvOS
  93. */
  94. function isTvOS (platformName) {
  95. return _.toLower(platformName) === _.toLower(PLATFORM_NAME_TVOS);
  96. }
  97. async function replaceInFile (file, find, replace) {
  98. let contents = await fs.readFile(file, 'utf8');
  99. let newContents = contents.replace(find, replace);
  100. if (newContents !== contents) {
  101. await fs.writeFile(file, newContents, 'utf8');
  102. }
  103. }
  104. /**
  105. * Update WebDriverAgentRunner project bundle ID with newBundleId.
  106. * This method assumes project file is in the correct state.
  107. * @param {string} agentPath - Path to the .xcodeproj directory.
  108. * @param {string} newBundleId the new bundle ID used to update.
  109. */
  110. async function updateProjectFile (agentPath, newBundleId) {
  111. let projectFilePath = path.resolve(agentPath, PROJECT_FILE);
  112. try {
  113. // Assuming projectFilePath is in the correct state, create .old from projectFilePath
  114. await fs.copyFile(projectFilePath, `${projectFilePath}.old`);
  115. await replaceInFile(projectFilePath, new RegExp(_.escapeRegExp(WDA_RUNNER_BUNDLE_ID), 'g'), newBundleId); // eslint-disable-line no-useless-escape
  116. log.debug(`Successfully updated '${projectFilePath}' with bundle id '${newBundleId}'`);
  117. } catch (err) {
  118. log.debug(`Error updating project file: ${err.message}`);
  119. log.warn(`Unable to update project file '${projectFilePath}' with ` +
  120. `bundle id '${newBundleId}'. WebDriverAgent may not start`);
  121. }
  122. }
  123. /**
  124. * Reset WebDriverAgentRunner project bundle ID to correct state.
  125. * @param {string} agentPath - Path to the .xcodeproj directory.
  126. */
  127. async function resetProjectFile (agentPath) {
  128. const projectFilePath = path.join(agentPath, PROJECT_FILE);
  129. try {
  130. // restore projectFilePath from .old file
  131. if (!await fs.exists(`${projectFilePath}.old`)) {
  132. return; // no need to reset
  133. }
  134. await fs.mv(`${projectFilePath}.old`, projectFilePath);
  135. log.debug(`Successfully reset '${projectFilePath}' with bundle id '${WDA_RUNNER_BUNDLE_ID}'`);
  136. } catch (err) {
  137. log.debug(`Error resetting project file: ${err.message}`);
  138. log.warn(`Unable to reset project file '${projectFilePath}' with ` +
  139. `bundle id '${WDA_RUNNER_BUNDLE_ID}'. WebDriverAgent has been ` +
  140. `modified and not returned to the original state.`);
  141. }
  142. }
  143. async function setRealDeviceSecurity (keychainPath, keychainPassword) {
  144. log.debug('Setting security for iOS device');
  145. await exec('security', ['-v', 'list-keychains', '-s', keychainPath]);
  146. await exec('security', ['-v', 'unlock-keychain', '-p', keychainPassword, keychainPath]);
  147. await exec('security', ['set-keychain-settings', '-t', '3600', '-l', keychainPath]);
  148. }
  149. /**
  150. * Information of the device under test
  151. * @typedef {Object} DeviceInfo
  152. * @property {string} isRealDevice - Equals to true if the current device is a real device
  153. * @property {string} udid - The device UDID.
  154. * @property {string} platformVersion - The platform version of OS.
  155. * @property {string} platformName - The platform name of iOS, tvOS
  156. */
  157. /**
  158. * Creates xctestrun file per device & platform version.
  159. * We expects to have WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device
  160. * and WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-${x86_64|arm64}.xctestrun for simulator located @bootstrapPath
  161. * Newer Xcode (Xcode 10.0 at least) generate xctestrun file following sdkVersion.
  162. * e.g. Xcode which has iOS SDK Version 12.2 on an intel Mac host machine generates WebDriverAgentRunner_iphonesimulator.2-x86_64.xctestrun
  163. * even if the cap has platform version 11.4
  164. *
  165. * @param {DeviceInfo} deviceInfo
  166. * @param {string} sdkVersion - The Xcode SDK version of OS.
  167. * @param {string} bootstrapPath - The folder path containing xctestrun file.
  168. * @param {number|string} wdaRemotePort - The remote port WDA is listening on.
  169. * @return {Promise<string>} returns xctestrunFilePath for given device
  170. * @throws if WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device
  171. * or WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-x86_64.xctestrun for simulator is not found @bootstrapPath,
  172. * then it will throw file not found exception
  173. */
  174. async function setXctestrunFile (deviceInfo, sdkVersion, bootstrapPath, wdaRemotePort) {
  175. const xctestrunFilePath = await getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath);
  176. const xctestRunContent = await plist.parsePlistFile(xctestrunFilePath);
  177. const updateWDAPort = getAdditionalRunContent(deviceInfo.platformName, wdaRemotePort);
  178. const newXctestRunContent = _.merge(xctestRunContent, updateWDAPort);
  179. await plist.updatePlistFile(xctestrunFilePath, newXctestRunContent, true);
  180. return xctestrunFilePath;
  181. }
  182. /**
  183. * Return the WDA object which appends existing xctest runner content
  184. * @param {string} platformName - The name of the platform
  185. * @param {number|string} wdaRemotePort - The remote port number
  186. * @return {object} returns a runner object which has USE_PORT
  187. */
  188. function getAdditionalRunContent (platformName, wdaRemotePort) {
  189. const runner = `WebDriverAgentRunner${isTvOS(platformName) ? '_tvOS' : ''}`;
  190. return {
  191. [runner]: {
  192. EnvironmentVariables: {
  193. // USE_PORT must be 'string'
  194. USE_PORT: `${wdaRemotePort}`
  195. }
  196. }
  197. };
  198. }
  199. /**
  200. * Return the path of xctestrun if it exists
  201. * @param {DeviceInfo} deviceInfo
  202. * @param {string} sdkVersion - The Xcode SDK version of OS.
  203. * @param {string} bootstrapPath - The folder path containing xctestrun file.
  204. * @returns {Promise<string>}
  205. */
  206. async function getXctestrunFilePath (deviceInfo, sdkVersion, bootstrapPath) {
  207. // First try the SDK path, for Xcode 10 (at least)
  208. const sdkBased = [
  209. path.resolve(bootstrapPath, `${deviceInfo.udid}_${sdkVersion}.xctestrun`),
  210. sdkVersion,
  211. ];
  212. // Next try Platform path, for earlier Xcode versions
  213. const platformBased = [
  214. path.resolve(bootstrapPath, `${deviceInfo.udid}_${deviceInfo.platformVersion}.xctestrun`),
  215. deviceInfo.platformVersion,
  216. ];
  217. for (const [filePath, version] of [sdkBased, platformBased]) {
  218. if (await fs.exists(filePath)) {
  219. log.info(`Using '${filePath}' as xctestrun file`);
  220. return filePath;
  221. }
  222. const originalXctestrunFile = path.resolve(bootstrapPath, getXctestrunFileName(deviceInfo, version));
  223. if (await fs.exists(originalXctestrunFile)) {
  224. // If this is first time run for given device, then first generate xctestrun file for device.
  225. // We need to have a xctestrun file **per device** because we cant not have same wda port for all devices.
  226. await fs.copyFile(originalXctestrunFile, filePath);
  227. log.info(`Using '${filePath}' as xctestrun file copied by '${originalXctestrunFile}'`);
  228. return filePath;
  229. }
  230. }
  231. throw new Error(
  232. `If you are using 'useXctestrunFile' capability then you ` +
  233. `need to have a xctestrun file (expected: ` +
  234. `'${path.resolve(bootstrapPath, getXctestrunFileName(deviceInfo, sdkVersion))}')`
  235. );
  236. }
  237. /**
  238. * Return the name of xctestrun file
  239. * @param {DeviceInfo} deviceInfo
  240. * @param {string} version - The Xcode SDK version of OS.
  241. * @return {string} returns xctestrunFilePath for given device
  242. */
  243. function getXctestrunFileName (deviceInfo, version) {
  244. const archSuffix = deviceInfo.isRealDevice
  245. ? `os${version}-arm64`
  246. : `simulator${version}-${arch() === 'arm64' ? 'arm64' : 'x86_64'}`;
  247. return `WebDriverAgentRunner_${isTvOS(deviceInfo.platformName) ? 'tvOS_appletv' : 'iphone'}${archSuffix}.xctestrun`;
  248. }
  249. /**
  250. * Ensures the process is killed after the timeout
  251. *
  252. * @param {string} name
  253. * @param {import('teen_process').SubProcess} proc
  254. * @returns {Promise<void>}
  255. */
  256. async function killProcess (name, proc) {
  257. if (!proc || !proc.isRunning) {
  258. return;
  259. }
  260. log.info(`Shutting down '${name}' process (pid '${proc.proc?.pid}')`);
  261. log.info(`Sending 'SIGTERM'...`);
  262. try {
  263. await proc.stop('SIGTERM', 1000);
  264. return;
  265. } catch (err) {
  266. if (!err.message.includes(`Process didn't end after`)) {
  267. throw err;
  268. }
  269. log.debug(`${name} process did not end in a timely fashion: '${err.message}'.`);
  270. }
  271. log.info(`Sending 'SIGKILL'...`);
  272. try {
  273. await proc.stop('SIGKILL');
  274. } catch (err) {
  275. if (err.message.includes('not currently running')) {
  276. // the process ended but for some reason we were not informed
  277. return;
  278. }
  279. throw err;
  280. }
  281. }
  282. /**
  283. * Generate a random integer.
  284. *
  285. * @return {number} A random integer number in range [low, hight). `low`` is inclusive and `high` is exclusive.
  286. */
  287. function randomInt (low, high) {
  288. return Math.floor(Math.random() * (high - low) + low);
  289. }
  290. /**
  291. * Retrieves WDA upgrade timestamp
  292. *
  293. * @return {Promise<number?>} The UNIX timestamp of the package manifest. The manifest only gets modified on
  294. * package upgrade.
  295. */
  296. async function getWDAUpgradeTimestamp () {
  297. const packageManifest = path.resolve(getModuleRoot(), 'package.json');
  298. if (!await fs.exists(packageManifest)) {
  299. return null;
  300. }
  301. const {mtime} = await fs.stat(packageManifest);
  302. return mtime.getTime();
  303. }
  304. /**
  305. * Kills running XCTest processes for the particular device.
  306. *
  307. * @param {string} udid - The device UDID.
  308. * @param {boolean} isSimulator - Equals to true if the current device is a Simulator
  309. */
  310. async function resetTestProcesses (udid, isSimulator) {
  311. const processPatterns = [`xcodebuild.*${udid}`];
  312. if (isSimulator) {
  313. processPatterns.push(`${udid}.*XCTRunner`);
  314. // The pattern to find in case idb was used
  315. processPatterns.push(`xctest.*${udid}`);
  316. }
  317. log.debug(`Killing running processes '${processPatterns.join(', ')}' for the device ${udid}...`);
  318. await B.all(processPatterns.map(killAppUsingPattern));
  319. }
  320. /**
  321. * Get the IDs of processes listening on the particular system port.
  322. * It is also possible to apply additional filtering based on the
  323. * process command line.
  324. *
  325. * @param {string|number} port - The port number.
  326. * @param {?Function} filteringFunc - Optional lambda function, which
  327. * receives command line string of the particular process
  328. * listening on given port, and is expected to return
  329. * either true or false to include/exclude the corresponding PID
  330. * from the resulting array.
  331. * @returns {Promise<string[]>} - the list of matched process ids.
  332. */
  333. async function getPIDsListeningOnPort (port, filteringFunc = null) {
  334. const result = [];
  335. try {
  336. // This only works since Mac OS X El Capitan
  337. const {stdout} = await exec('lsof', ['-ti', `tcp:${port}`]);
  338. result.push(...(stdout.trim().split(/\n+/)));
  339. } catch (e) {
  340. if (e.code !== 1) {
  341. // code 1 means no processes. Other errors need reporting
  342. log.debug(`Error getting processes listening on port '${port}': ${e.stderr || e.message}`);
  343. }
  344. return result;
  345. }
  346. if (!_.isFunction(filteringFunc)) {
  347. return result;
  348. }
  349. return await B.filter(result, async (pid) => {
  350. let stdout;
  351. try {
  352. ({stdout} = await exec('ps', ['-p', pid, '-o', 'command']));
  353. } catch (e) {
  354. if (e.code === 1) {
  355. // The process does not exist anymore, there's nothing to filter
  356. return false;
  357. }
  358. throw e;
  359. }
  360. return await filteringFunc(stdout);
  361. });
  362. }
  363. export { updateProjectFile, resetProjectFile, setRealDeviceSecurity,
  364. getAdditionalRunContent, getXctestrunFileName,
  365. setXctestrunFile, getXctestrunFilePath, killProcess, randomInt,
  366. getWDAUpgradeTimestamp, resetTestProcesses,
  367. getPIDsListeningOnPort, killAppUsingPattern, isTvOS
  368. };