Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to flick utility usage for real device screen recording #744

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 87 additions & 91 deletions lib/commands/recordscreen.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,24 @@
import _ from 'lodash';
import { retryInterval, waitForCondition } from 'asyncbox';
import B from 'bluebird';
import { waitForCondition } from 'asyncbox';
import { util, fs, tempDir } from 'appium-support';
import { exec } from 'teen_process';
import { exec, SubProcess } from 'teen_process';
import log from '../logger';
import { getPidUsingPattern, encodeBase64OrUpload } from '../utils';
import path from 'path';


let commands = {};

const RETRY_PAUSE = 1000;
const MAX_RECORDING_TIME_SEC = 60 * 10;
const DEFAULT_RECORDING_TIME_SEC = 60 * 3;
const PROCESS_SHUTDOWN_TIMEOUT_SEC = 5;
const REAL_DEVICE_BINARY = 'xrecord';
const REAL_DEVICE_BINARY = 'flick';
const REAL_DEVICE_PGREP_PATTERN = (udid) => `${REAL_DEVICE_BINARY}.*${udid}`;
const SIMULATOR_BINARY = 'xcrun';
const SIMULATOR_PGREP_PATTERN = (udid) => `simctl io ${udid} recordVideo`;
const DEFAULT_EXT = '.mp4';

