appium-safari-driver
Version:
Appium driver for Safari browser
368 lines (338 loc) • 11.3 kB
text/typescript
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;
}