UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

267 lines (244 loc) 8.95 kB
import {fs, tempDir, logger, util} from 'appium/support'; import {SubProcess} from 'teen_process'; import {encodeBase64OrUpload} from '../utils'; import {waitForCondition} from 'asyncbox'; import type {XCUITestDriver} from '../driver'; import type {AudioRecorderOptions} from './types'; const MAX_RECORDING_TIME_SEC = 43200; const AUDIO_RECORD_FEAT_NAME = 'audio_record'; const DEFAULT_SOURCE = 'avfoundation'; const PROCESS_STARTUP_TIMEOUT_MS = 5000; const DEFAULT_EXT = '.mp4'; const FFMPEG_BINARY = 'ffmpeg'; const ffmpegLogger = logger.getLogger(FFMPEG_BINARY); export class AudioRecorder { private readonly input: string | number; private readonly log: any; private readonly audioPath: string; private readonly opts: AudioRecorderOptions; private mainProcess: SubProcess | null; constructor(input: string | number, log: any, audioPath: string, opts: AudioRecorderOptions = {} as AudioRecorderOptions) { this.input = input; this.log = log; this.audioPath = audioPath; this.opts = opts; this.mainProcess = null; } async start(timeoutSeconds: number): Promise<void> { try { await 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 {audioSource, audioCodec, audioBitrate, audioChannels, audioRate} = this.opts; const args = [ '-t', `${timeoutSeconds}`, '-f', audioSource, '-i', String(this.input), '-c:a', audioCodec, '-b:a', audioBitrate, '-ac', `${audioChannels}`, '-ar', `${audioRate}`, this.audioPath, ]; this.mainProcess = new SubProcess(FFMPEG_BINARY, args); let isCaptureStarted = false; this.mainProcess.on('output', (stdout, stderr) => { if (stderr) { if (stderr.trim().startsWith('size=')) { if (!isCaptureStarted) { isCaptureStarted = true; } } else { ffmpegLogger.info(`${stderr}`); } } }); await this.mainProcess.start(0); try { await waitForCondition(() => isCaptureStarted, { waitMs: PROCESS_STARTUP_TIMEOUT_MS, intervalMs: 300, }); } catch { this.log.warn( `Audio recording process did not start within ${PROCESS_STARTUP_TIMEOUT_MS}ms. Continuing anyway`, ); } if (!this.mainProcess.isRunning) { this.mainProcess = null; throw new Error( `The audio recording process '${FFMPEG_BINARY}' died unexpectedly. ` + `Check server logs for more details`, ); } this.log.info( `Starting capture on audio input '${this.input}' with command: '${util.quote([ FFMPEG_BINARY, ...args, ])}'. ` + `Will timeout in ${timeoutSeconds}s`, ); this.mainProcess.once('exit', (code, signal) => { // ffmpeg returns code 255 if SIGINT arrives if ([0, 255].includes(code ?? 0)) { this.log.info(`The recording session on audio input '${this.input}' has been finished`); } else { this.log.debug( `The recording session on audio input '${this.input}' has exited ` + `with code ${code}, signal ${signal}`, ); } }); } isRecording(): boolean { return !!this.mainProcess?.isRunning; } async interrupt(force = false): Promise<boolean> { if (this.isRecording()) { const interruptPromise = this.mainProcess?.stop(force ? 'SIGTERM' : 'SIGINT'); this.mainProcess = null; try { await interruptPromise; } catch (e: any) { this.log.warn( `Cannot ${force ? 'terminate' : 'interrupt'} ${FFMPEG_BINARY}. ` + `Original error: ${e.message}`, ); return false; } } return true; } async finish(): Promise<string> { await this.interrupt(); return this.audioPath; } async cleanup(): Promise<void> { if (await fs.exists(this.audioPath)) { await fs.rimraf(this.audioPath); } } } /** * Records the given hardware audio input and saves it into an `.mp4` file. * * **To use this command, the `audio_record` security feature must be enabled _and_ [FFMpeg](https://ffmpeg.org/) must be installed on the Appium server.** * * @param audioInput - The name of the corresponding audio input device to use for the capture. The full list of capture devices could be shown by executing `ffmpeg -f avfoundation -list_devices true -i ""` * @param timeLimit - The maximum recording time, in seconds. * @param audioCodec - The name of the audio codec. * @param audioBitrate - The bitrate of the resulting audio stream. * @param audioChannels - The count of audio channels in the resulting stream. Setting it to `1` will create a single channel (mono) audio stream. * @param audioRate - The sampling rate of the resulting audio stream (in Hz). * @param forceRestart - Whether to restart audio capture process forcefully when `mobile: startRecordingAudio` is called (`true`) or ignore the call until the current audio recording is completed (`false`). * @group Real Device Only */ export async function startAudioRecording( this: XCUITestDriver, audioInput: string | number, timeLimit: string | number = 180, audioCodec = 'aac', audioBitrate = '128k', audioChannels: string | number = 2, audioRate: string | number = 44100, forceRestart = false, ): Promise<void> { if (!this.isFeatureEnabled(AUDIO_RECORD_FEAT_NAME)) { throw this.log.errorWithException( `Audio capture feature must be enabled on the server side. ` + `Please set '--relaxed-security' or '--allow-insecure' with '${AUDIO_RECORD_FEAT_NAME}' option. ` + `Read https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/security.md for more details.`, ); } if (!audioInput) { throw this.log.errorWithException( `The mandatory audioInput option is not provided. Please set it ` + `to a correct value (e. g. ':1'). Use 'ffmpeg -f avfoundation -list_devices true -i ""' ` + `command to list available input sources`, ); } if (this._audioRecorder?.isRecording()) { this.log.info(`There is an active audio recording process`); if (forceRestart) { this.log.info(`Stopping it because 'forceRestart' option is set to true`); await this._audioRecorder.interrupt(true); } else { this.log.info( `Doing nothing. ` + `Set 'forceRestart' option to true if you'd like to start a new audio recording session`, ); return; } } if (this._audioRecorder) { await this._audioRecorder.cleanup(); this._audioRecorder = null; } const audioPath = await tempDir.path({ prefix: `appium_${util.uuidV4().substring(0, 8)}`, suffix: DEFAULT_EXT, }); const audioRecorder = new AudioRecorder(audioInput, this.log, audioPath, { audioSource: DEFAULT_SOURCE, audioCodec, audioBitrate, audioChannels: Number(audioChannels), audioRate: Number(audioRate), }); const timeoutSeconds = parseInt(String(timeLimit), 10); 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.`, ); } try { await audioRecorder.start(timeoutSeconds); } catch (e) { await audioRecorder.interrupt(true); await audioRecorder.cleanup(); throw e; } this._audioRecorder = audioRecorder; } /** * Stop recording of the audio input. If no audio 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 audio recording * processes are running then the method returns an empty string. * * @returns Base64-encoded content of the recorded media file or an * empty string if no audio recording has been started before. * @throws {Error} If there was an error while getting the recorded file. */ export async function stopAudioRecording(this: XCUITestDriver): Promise<string> { if (!this._audioRecorder) { this.log.info('Audio recording has not been started. There is nothing to stop'); return ''; } let resultPath: string; try { resultPath = await this._audioRecorder.finish(); if (!(await fs.exists(resultPath))) { throw this.log.errorWithException( `${FFMPEG_BINARY} has failed ` + `to store the actual audio recording at '${resultPath}'`, ); } } catch (e) { await this._audioRecorder.interrupt(true); await this._audioRecorder.cleanup(); this._audioRecorder = null; throw e; } return await encodeBase64OrUpload(resultPath); }