async function extractCurrentRecordingPath (pid) {
const {output} = await exec('ps', ['o', 'command', '-p', pid]);
log.debug(`Got the following output from ps: ${output}`);
const pattern = new RegExp(/[\s="'](\/.*\.mp4)/);
const matches = pattern.exec(output);
return _.isEmpty(matches) ? null : _.last(matches);
}

async function finishScreenCapture (pid) {
async function interruptScreenCapture (pid) {
try {
await exec('kill', ['-2', pid]);
} catch (e) {
Expand All @@ -48,6 +39,20 @@ async function finishScreenCapture (pid) {
return true;
}

async function finishRealDeviceScreenCapture (udid, dstPath) {
const args = [
'video',
'-a', 'stop',
'-p', 'ios',
'-u', udid,
'-o', path.dirname(dstPath),
'-n', path.basename(dstPath),
];
log.info(`Stopping screen recording by executing ${REAL_DEVICE_BINARY} ` +
`with arguments ${JSON.stringify(args)}`);
await exec(REAL_DEVICE_BINARY, args);
}

async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions = {}) {
try {
return await encodeBase64OrUpload(localFile, remotePath, uploadOptions);
Expand All @@ -74,8 +79,8 @@ async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions
* @property {?string} videoType - The format of the screen capture to be recorded.
* Available formats: "h264", "mp4" or "fmp4". Default is "mp4".
* Only works for Simulator.
* @property {?string} videoQuality - The video encoding quality (low, medium, high, photo - defaults to medium).
* Only works for real devices.
* @property {?string|number} frameRate - The framerate (in seconds) when converting screenshots to video. Default: 1.
* Only works for real devices.
* @property {?boolean} forceRestart - Whether to try to catch and upload/return the currently running screen recording
* (`false`, the default setting) or ignore the result of it and start a new recording
* immediately.
Expand All @@ -85,7 +90,7 @@ async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions

/**
* Record the display of devices running iOS Simulator since Xcode 8.3 or real devices since iOS 8
* (xrecord utility is required: https://github.com/WPO-Foundation/xrecord).
* (flick utility is required: https://github.com/isonic1/flick).
* It records screen activity to an 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.
Expand All @@ -96,13 +101,13 @@ async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions
* @throws {Error} If screen recording has failed to start.
*/
commands.startRecordingScreen = async function (options = {}) {
const {videoType, timeLimit=DEFAULT_RECORDING_TIME_SEC, videoQuality='medium',
const {videoType, timeLimit=DEFAULT_RECORDING_TIME_SEC, frameRate,
forceRestart} = options;

let result = '';
if (!forceRestart) {
log.info(`Checking if there is/was a previous screen recording. ` +
`Set 'forceRestart' option to 'true' if you'd like to skip this step.`);
`Set 'forceRestart' option to 'true' if you'd like to skip this step.`);
result = await this.stopRecordingScreen(options);
}

Expand All @@ -129,21 +134,20 @@ commands.startRecordingScreen = async function (options = {}) {
let args;
if (this.isRealDevice()) {
binaryName = REAL_DEVICE_BINARY;
if (!await fs.which(binaryName)) {
log.errorAndThrow(`'${binaryName}' binary is not found in PATH. Make sure it is present on the system. ` +
`Check https://github.com/WPO-Foundation/xrecord for more details.`);
try {
await fs.which(binaryName);
} catch (err) {
log.errorAndThrow(`'${binaryName}' binary is not found in PATH. Make sure it is installed: 'gem install flick'. ` +
`Check https://github.com/isonic1/flick for more details.`);
}
args = [
'--quicktime',
'--id', this.opts.device.udid,
'--out', localPath,
`--force`
'video',
'-a', 'start',
'-p', 'ios',
'-u', this.opts.device.udid,
];
if (util.hasValue(timeLimit)) {
args.push('--time', `${timeLimit}`);
}
if (util.hasValue(videoQuality)) {
args.push('--quality', `${videoQuality}`);
if (util.hasValue(frameRate)) {
args.push('-r', frameRate);
}
} else {
binaryName = SIMULATOR_BINARY;
Expand All @@ -159,56 +163,34 @@ commands.startRecordingScreen = async function (options = {}) {
args.push(localPath);
}

// wrap in a manual Promise so we can handle errors in exec operation
return await new B(async (resolve, reject) => {
let err = null;
let timeout = Math.floor(parseFloat(timeLimit) * 1000);
if (timeout > MAX_RECORDING_TIME_SEC * 1000 || timeout <= 0) {
return reject(new Error(`The timeLimit value must be in range (0, ${MAX_RECORDING_TIME_SEC}] seconds. ` +
`The value of ${timeLimit} has been passed instead.`));
}
log.debug(`Beginning screen recording with command: '${binaryName} ${args.join(' ')}'` +
`Will timeout in ${timeout / 1000} s`);
if (this.isRealDevice()) {
// xrecord has its owen timer, so we only use this one as a safety precaution
// although simctl has no built-in timer and we have to be precise in such case
timeout += PROCESS_SHUTDOWN_TIMEOUT_SEC * 1000 * 2;
const timeout = Math.floor(parseFloat(timeLimit) * 1000);
if (timeout > MAX_RECORDING_TIME_SEC * 1000 || timeout <= 0) {
throw new Error(`The timeLimit value must be in range (0, ${MAX_RECORDING_TIME_SEC}] seconds. ` +
`The value of ${timeLimit} has been passed instead.`);
}
log.info(`Beginning screen recording with command: '${binaryName} ${args.join(' ')}'` +
`Will timeout in ${timeout / 1000} s`);
const recorderProc = new SubProcess(binaryName, args);
await recorderProc.start(0);
setTimeout(async () => {
if (_.isEmpty(this._recentScreenRecordingPath) || !recorderProc.isRunning) {
return;
}
// do not await here, as the call runs in the background and we check for its product
exec(binaryName, args, {timeout, killSignal: 'SIGINT'}).catch((e) => {
err = e;
});

// there is the delay time to start recording the screen for real devices, so, wait until it is ready.
// the ready condition is
// 1. check the movie file is created
// 2. check the screen capture has been started
//
// simctl keeps the file in an internal buffer instead and only creates it when the recording is done.
if (this.isRealDevice()) {
try {
await retryInterval(10, RETRY_PAUSE, async () => {
if (err) {
return;
}

const {size} = await fs.stat(localPath);
if (size <= 32) {
throw new Error(`Remote file '${localPath}' found but it is still too small: ${size} bytes`);
}
});
} catch (e) {
err = e;
try {
if (this.isRealDevice()) {
await finishRealDeviceScreenCapture(this.opts.device.udid, localPath);
} else {
await recorderProc.stop('SIGINT');
}
} catch (err) {
log.warn(`Cannot stop the screen recording after ${timeout}ms timeout. ` +
`Original error: ${err.message}`);
}
}, timeout);

if (err) {
log.error(`Error recording screen: ${err.message}`);
return reject(err);
}
this._recentScreenRecordingPath = localPath;
resolve(result);
});
this._recentScreenRecordingPath = localPath;
return result;
};

/**
Expand Down Expand Up @@ -243,33 +225,47 @@ commands.stopRecordingScreen = async function (options = {}) {

const pgrepPattern = this.isRealDevice() ? REAL_DEVICE_PGREP_PATTERN : SIMULATOR_PGREP_PATTERN;
const pid = await getPidUsingPattern(pgrepPattern(this.opts.device.udid));
let localPath = this._recentScreenRecordingPath;
if (_.isEmpty(pid)) {
log.info(`Screen recording is not running. There is nothing to stop.`);
log.info('Screen recording is not running. There is nothing to stop.');
} else {
localPath = localPath || await extractCurrentRecordingPath(pid);
try {
if (_.isEmpty(localPath)) {
if (_.isEmpty(this._recentScreenRecordingPath)) {
log.errorAndThrow(`Cannot parse the path to the file created by ` +
`screen recorder process from 'ps' output. ` +
`Did you start screen recording before?`);
`screen recorder process. ` +
`Did you start screen recording not from Appium before?`);
}
} finally {
if (!await finishScreenCapture(pid)) {
log.warn(`Unable to stop screen recording. Continuing anyway`);
if (this.isRealDevice() && !_.isEmpty(this._recentScreenRecordingPath)) {
try {
await finishRealDeviceScreenCapture(this.opts.device.udid, this._recentScreenRecordingPath);
} catch (err) {
log.warn(`Unable to stop screen recording. Continuing anyway. Original error: ${err.message}`);
}
} else {
if (!await interruptScreenCapture(pid)) {
log.warn(`Unable to stop screen recording. Continuing anyway`);
}
}
}
}

let result = '';
if (!_.isEmpty(localPath)) {
try {
result = await uploadRecordedMedia(localPath, remotePath, {user, pass, method});
} finally {
this._recentScreenRecordingPath = null;
if (_.isEmpty(this._recentScreenRecordingPath)) {
return '';
}

try {
if (!await fs.exists(this._recentScreenRecordingPath)) {
log.errorAndThrow(`The screen recorder utility has failed ` +
`to store the actual screen recording at '${this._recentScreenRecordingPath}'`);
}
return await uploadRecordedMedia(this._recentScreenRecordingPath, remotePath, {
user,
pass,
method
});
} finally {
this._recentScreenRecordingPath = null;
}
return result;
};


Expand Down
Loading