xcodebuild.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import { retryInterval } from 'asyncbox';
  2. import { SubProcess, exec } from 'teen_process';
  3. import { logger, timing } from '@appium/support';
  4. import defaultLogger from './logger';
  5. import B from 'bluebird';
  6. import {
  7. setRealDeviceSecurity, setXctestrunFile,
  8. updateProjectFile, resetProjectFile, killProcess,
  9. getWDAUpgradeTimestamp, isTvOS
  10. } from './utils';
  11. import _ from 'lodash';
  12. import path from 'path';
  13. import { EOL } from 'os';
  14. import { WDA_RUNNER_BUNDLE_ID } from './constants';
  15. const DEFAULT_SIGNING_ID = 'iPhone Developer';
  16. const PREBUILD_DELAY = 0;
  17. const RUNNER_SCHEME_IOS = 'WebDriverAgentRunner';
  18. const LIB_SCHEME_IOS = 'WebDriverAgentLib';
  19. const ERROR_WRITING_ATTACHMENT = 'Error writing attachment data to file';
  20. const ERROR_COPYING_ATTACHMENT = 'Error copying testing attachment';
  21. const IGNORED_ERRORS = [
  22. ERROR_WRITING_ATTACHMENT,
  23. ERROR_COPYING_ATTACHMENT,
  24. 'Failed to remove screenshot at path',
  25. ];
  26. const RUNNER_SCHEME_TV = 'WebDriverAgentRunner_tvOS';
  27. const LIB_SCHEME_TV = 'WebDriverAgentLib_tvOS';
  28. const xcodeLog = logger.getLogger('Xcode');
  29. class XcodeBuild {
  30. /** @type {SubProcess} */
  31. xcodebuild;
  32. /**
  33. * @param {string} xcodeVersion
  34. * @param {any} device
  35. * @param {any} args
  36. * @param {import('@appium/types').AppiumLogger?} log
  37. */
  38. constructor (xcodeVersion, device, args = {}, log = null) {
  39. this.xcodeVersion = xcodeVersion;
  40. this.device = device;
  41. this.log = log ?? defaultLogger;
  42. this.realDevice = args.realDevice;
  43. this.agentPath = args.agentPath;
  44. this.bootstrapPath = args.bootstrapPath;
  45. this.platformVersion = args.platformVersion;
  46. this.platformName = args.platformName;
  47. this.iosSdkVersion = args.iosSdkVersion;
  48. this.showXcodeLog = args.showXcodeLog;
  49. this.xcodeConfigFile = args.xcodeConfigFile;
  50. this.xcodeOrgId = args.xcodeOrgId;
  51. this.xcodeSigningId = args.xcodeSigningId || DEFAULT_SIGNING_ID;
  52. this.keychainPath = args.keychainPath;
  53. this.keychainPassword = args.keychainPassword;
  54. this.prebuildWDA = args.prebuildWDA;
  55. this.usePrebuiltWDA = args.usePrebuiltWDA;
  56. this.useSimpleBuildTest = args.useSimpleBuildTest;
  57. this.useXctestrunFile = args.useXctestrunFile;
  58. this.launchTimeout = args.launchTimeout;
  59. this.wdaRemotePort = args.wdaRemotePort;
  60. this.updatedWDABundleId = args.updatedWDABundleId;
  61. this.derivedDataPath = args.derivedDataPath;
  62. this.mjpegServerPort = args.mjpegServerPort;
  63. this.prebuildDelay = _.isNumber(args.prebuildDelay) ? args.prebuildDelay : PREBUILD_DELAY;
  64. this.allowProvisioningDeviceRegistration = args.allowProvisioningDeviceRegistration;
  65. this.resultBundlePath = args.resultBundlePath;
  66. this.resultBundleVersion = args.resultBundleVersion;
  67. this._didBuildFail = false;
  68. this._didProcessExit = false;
  69. }
  70. async init (noSessionProxy) {
  71. this.noSessionProxy = noSessionProxy;
  72. if (this.useXctestrunFile) {
  73. const deviveInfo = {
  74. isRealDevice: this.realDevice,
  75. udid: this.device.udid,
  76. platformVersion: this.platformVersion,
  77. platformName: this.platformName
  78. };
  79. this.xctestrunFilePath = await setXctestrunFile(deviveInfo, this.iosSdkVersion, this.bootstrapPath, this.wdaRemotePort);
  80. return;
  81. }
  82. // if necessary, update the bundleId to user's specification
  83. if (this.realDevice) {
  84. // In case the project still has the user specific bundle ID, reset the project file first.
  85. // - We do this reset even if updatedWDABundleId is not specified,
  86. // since the previous updatedWDABundleId test has generated the user specific bundle ID project file.
  87. // - We don't call resetProjectFile for simulator,
  88. // since simulator test run will work with any user specific bundle ID.
  89. await resetProjectFile(this.agentPath);
  90. if (this.updatedWDABundleId) {
  91. await updateProjectFile(this.agentPath, this.updatedWDABundleId);
  92. }
  93. }
  94. }
  95. async retrieveDerivedDataPath () {
  96. if (this.derivedDataPath) {
  97. return this.derivedDataPath;
  98. }
  99. // avoid race conditions
  100. if (this._derivedDataPathPromise) {
  101. return await this._derivedDataPathPromise;
  102. }
  103. this._derivedDataPathPromise = (async () => {
  104. let stdout;
  105. try {
  106. ({stdout} = await exec('xcodebuild', ['-project', this.agentPath, '-showBuildSettings']));
  107. } catch (err) {
  108. this.log.warn(`Cannot retrieve WDA build settings. Original error: ${err.message}`);
  109. return;
  110. }
  111. const pattern = /^\s*BUILD_DIR\s+=\s+(\/.*)/m;
  112. const match = pattern.exec(stdout);
  113. if (!match) {
  114. this.log.warn(`Cannot parse WDA build dir from ${_.truncate(stdout, {length: 300})}`);
  115. return;
  116. }
  117. this.log.debug(`Parsed BUILD_DIR configuration value: '${match[1]}'`);
  118. // Derived data root is two levels higher over the build dir
  119. this.derivedDataPath = path.dirname(path.dirname(path.normalize(match[1])));
  120. this.log.debug(`Got derived data root: '${this.derivedDataPath}'`);
  121. return this.derivedDataPath;
  122. })();
  123. return await this._derivedDataPathPromise;
  124. }
  125. async reset () {
  126. // if necessary, reset the bundleId to original value
  127. if (this.realDevice && this.updatedWDABundleId) {
  128. await resetProjectFile(this.agentPath);
  129. }
  130. }
  131. async prebuild () {
  132. // first do a build phase
  133. this.log.debug('Pre-building WDA before launching test');
  134. this.usePrebuiltWDA = true;
  135. await this.start(true);
  136. if (this.prebuildDelay > 0) {
  137. // pause a moment
  138. await B.delay(this.prebuildDelay);
  139. }
  140. }
  141. async cleanProject () {
  142. const libScheme = isTvOS(this.platformName) ? LIB_SCHEME_TV : LIB_SCHEME_IOS;
  143. const runnerScheme = isTvOS(this.platformName) ? RUNNER_SCHEME_TV : RUNNER_SCHEME_IOS;
  144. for (const scheme of [libScheme, runnerScheme]) {
  145. this.log.debug(`Cleaning the project scheme '${scheme}' to make sure there are no leftovers from previous installs`);
  146. await exec('xcodebuild', [
  147. 'clean',
  148. '-project', this.agentPath,
  149. '-scheme', scheme,
  150. ]);
  151. }
  152. }
  153. getCommand (buildOnly = false) {
  154. let cmd = 'xcodebuild';
  155. let args;
  156. // figure out the targets for xcodebuild
  157. const [buildCmd, testCmd] = this.useSimpleBuildTest ? ['build', 'test'] : ['build-for-testing', 'test-without-building'];
  158. if (buildOnly) {
  159. args = [buildCmd];
  160. } else if (this.usePrebuiltWDA || this.useXctestrunFile) {
  161. args = [testCmd];
  162. } else {
  163. args = [buildCmd, testCmd];
  164. }
  165. if (this.allowProvisioningDeviceRegistration) {
  166. // To -allowProvisioningDeviceRegistration flag takes effect, -allowProvisioningUpdates needs to be passed as well.
  167. args.push('-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration');
  168. }
  169. if (this.resultBundlePath) {
  170. args.push('-resultBundlePath', this.resultBundlePath);
  171. }
  172. if (this.resultBundleVersion) {
  173. args.push('-resultBundleVersion', this.resultBundleVersion);
  174. }
  175. if (this.useXctestrunFile && this.xctestrunFilePath) {
  176. args.push('-xctestrun', this.xctestrunFilePath);
  177. } else {
  178. const runnerScheme = isTvOS(this.platformName) ? RUNNER_SCHEME_TV : RUNNER_SCHEME_IOS;
  179. args.push('-project', this.agentPath, '-scheme', runnerScheme);
  180. if (this.derivedDataPath) {
  181. args.push('-derivedDataPath', this.derivedDataPath);
  182. }
  183. }
  184. args.push('-destination', `id=${this.device.udid}`);
  185. const versionMatch = new RegExp(/^(\d+)\.(\d+)/).exec(this.platformVersion);
  186. if (versionMatch) {
  187. args.push(
  188. `${isTvOS(this.platformName) ? 'TV' : 'IPHONE'}OS_DEPLOYMENT_TARGET=${versionMatch[1]}.${versionMatch[2]}`
  189. );
  190. } else {
  191. this.log.warn(`Cannot parse major and minor version numbers from platformVersion "${this.platformVersion}". ` +
  192. 'Will build for the default platform instead');
  193. }
  194. if (this.realDevice) {
  195. if (this.xcodeConfigFile) {
  196. this.log.debug(`Using Xcode configuration file: '${this.xcodeConfigFile}'`);
  197. args.push('-xcconfig', this.xcodeConfigFile);
  198. }
  199. if (this.xcodeOrgId && this.xcodeSigningId) {
  200. args.push(
  201. `DEVELOPMENT_TEAM=${this.xcodeOrgId}`,
  202. `CODE_SIGN_IDENTITY=${this.xcodeSigningId}`,
  203. );
  204. }
  205. }
  206. if (!process.env.APPIUM_XCUITEST_TREAT_WARNINGS_AS_ERRORS) {
  207. // This sometimes helps to survive Xcode updates
  208. args.push('GCC_TREAT_WARNINGS_AS_ERRORS=0');
  209. }
  210. // Below option slightly reduces build time in debug build
  211. // with preventing to generate `/Index/DataStore` which is used by development
  212. args.push('COMPILER_INDEX_STORE_ENABLE=NO');
  213. return {cmd, args};
  214. }
  215. async createSubProcess (buildOnly = false) {
  216. if (!this.useXctestrunFile && this.realDevice) {
  217. if (this.keychainPath && this.keychainPassword) {
  218. await setRealDeviceSecurity(this.keychainPath, this.keychainPassword);
  219. }
  220. }
  221. const {cmd, args} = this.getCommand(buildOnly);
  222. this.log.debug(`Beginning ${buildOnly ? 'build' : 'test'} with command '${cmd} ${args.join(' ')}' ` +
  223. `in directory '${this.bootstrapPath}'`);
  224. /** @type {Record<string, any>} */
  225. const env = Object.assign({}, process.env, {
  226. USE_PORT: this.wdaRemotePort,
  227. WDA_PRODUCT_BUNDLE_IDENTIFIER: this.updatedWDABundleId || WDA_RUNNER_BUNDLE_ID,
  228. });
  229. if (this.mjpegServerPort) {
  230. // https://github.com/appium/WebDriverAgent/pull/105
  231. env.MJPEG_SERVER_PORT = this.mjpegServerPort;
  232. }
  233. const upgradeTimestamp = await getWDAUpgradeTimestamp();
  234. if (upgradeTimestamp) {
  235. env.UPGRADE_TIMESTAMP = upgradeTimestamp;
  236. }
  237. this._didBuildFail = false;
  238. const xcodebuild = new SubProcess(cmd, args, {
  239. cwd: this.bootstrapPath,
  240. env,
  241. detached: true,
  242. stdio: ['ignore', 'pipe', 'pipe'],
  243. });
  244. let logXcodeOutput = !!this.showXcodeLog;
  245. const logMsg = _.isBoolean(this.showXcodeLog)
  246. ? `Output from xcodebuild ${this.showXcodeLog ? 'will' : 'will not'} be logged`
  247. : 'Output from xcodebuild will only be logged if any errors are present there';
  248. this.log.debug(`${logMsg}. To change this, use 'showXcodeLog' desired capability`);
  249. xcodebuild.on('output', (stdout, stderr) => {
  250. let out = stdout || stderr;
  251. // if we have an error we want to output the logs
  252. // otherwise the failure is inscrutible
  253. // but do not log permission errors from trying to write to attachments folder
  254. const ignoreError = IGNORED_ERRORS.some((x) => out.includes(x));
  255. if (this.showXcodeLog !== false && out.includes('Error Domain=') && !ignoreError) {
  256. logXcodeOutput = true;
  257. // handle case where xcode returns 0 but is failing
  258. this._didBuildFail = true;
  259. }
  260. // do not log permission errors from trying to write to attachments folder
  261. if (logXcodeOutput && !ignoreError) {
  262. for (const line of out.split(EOL)) {
  263. xcodeLog.error(line);
  264. }
  265. }
  266. });
  267. return xcodebuild;
  268. }
  269. async start (buildOnly = false) {
  270. this.xcodebuild = await this.createSubProcess(buildOnly);
  271. // wrap the start procedure in a promise so that we can catch, and report,
  272. // any startup errors that are thrown as events
  273. return await new B((resolve, reject) => {
  274. this.xcodebuild.once('exit', (code, signal) => {
  275. xcodeLog.error(`xcodebuild exited with code '${code}' and signal '${signal}'`);
  276. this.xcodebuild.removeAllListeners();
  277. this.didProcessExit = true;
  278. if (this._didBuildFail || (!signal && code !== 0)) {
  279. let errorMessage = `xcodebuild failed with code ${code}.` +
  280. ` This usually indicates an issue with the local Xcode setup or WebDriverAgent` +
  281. ` project configuration or the driver-to-platform version mismatch.`;
  282. if (!this.showXcodeLog) {
  283. errorMessage += ` Consider setting 'showXcodeLog' capability to true in` +
  284. ` order to check the Appium server log for build-related error messages.`;
  285. } else if (this.realDevice) {
  286. errorMessage += ` Consider checking the WebDriverAgent configuration guide` +
  287. ` for real iOS devices at` +
  288. ` https://github.com/appium/appium-xcuitest-driver/blob/master/docs/real-device-config.md.`;
  289. }
  290. return reject(new Error(errorMessage));
  291. }
  292. // in the case of just building, the process will exit and that is our finish
  293. if (buildOnly) {
  294. return resolve();
  295. }
  296. });
  297. return (async () => {
  298. try {
  299. const timer = new timing.Timer().start();
  300. await this.xcodebuild.start(true);
  301. if (!buildOnly) {
  302. let status = await this.waitForStart(timer);
  303. resolve(status);
  304. }
  305. } catch (err) {
  306. let msg = `Unable to start WebDriverAgent: ${err}`;
  307. this.log.error(msg);
  308. reject(new Error(msg));
  309. }
  310. })();
  311. });
  312. }
  313. async waitForStart (timer) {
  314. // try to connect once every 0.5 seconds, until `launchTimeout` is up
  315. this.log.debug(`Waiting up to ${this.launchTimeout}ms for WebDriverAgent to start`);
  316. let currentStatus = null;
  317. try {
  318. const retries = Math.trunc(this.launchTimeout / 500);
  319. await retryInterval(retries, 1000, async () => {
  320. if (this._didProcessExit) {
  321. // there has been an error elsewhere and we need to short-circuit
  322. return currentStatus;
  323. }
  324. const proxyTimeout = this.noSessionProxy.timeout;
  325. this.noSessionProxy.timeout = 1000;
  326. try {
  327. currentStatus = await this.noSessionProxy.command('/status', 'GET');
  328. if (currentStatus && currentStatus.ios && currentStatus.ios.ip) {
  329. this.agentUrl = currentStatus.ios.ip;
  330. }
  331. this.log.debug(`WebDriverAgent information:`);
  332. this.log.debug(JSON.stringify(currentStatus, null, 2));
  333. } catch (err) {
  334. throw new Error(`Unable to connect to running WebDriverAgent: ${err.message}`);
  335. } finally {
  336. this.noSessionProxy.timeout = proxyTimeout;
  337. }
  338. });
  339. if (this._didProcessExit) {
  340. // there has been an error elsewhere and we need to short-circuit
  341. return currentStatus;
  342. }
  343. this.log.debug(`WebDriverAgent successfully started after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
  344. } catch (err) {
  345. this.log.debug(err.stack);
  346. throw new Error(
  347. `We were not able to retrieve the /status response from the WebDriverAgent server after ${this.launchTimeout}ms timeout.` +
  348. `Try to increase the value of 'appium:wdaLaunchTimeout' capability as a possible workaround.`
  349. );
  350. }
  351. return currentStatus;
  352. }
  353. async quit () {
  354. await killProcess('xcodebuild', this.xcodebuild);
  355. }
  356. }
  357. export { XcodeBuild };
  358. export default XcodeBuild;