appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
389 lines • 15.3 kB
JavaScript
;
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