UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

389 lines 15.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ScreenRecorder = exports.STOP_SCREEN_RECORDING_EXECUTE_OPTIONALS = exports.START_SCREEN_RECORDING_EXECUTE_OPTIONALS = void 0; exports.startRecordingScreen = startRecordingScreen; exports.stopRecordingScreen = stopRecordingScreen; exports.mobileStartScreenRecording = mobileStartScreenRecording; exports.mobileStopScreenRecording = mobileStopScreenRecording; const lodash_1 = __importDefault(require("lodash")); const support_1 = require("appium/support"); const teen_process_1 = require("teen_process"); const utils_1 = require("../utils"); const appium_webdriveragent_1 = require("appium-webdriveragent"); const asyncbox_1 = require("asyncbox"); /** * Optional execute-script keys for `mobile: startScreenRecording`, in Appium flattened-arg order. * Keep in sync with `lib/execute-method-map.ts` (`mobile: startScreenRecording` optional keys). */ exports.START_SCREEN_RECORDING_EXECUTE_OPTIONALS = [ 'videoType', 'videoQuality', 'videoFps', 'videoFilters', 'videoScale', 'pixelFormat', 'forceRestart', 'timeLimit', 'hardwareAcceleration', 'remotePath', 'user', 'pass', 'headers', 'fileFieldName', 'formFields', 'method', ]; /** * Optional execute-script keys for `mobile: stopScreenRecording`, in Appium flattened-arg order. * Keep in sync with `lib/execute-method-map.ts` (`mobile: stopScreenRecording` optional keys). */ exports.STOP_SCREEN_RECORDING_EXECUTE_OPTIONALS = [ 'remotePath', 'user', 'pass', 'headers', 'fileFieldName', 'formFields', 'method', ]; /** * Set max timeout for 'reconnect_delay_max' ffmpeg argument usage. * It could have [0-4294] range limitation, thus this value should be less than that right now * to return a better error message. */ const MAX_RECORDING_TIME_SEC = 4200; const DEFAULT_RECORDING_TIME_SEC = 60 * 3; const DEFAULT_FPS = 10; const DEFAULT_QUALITY = 'medium'; const DEFAULT_MJPEG_SERVER_PORT = 9100; const DEFAULT_VCODEC = 'mjpeg'; const MP4_EXT = '.mp4'; const FFMPEG_BINARY = 'ffmpeg'; const ffmpegLogger = support_1.logger.getLogger(FFMPEG_BINARY); const QUALITY_MAPPING = { low: 10, medium: 25, high: 75, photo: 100, }; const HARDWARE_ACCELERATION_PARAMETERS = { /* https://trac.ffmpeg.org/wiki/HWAccelIntro#VideoToolbox */ videoToolbox: { hwaccel: 'videotoolbox', hwaccelOutputFormat: 'videotoolbox_vld', scaleFilterHWAccel: 'scale_vt', videoTypeHWAccel: 'h264_videotoolbox', }, /* https://trac.ffmpeg.org/wiki/HWAccelIntro#CUDANVENCNVDEC */ cuda: { hwaccel: 'cuda', hwaccelOutputFormat: 'cuda', scaleFilterHWAccel: 'scale_cuda', videoTypeHWAccel: 'h264_nvenc', }, /* https://trac.ffmpeg.org/wiki/Hardware/AMF */ amf_dx11: { hwaccel: 'd3d11va', hwaccelOutputFormat: 'd3d11', scaleFilterHWAccel: 'scale', videoTypeHWAccel: 'av1_amf', }, /* https://trac.ffmpeg.org/wiki/Hardware/QuickSync */ qsv: { hwaccel: 'qsv', hwaccelOutputFormat: '', scaleFilterHWAccel: 'scale_qsv', videoTypeHWAccel: 'h264_qsv', }, /* https://trac.ffmpeg.org/wiki/Hardware/VAAPI */ vaapi: { hwaccel: 'vaapi', hwaccelOutputFormat: 'vaapi', scaleFilterHWAccel: 'scale_vaapi', videoTypeHWAccel: 'h264_vaapi', }, }; const CAPTURE_START_MARKER = /^\s*frame=/; class ScreenRecorder { videoPath; log; opts; udid; mainProcess; timeoutHandler; constructor(udid, log, videoPath, opts) { this.videoPath = videoPath; this.log = log; this.opts = opts; this.udid = udid; this.mainProcess = null; this.timeoutHandler = null; } async start(timeoutMs) { try { await support_1.fs.which(FFMPEG_BINARY); } catch { throw new Error(`'${FFMPEG_BINARY}' binary is not found in PATH. Install it using 'brew install ffmpeg'. ` + `Check https://www.ffmpeg.org/download.html for more details.`); } const { hardwareAcceleration, remotePort, remoteUrl, videoFps, videoType, videoScale, videoFilters, pixelFormat, } = this.opts; const args = [ '-f', 'mjpeg', // https://github.com/appium/appium/issues/16294 '-reconnect', '1', '-reconnect_at_eof', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', `${timeoutMs / 1000 + 1}`, ]; const { hwaccel, hwaccelOutputFormat, scaleFilterHWAccel, videoTypeHWAccel } = HARDWARE_ACCELERATION_PARAMETERS[hardwareAcceleration || ''] ?? {}; if (hwaccel) { args.push('-hwaccel', hwaccel); } if (hwaccelOutputFormat) { args.push('-hwaccel_output_format', hwaccelOutputFormat); } //Parameter `-r` is optional. See details: https://github.com/appium/appium/issues/12067 if ((videoFps && videoType === 'libx264') || videoTypeHWAccel) { args.push('-r', String(videoFps)); } const parsed = new URL(remoteUrl); args.push('-i', `${parsed.protocol}//${parsed.hostname}:${remotePort}`); if (videoFilters || videoScale) { args.push('-vf', videoFilters || `${scaleFilterHWAccel || 'scale'}=${videoScale}`); } // Quicktime compatibility via pixelFormat: 'yuv420p' if (pixelFormat) { args.push('-pix_fmt', pixelFormat); } args.push('-vcodec', videoTypeHWAccel || videoType || DEFAULT_VCODEC); args.push('-y'); args.push(this.videoPath); this.mainProcess = new teen_process_1.SubProcess(FFMPEG_BINARY, args); let isCaptureStarted = false; this.mainProcess.on('line-stderr', (line) => { if (CAPTURE_START_MARKER.test(line)) { if (!isCaptureStarted) { isCaptureStarted = true; } } else { ffmpegLogger.info(line); } }); await this.mainProcess.start(0); const startupTimeout = 5000; try { await (0, asyncbox_1.waitForCondition)(() => isCaptureStarted, { waitMs: startupTimeout, intervalMs: 300, }); } catch { this.log.warn(`Screen capture process did not start within ${startupTimeout}ms. Continuing anyway`); } if (!this.mainProcess.isRunning) { throw new Error(`The screen capture process '${FFMPEG_BINARY}' died unexpectedly. ` + `Check server logs for more details`); } this.log.info(`Starting screen capture on the device '${this.udid}' with command: '${FFMPEG_BINARY} ${args.join(' ')}'. ` + `Will timeout in ${timeoutMs}ms`); this.timeoutHandler = setTimeout(async () => { if (!(await this.interrupt())) { this.log.warn(`Cannot finish the active screen recording on the device '${this.udid}' after ${timeoutMs}ms timeout`); } }, timeoutMs); } async interrupt(force = false) { let result = true; if (this.timeoutHandler) { clearTimeout(this.timeoutHandler); this.timeoutHandler = null; } if (this.mainProcess?.isRunning) { const interruptPromise = this.mainProcess.stop(force ? 'SIGTERM' : 'SIGINT'); this.mainProcess = null; try { await interruptPromise; } catch (e) { this.log.warn(`Cannot ${force ? 'terminate' : 'interrupt'} ${FFMPEG_BINARY}. ` + `Original error: ${e.message}`); result = false; } } return result; } async finish() { await this.interrupt(); return this.videoPath; } async cleanup() { if (await support_1.fs.exists(this.videoPath)) { await support_1.fs.rimraf(this.videoPath); } } } exports.ScreenRecorder = ScreenRecorder; /** * Direct Appium to start recording the device screen * * Record the display of devices running iOS Simulator since Xcode 9 or real devices since iOS 11 * (ffmpeg utility is required: 'brew install ffmpeg'). * It records screen activity to a MPEG-4 file. Audio is not recorded with the video file. * If screen recording has been already started then the command will stop it forcefully and start a new one. * The previously recorded video file will be deleted. * * @param options - The available options. * @returns Base64-encoded content of the recorded media file if * any screen recording is currently running or an empty string. * @throws {Error} If screen recording has failed to start. */ async function startRecordingScreen(options = {}) { const { videoType = DEFAULT_VCODEC, timeLimit = DEFAULT_RECORDING_TIME_SEC, videoQuality = DEFAULT_QUALITY, videoFps = DEFAULT_FPS, videoFilters, videoScale, forceRestart, pixelFormat, hardwareAcceleration, } = options; let result = ''; if (!forceRestart) { this.log.info(`Checking if there is/was a previous screen recording. ` + `Set 'forceRestart' option to 'true' if you'd like to skip this step.`); result = (await this.stopRecordingScreen(options)) ?? result; } const videoPath = await support_1.tempDir.path({ prefix: `appium_${Math.random().toString(16).substring(2, 8)}`, suffix: MP4_EXT, }); const screenRecorder = new ScreenRecorder(this.device.udid, this.log, videoPath, { remotePort: this.opts.mjpegServerPort || DEFAULT_MJPEG_SERVER_PORT, remoteUrl: this.opts.wdaBaseUrl || appium_webdriveragent_1.WDA_BASE_URL, videoType, videoFilters, videoScale, videoFps: typeof videoFps === 'string' ? parseInt(videoFps, 10) : videoFps, pixelFormat, hardwareAcceleration, }); if (!(await screenRecorder.interrupt(true))) { throw this.log.errorWithException('Unable to stop screen recording process'); } if (this._recentScreenRecorder) { await this._recentScreenRecorder.cleanup(); this._recentScreenRecorder = null; } const timeoutSeconds = parseFloat(String(timeLimit)); if (isNaN(timeoutSeconds) || timeoutSeconds > MAX_RECORDING_TIME_SEC || timeoutSeconds <= 0) { throw this.log.errorWithException(`The timeLimit value must be in range [1, ${MAX_RECORDING_TIME_SEC}] seconds. ` + `The value of '${timeLimit}' has been passed instead.`); } let { mjpegServerScreenshotQuality, mjpegServerFramerate } = (await this.proxyCommand('/appium/settings', 'GET')); if (videoQuality) { const quality = lodash_1.default.isInteger(videoQuality) ? videoQuality : QUALITY_MAPPING[lodash_1.default.toLower(String(videoQuality))]; if (!quality) { throw new Error(`videoQuality value should be one of ${JSON.stringify(lodash_1.default.keys(QUALITY_MAPPING))} or a number in range 1..100. ` + `'${videoQuality}' is given instead`); } mjpegServerScreenshotQuality = mjpegServerScreenshotQuality !== quality ? quality : undefined; } else { mjpegServerScreenshotQuality = undefined; } if (videoFps) { const fps = parseInt(String(videoFps), 10); if (isNaN(fps)) { throw new Error(`videoFps value should be a valid number in range 1..60. ` + `'${videoFps}' is given instead`); } mjpegServerFramerate = mjpegServerFramerate !== fps ? fps : undefined; } else { mjpegServerFramerate = undefined; } if (support_1.util.hasValue(mjpegServerScreenshotQuality) || support_1.util.hasValue(mjpegServerFramerate)) { await this.proxyCommand('/appium/settings', 'POST', { settings: { mjpegServerScreenshotQuality, mjpegServerFramerate, }, }); } try { await screenRecorder.start(timeoutSeconds * 1000); } catch (e) { await screenRecorder.interrupt(true); await screenRecorder.cleanup(); throw e; } this._recentScreenRecorder = screenRecorder; return result; } /** * Direct Appium to stop screen recording and return the video * * If no screen recording process is running then the endpoint will try to get * the recently recorded file. If no previously recorded file is found and no * active screen recording processes are running then the method returns an * empty string. * * @param options - The available options. * @returns Base64-encoded content of the recorded media * file if `remotePath` parameter is empty or null or an empty string. * @throws {Error} If there was an error while getting the name of a media * file or the file content cannot be uploaded to the remote * location. */ async function stopRecordingScreen(options = {}) { if (!this._recentScreenRecorder) { this.log.info('Screen recording is not running. There is nothing to stop.'); return ''; } try { const videoPath = await this._recentScreenRecorder.finish(); if (!(await support_1.fs.exists(videoPath))) { throw this.log.errorWithException(`The screen recorder utility has failed ` + `to store the actual screen recording at '${videoPath}'`); } return await (0, utils_1.encodeBase64OrUpload)(videoPath, options.remotePath, options); } finally { await this._recentScreenRecorder.interrupt(true); await this._recentScreenRecorder.cleanup(); this._recentScreenRecorder = null; } } /** * Execute-script entry for `mobile: startScreenRecording`. Appium passes one positional argument * per optional key (see `START_SCREEN_RECORDING_EXECUTE_OPTIONALS`); this wrapper collapses * them into the options object for `startRecordingScreen`. */ async function mobileStartScreenRecording(...args) { const options = optionsFromFlattenedExecuteArgs([...exports.START_SCREEN_RECORDING_EXECUTE_OPTIONALS], args); return await this.startRecordingScreen(options); } /** * Execute-script entry for `mobile: stopScreenRecording`. Collapses flattened args into the * options object for `stopRecordingScreen`. */ async function mobileStopScreenRecording(...args) { const options = optionsFromFlattenedExecuteArgs([...exports.STOP_SCREEN_RECORDING_EXECUTE_OPTIONALS], args); return await this.stopRecordingScreen(options); } function optionsFromFlattenedExecuteArgs(keys, args) { const out = {}; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (key !== undefined && args[i] !== undefined) { out[key] = args[i]; } } return out; } //# sourceMappingURL=recordscreen.js.map