appium-mac2-driver
Version:
XCTest-based Appium driver for macOS apps automation
360 lines (337 loc) • 12.6 kB
text/typescript
import _ from 'lodash';
import {waitForCondition} from 'asyncbox';
import {util, fs, tempDir} from 'appium/support';
import {SubProcess} from 'teen_process';
import B from 'bluebird';
import {uploadRecordedMedia} from './helpers';
import type {Mac2Driver} from '../driver';
import type {AppiumLogger, StringRecord} from '@appium/types';
const RETRY_PAUSE = 300;
const RETRY_TIMEOUT = 5000;
const DEFAULT_TIME_LIMIT = 60 * 10; // 10 minutes
const PROCESS_SHUTDOWN_TIMEOUT = 10 * 1000;
const DEFAULT_EXT = 'mp4';
const FFMPEG_BINARY = 'ffmpeg';
const DEFAULT_FPS = 15;
const DEFAULT_PRESET = 'veryfast';
type Preset =
| 'ultrafast'
| 'superfast'
| 'veryfast'
| 'faster'
| 'fast'
| 'medium'
| 'slow'
| 'slower'
| 'veryslow';
interface ScreenRecorderOptions {
fps?: number;
deviceId: string | number;
preset?: Preset;
captureCursor?: boolean;
captureClicks?: boolean;
videoFilter?: string;
timeLimit?: number;
}
/**
* @param log - Logger instance
*/
async function requireFfmpegPath(log: AppiumLogger): Promise<string> {
try {
return await fs.which(FFMPEG_BINARY);
} catch {
throw log.errorWithException(
`${FFMPEG_BINARY} has not been found in PATH. ` + `Please make sure it is installed`,
);
}
}
export class ScreenRecorder {
private readonly _log: AppiumLogger;
private readonly _videoPath: string;
private _process: SubProcess | null;
private readonly _fps: number;
private readonly _deviceId: string | number;
private readonly _captureCursor?: boolean;
private readonly _captureClicks?: boolean;
private readonly _preset: Preset;
private readonly _videoFilter?: string;
private readonly _timeLimit: number;
/**
*
* @param videoPath - Path to the video file
* @param log - Logger instance
* @param opts - Screen recorder options
*/
constructor(videoPath: string, log: AppiumLogger, opts: ScreenRecorderOptions) {
this._log = log;
this._videoPath = videoPath;
this._process = null;
this._fps = opts.fps && opts.fps > 0 ? opts.fps : DEFAULT_FPS;
this._deviceId = opts.deviceId;
this._captureCursor = opts.captureCursor;
this._captureClicks = opts.captureClicks;
this._preset = opts.preset || DEFAULT_PRESET;
this._videoFilter = opts.videoFilter;
this._timeLimit = opts.timeLimit && opts.timeLimit > 0 ? opts.timeLimit : DEFAULT_TIME_LIMIT;
}
async getVideoPath(): Promise<string> {
return (await fs.exists(this._videoPath)) ? this._videoPath : '';
}
isRunning(): boolean {
return !!this._process?.isRunning;
}
async start(): Promise<void> {
const ffmpeg = await requireFfmpegPath(this._log);
const args: string[] = [
'-loglevel',
'error',
'-t',
`${this._timeLimit}`,
'-f',
'avfoundation',
...(this._captureCursor ? ['-capture_cursor', '1'] : []),
...(this._captureClicks ? ['-capture_mouse_clicks', '1'] : []),
'-framerate',
`${this._fps}`,
'-i',
`${this._deviceId}`,
'-vcodec',
'libx264',
'-preset',
this._preset,
'-tune',
'zerolatency',
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-fflags',
'nobuffer',
'-f',
DEFAULT_EXT,
'-r',
`${this._fps}`,
...(this._videoFilter ? ['-filter:v', this._videoFilter] : []),
];
const fullCmd: string[] = [ffmpeg, ...args, this._videoPath];
this._process = new SubProcess(fullCmd[0], fullCmd.slice(1));
this._log.debug(`Starting ${FFMPEG_BINARY}: ${util.quote(fullCmd)}`);
this._process.on('output', (stdout, stderr) => {
if (_.trim(stdout || stderr)) {
this._log.debug(`[${FFMPEG_BINARY}] ${stdout || stderr}`);
}
});
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 (await this.getVideoPath()) {
return true;
}
if (!this._process) {
throw new Error(`${FFMPEG_BINARY} process died unexpectedly`);
}
return false;
},
{
waitMs: RETRY_TIMEOUT,
intervalMs: RETRY_PAUSE,
},
);
} catch {
await this._enforceTermination();
throw this._log.errorWithException(
`The expected screen record file '${this._videoPath}' does not exist. ` +
`Check the server log for more details`,
);
}
this._log.info(
`The video recording has started. Will timeout in ${util.pluralize('second', this._timeLimit, true)}`,
);
}
async stop(force: boolean = false): Promise<string> {
if (force) {
return await this._enforceTermination();
}
if (!this.isRunning()) {
this._log.debug('Screen recording is not running. Returning the recent result');
return await this.getVideoPath();
}
return new B((resolve, reject) => {
const timer = setTimeout(async () => {
await this._enforceTermination();
reject(
new Error(`Screen recording has failed to exit after ${PROCESS_SHUTDOWN_TIMEOUT}ms`),
);
}, PROCESS_SHUTDOWN_TIMEOUT);
this._process?.once('exit', async (code, signal) => {
clearTimeout(timer);
if (code === 0) {
resolve(await this.getVideoPath());
} else {
reject(new Error(`Screen recording exited with error code ${code}, signal ${signal}`));
}
});
this._process?.proc?.stdin?.write('q');
this._process?.proc?.stdin?.end();
});
}
private async _enforceTermination(): Promise<string> {
if (this._process && this.isRunning()) {
this._log.debug('Force-stopping the currently running video recording');
try {
await this._process.stop('SIGKILL');
} catch {
// Ignore errors during force stop
}
}
this._process = null;
const videoPath = await this.getVideoPath();
if (videoPath) {
await fs.rimraf(videoPath);
}
return '';
}
}
/**
* Record the display in background while the automated test is running.
* This method requires FFMPEG (https://www.ffmpeg.org/download.html) to be installed
* and present in PATH. Also, the Appium process must be allowed to access screen recording
* in System Preferences->Security & Privacy->Screen Recording.
* The resulting video uses H264 codec and is ready to be played by media players built-in into web browsers.
*
* @param deviceId - Screen device index to use for the recording.
* The list of available devices could be retrieved using
* `ffmpeg -f avfoundation -list_devices true -i` command.
* @param timeLimit - The maximum recording time, in seconds. The default
* value is 600 seconds (10 minutes).
* @param videoFilter - The video filter spec to apply for ffmpeg.
* See https://trac.ffmpeg.org/wiki/FilteringGuide for more details on the possible values.
* Example: Set it to `scale=ifnot(gte(iw\,1024)\,iw\,1024):-2` in order to limit the video width
* to 1024px. The height will be adjusted automatically to match the actual ratio.
* @param fps - The count of frames per second in the resulting video.
* The greater fps it has the bigger file size is. 15 by default.
* @param preset - One of the supported encoding presets. A preset is a collection of options that will provide a
* certain encoding speed to compression ratio.
* A slower preset will provide better compression (compression is quality per filesize).
* This means that, for example, if you target a certain file size or constant bit rate, you will
* achieve better quality with a slower preset. Read https://trac.ffmpeg.org/wiki/Encode/H.264 for more details.
* `veryfast` by default
* @param captureCursor - Whether to capture the mouse cursor while recording the screen.
* False by default
* @param captureClicks - Whether to capture mouse clicks while recording the screen.
* False by default.
* @param forceRestart - 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`). The default value is `true`.
* @throws {Error} If screen recording has failed to start or is not supported on the device under test.
*/
export async function startRecordingScreen(
this: Mac2Driver,
deviceId: string | number,
timeLimit?: string | number,
videoFilter?: string,
fps?: string | number,
preset?: Preset,
captureCursor?: boolean,
captureClicks?: boolean,
forceRestart: boolean = true,
): Promise<void> {
if (_.isNil(deviceId)) {
throw new Error(
`'deviceId' option must be provided. Run 'ffmpeg -f avfoundation -list_devices true -i' ` +
'to fetch the list of available device ids',
);
}
if (this._screenRecorder?.isRunning?.()) {
this.log.debug('The screen recording is already running');
if (!forceRestart) {
this.log.debug('Doing nothing');
return;
}
this.log.debug('Forcing the active screen recording to stop');
await this._screenRecorder.stop(true);
}
this._screenRecorder = null;
const videoPath = await tempDir.path({
prefix: util.uuidV4().substring(0, 8),
suffix: `.${DEFAULT_EXT}`,
});
this._screenRecorder = new ScreenRecorder(videoPath, this.log, {
fps: parseInt(`${fps}`, 10),
timeLimit: parseInt(`${timeLimit}`, 10),
preset,
captureCursor,
captureClicks,
videoFilter,
deviceId,
});
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 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.
* @param user - The name of the user for the remote authentication.
* @param pass - The password for the remote authentication.
* @param method - The http multipart upload method name. The 'PUT' one is used by default.
* @param headers - Additional headers mapping for multipart http(s) uploads
* @param fileFieldName - The name of the form field, where the file content BLOB should
* be stored for http(s) uploads
* @param formFields - Additional form fields for multipart http(s) uploads
* @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: Mac2Driver,
remotePath?: string | null,
user?: string,
pass?: string,
method?: string,
headers?: StringRecord | [string, any][],
fileFieldName?: string,
formFields?: StringRecord | [string, string][],
): Promise<string> {
if (!this._screenRecorder) {
this.log.debug('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.debug('No video data is found. Returning an empty string');
return '';
}
const options: StringRecord = {
user,
pass,
method,
headers,
fileFieldName,
formFields,
};
return await uploadRecordedMedia.bind(this)(videoPath, remotePath, options);
}