UNPKG

appium-safari-driver

Version:
368 lines (338 loc) 11.3 kB
import {util, fs, net, tempDir} from 'appium/support'; import {waitForCondition} from 'asyncbox'; import {Simctl} from 'node-simctl'; import type {SubProcess} from 'teen_process'; import type {AppiumLogger, StringRecord} from '@appium/types'; import type {SafariDriver} from '../driver'; 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'; interface UploadOptions { user?: string; pass?: string; method?: string; headers?: Record<string, string>; fileFieldName?: string; formFields?: Record<string, string> | Array<[string, string]>; } async function uploadRecordedMedia( localFile: string, remotePath: string | null = null, uploadOptions: UploadOptions = {}, ): Promise<string> { if (!remotePath) { return (await util.toInMemoryBase64(localFile)).toString(); } const {user, pass, method, headers, fileFieldName, formFields} = uploadOptions; const options: any = { 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<string>(); process.on('exit', () => { for (const videoFile of VIDEO_FILES) { try { fs.rimrafSync(videoFile); } catch {} } }); export interface StartRecordingOptions { /** Specifies the codec type: "h264" or "hevc" */ codec?: string; /** Supports "internal" or "external". Default is "internal" */ display?: string; /** 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. */ mask?: string; /** The maximum recording time, in seconds. The default value is 600 seconds (10 minutes). */ timeLimit?: string | number; /** 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`). */ forceRestart?: boolean; } export interface StopRecordingOptions { /** 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. */ remotePath?: string; /** The name of the user for the remote authentication. */ user?: string; /** The password for the remote authentication. */ pass?: string; /** The http multipart upload method name. The 'PUT' one is used by default. */ method?: string; /** Additional headers mapping for multipart http(s) uploads */ headers?: Record<string, string>; /** The name of the form field, where the file content BLOB should be stored for http(s) uploads */ fileFieldName?: string; /** Additional form fields for multipart http(s) uploads */ formFields?: Record<string, string> | Array<[string, string]>; } interface ScreenRecorderOptions { codec?: string; display?: string; mask?: string; timeLimit?: string | number; } export class ScreenRecorder { private log: AppiumLogger; private _process: SubProcess | null = null; private _udid: string; private _videoPath: string; private _codec?: string; private _display?: string; private _mask?: string; private _timeLimitMs: number = DEFAULT_TIME_LIMIT_MS; private _timer: NodeJS.Timeout | null = null; constructor( udid: string, videoPath: string, log: AppiumLogger, opts: ScreenRecorderOptions = {}, ) { this.log = log; this._udid = udid; this._videoPath = videoPath; this._codec = opts.codec; this._display = opts.display; this._mask = opts.mask; if (opts.timeLimit) { const timeLimitMs = parseInt(String(opts.timeLimit), 10); if (timeLimitMs > 0) { this._timeLimitMs = timeLimitMs * 1000; } } } get isRunning(): boolean { return !!this._process?.isRunning; } async getVideoPath(): Promise<string> { if (await fs.exists(this._videoPath)) { VIDEO_FILES.add(this._videoPath); return this._videoPath; } return ''; } async start(): Promise<void> { const args: string[] = [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 = (stdout || stderr)?.trim() ?? ''; 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: any) { this.log.error(e); } } }, this._timeLimitMs); this.log.info(`The video recording has started. Will timeout in ${this._timeLimitMs}ms`); } async stop(force: boolean = false): Promise<string> { 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(); } if (!this._process) { throw new Error('Screen recording process is not available'); } try { await 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(); } private async _enforceTermination(): Promise<string> { if (this.isRunning && this._process) { this.log.debug('Force-stopping the currently running video recording'); try { await this._process.stop('SIGKILL'); } catch {} } this._process = null; const videoPath = await this.getVideoPath(); if (videoPath) { await fs.rimraf(videoPath); VIDEO_FILES.delete(videoPath); } return ''; } } /** * 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. * * @param options - The available options. * @throws {Error} If screen recording has failed to start or is not supported for the destination device. */ export async function startRecordingScreen( this: SafariDriver, options?: StartRecordingOptions, ): Promise<void> { 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; } } /** * Stop recording the screen. * If no screen recording has been started before 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 falsy 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 * or screen recording is not supported on the device under test. */ export async function stopRecordingScreen( this: SafariDriver, options?: StopRecordingOptions, ): Promise<string> { 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 (!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 ?? null, options ?? {}); } async function extractSimulatorUdid(caps: StringRecord): Promise<string | null> { if (caps['safari:useSimulator'] === false) { return null; } const allDevices = Object.values(await new Simctl().getDevices(null, 'iOS')).flat(); for (const {name, udid, state, sdk} of allDevices) { if (state !== 'Booted') { continue; } if (caps['safari:deviceUDID']?.toLowerCase() === udid.toLowerCase()) { return udid; } if ( caps['safari:deviceName']?.toLowerCase() === name.toLowerCase() && ((caps['safari:platformVersion'] && caps['safari:platformVersion'] === sdk) || !caps['safari:platformVersion']) ) { return udid; } } return null; }