UNPKG

appium-safari-driver

Version:
336 lines (308 loc) 10.9 kB
import _ from 'lodash'; import { util, fs, net, tempDir } from 'appium/support'; import { waitForCondition } from 'asyncbox'; import { Simctl } from 'node-simctl'; const STARTUP_INTERVAL_MS = 300; const STARTUP_TIMEOUT_MS = 10 * 1000; const DEFAULT_TIME_LIMIT_MS = 60 * 10 * 1000; // 10 minutes const PROCESS_SHUTDOWN_TIMEOUT_MS = 10 * 1000; const DEFAULT_EXT = '.mp4'; /** * * @param {string} localFile * @param {string|null} remotePath * @param {Object} uploadOptions * @returns */ async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions = {}) { if (_.isEmpty(remotePath) || !remotePath) { return (await util.toInMemoryBase64(localFile)).toString(); } const {user, pass, method, headers, fileFieldName, formFields} = uploadOptions; const options = { method: method || 'PUT', headers, fileFieldName, formFields, }; if (user && pass) { options.auth = {user, pass}; } await net.uploadFile(localFile, remotePath, options); return ''; } const VIDEO_FILES = new Set(); process.on('exit', () => { for (const videoFile of VIDEO_FILES) { try { fs.rimrafSync(videoFile); } catch {} } }); class ScreenRecorder { /** * @param {string} udid * @param {string} videoPath * @param {import('@appium/types').AppiumLogger} log * @param {{codec?: string, display?: string, mask?: string, timeLimit?: string|number}} [opts={}] */ constructor (udid, videoPath, log, opts = {}) { this.log = log; this._process = null; this._udid = udid; this._videoPath = videoPath; this._codec = opts.codec; this._display = opts.display; this._mask = opts.mask; this._timeLimitMs = DEFAULT_TIME_LIMIT_MS; if (opts.timeLimit) { const timeLimitMs = parseInt(String(opts.timeLimit), 10); if (timeLimitMs > 0) { this._timeLimitMs = timeLimitMs * 1000; } } this._timer = null; } async getVideoPath () { if (await fs.exists(this._videoPath)) { VIDEO_FILES.add(this._videoPath); return this._videoPath; } return ''; } get isRunning () { return !!(this._process?.isRunning); } async _enforceTermination () { if (this.isRunning) { this.log.debug('Force-stopping the currently running video recording'); try { await /** @type {import('teen_process').SubProcess} */ (this._process).stop('SIGKILL'); } catch {} } this._process = null; const videoPath = await this.getVideoPath(); if (videoPath) { await fs.rimraf(videoPath); VIDEO_FILES.delete(videoPath); } return ''; } async start () { const args = [ this._udid, 'recordVideo' ]; if (this._display) { args.push('--display', this._display); } if (this._codec) { args.push('--codec', this._codec); } if (this._mask) { args.push('--mask', this._mask); } args.push('--force', this._videoPath); this._process = await new Simctl().exec('io', { args, asynchronous: true, }); this.log.debug(`Starting video recording with arguments: ${util.quote(args)}`); this._process.on('output', (stdout, stderr) => { const line = _.trim(stdout || stderr); if (line) { this.log.debug(`[recordVideo@${this._udid.substring(0, 8)}] ${line}`); } }); this._process.once('exit', async (code, signal) => { this._process = null; if (code === 0) { this.log.debug('Screen recording exited without errors'); } else { await this._enforceTermination(); this.log.warn(`Screen recording exited with error code ${code}, signal ${signal}`); } }); await this._process.start(0); try { await waitForCondition(async () => { if (!this.isRunning) { throw new Error(); } return !!(await this.getVideoPath()); }, { waitMs: STARTUP_TIMEOUT_MS, intervalMs: STARTUP_INTERVAL_MS, }); } catch { await this._enforceTermination(); throw this.log.errorWithException( `The expected screen record file '${this._videoPath}' does not exist after ${STARTUP_TIMEOUT_MS}ms. ` + `Check the server log for more details` ); } this._timer = setTimeout(async () => { if (this.isRunning) { try { await this.stop(); } catch (e) { this.log.error(e); } } }, this._timeLimitMs); this.log.info(`The video recording has started. Will timeout in ${this._timeLimitMs}ms`); } async stop (force = false) { if (this._timer) { clearTimeout(this._timer); this._timer = null; } if (force) { return await this._enforceTermination(); } if (!this.isRunning) { this.log.debug('Screen recording is not running. Returning the recently recorded video'); return await this.getVideoPath(); } try { await /** @type {import('teen_process').SubProcess} */ (this._process).stop('SIGINT', PROCESS_SHUTDOWN_TIMEOUT_MS); } catch { await this._enforceTermination(); throw new Error(`Screen recording has failed to stop after ${PROCESS_SHUTDOWN_TIMEOUT_MS}ms`); } return await this.getVideoPath(); } } async function extractSimulatorUdid (caps) { if (caps['safari:useSimulator'] === false) { return null; } const allDevices = _.flatMap(_.values(await new Simctl().getDevices(null, 'iOS'))); for (const {name, udid, state, sdk} of allDevices) { if (state !== 'Booted') { continue; } if (_.toLower(caps['safari:deviceUDID']) === _.toLower(udid)) { return udid; } if (_.toLower(caps['safari:deviceName']) === _.toLower(name) && (caps['safari:platformVersion'] && caps['safari:platformVersion'] === sdk || !caps['safari:platformVersion'])) { return udid; } } return null; } /** * @typedef {Object} StartRecordingOptions * * @property {string} codec [hevc] - Specifies the codec type: "h264" or "hevc" * @property {string} display [internal] - Supports "internal" or "external". Default is "internal" * @property {string} mask - For non-rectangular displays, handle the mask by policy: * - ignored: The mask is ignored and the unmasked framebuffer is saved. * - alpha: Not supported, but retained for compatibility; the mask is rendered black. * - black: The mask is rendered black. * @property {string|number} timeLimit [600] - The maximum recording time, in seconds. The default * value is 600 seconds (10 minutes). * @property {boolean} forceRestart [true] - Whether to ignore the call if a screen recording is currently running * (`false`) or to start a new recording immediately and terminate the existing one if running (`true`). */ /** * Record the Simulator's display in background while the automated test is running. * This method uses `xcrun simctl io recordVideo` helper under the hood. * Check the output of `xcrun simctl io` command for more details. * * @this {SafariDriver} * @param {StartRecordingOptions} options - The available options. * @this {import('../driver').SafariDriver} * @throws {Error} If screen recording has failed to start or is not supported for the destination device. */ export async function startRecordingScreen (options) { const { timeLimit, codec, display, mask, forceRestart = true, } = options ?? {}; if (this._screenRecorder?.isRunning) { this.log.info('The screen recording is already running'); if (!forceRestart) { this.log.info('Doing nothing'); return; } this.log.info('Forcing the active screen recording to stop'); await this._screenRecorder.stop(true); } this._screenRecorder = null; const udid = await extractSimulatorUdid(this.caps); if (!udid) { throw new Error('Cannot determine Simulator UDID to record the video from. ' + 'Double check your session capabilities'); } const videoPath = await tempDir.path({ prefix: util.uuidV4().substring(0, 8), suffix: DEFAULT_EXT, }); this._screenRecorder = new ScreenRecorder(udid, videoPath, this.log, { timeLimit: parseInt(`${timeLimit}`, 10), codec, display, mask, }); try { await this._screenRecorder.start(); } catch (e) { this._screenRecorder = null; throw e; } } /** * @typedef {Object} StopRecordingOptions * * @property {string} remotePath - The path to the remote location, where the resulting video should be uploaded. * The following protocols are supported: http/https, ftp. * Null or empty string value (the default setting) means the content of resulting * file should be encoded as Base64 and passed as the endpoint response value. * An exception will be thrown if the generated media file is too big to * fit into the available process memory. * @property {string} user - The name of the user for the remote authentication. * @property {string} pass - The password for the remote authentication. * @property {string} method - The http multipart upload method name. The 'PUT' one is used by default. * @property {Object} headers - Additional headers mapping for multipart http(s) uploads * @property {string} fileFieldName [file] - The name of the form field, where the file content BLOB should be stored for * http(s) uploads * @property {Object|[string, string][]} formFields - Additional form fields for multipart http(s) uploads */ /** * Stop recording the screen. * If no screen recording has been started before then the method returns an empty string. * * @this {SafariDriver} * @param {StopRecordingOptions} options - The available options. * @returns {Promise<string>} Base64-encoded content of the recorded media file if 'remotePath' * parameter is falsy or an empty string. * @this {import('../driver').SafariDriver} * @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 * or screen recording is not supported on the device under test. */ export async function stopRecordingScreen (options) { if (!this._screenRecorder) { this.log.info('No screen recording has been started. Doing nothing'); return ''; } this.log.debug('Retrieving the resulting video data'); const videoPath = await this._screenRecorder.stop(); if (!videoPath) { this.log.info('No video data is found. Returning an empty string'); return ''; } if (_.isEmpty(options?.remotePath)) { const {size} = await fs.stat(videoPath); this.log.debug(`The size of the resulting screen recording is ${util.toReadableSizeString(size)}`); } return await uploadRecordedMedia(videoPath, options?.remotePath, options); } /** * @typedef {import('../driver').SafariDriver} SafariDriver */