appium-mac2-driver
Version:
XCTest-based Appium driver for macOS apps automation
349 lines (315 loc) • 11.4 kB
text/typescript
import _ from 'lodash';
import B, {TimeoutError} from 'bluebird';
import path from 'node:path';
import {fs, util} from 'appium/support';
import type {Mac2Driver} from '../driver';
import {uploadRecordedMedia} from './helpers';
import type {AppiumLogger, StringRecord} from '@appium/types';
import type EventEmitter from 'node:events';
import {waitForCondition} from 'asyncbox';
import {exec} from 'teen_process';
import {BIDI_EVENT_NAME} from './bidi/constants';
import {toNativeVideoChunkAddedEvent} from './bidi/models';
const RECORDING_STARTUP_TIMEOUT_MS = 5000;
const BUFFER_SIZE = 0xffff;
const MONITORING_INTERVAL_DURATION_MS = 1000;
const MAX_MONITORING_DURATION_MS = 24 * 60 * 60 * 1000; // 1 day
export class NativeVideoChunksBroadcaster {
private _ee: EventEmitter;
private _log: AppiumLogger;
private _publishers: Map<string, Promise<void>>;
private _terminated: boolean;
constructor(ee: EventEmitter, log: AppiumLogger) {
this._ee = ee;
this._log = log;
this._publishers = new Map();
this._terminated = false;
}
get hasPublishers(): boolean {
return this._publishers.size > 0;
}
schedule(uuid: string): void {
if (!this._publishers.has(uuid)) {
this._publishers.set(uuid, this._createPublisher(uuid));
}
}
async waitFor(uuid: string): Promise<void> {
const publisher = this._publishers.get(uuid);
if (publisher) {
await publisher;
}
}
async shutdown(timeoutMs: number): Promise<void> {
try {
await this._wait(timeoutMs);
} catch (e) {
this._log.warn(e.message);
}
await this._cleanup();
this._publishers = new Map();
}
private async _createPublisher(uuid: string): Promise<void> {
let fullPath = '';
let bytesRead = 0n;
try {
await waitForCondition(
async () => {
const paths = await listAttachments();
const result = paths.find((name) => name.endsWith(uuid));
if (result) {
fullPath = result;
return true;
}
return false;
},
{
waitMs: RECORDING_STARTUP_TIMEOUT_MS,
intervalMs: 300,
},
);
} catch {
throw new Error(
`The video recording identified by ${uuid} did not ` +
`start within ${RECORDING_STARTUP_TIMEOUT_MS}ms timeout`,
);
}
const startedMs = performance.now();
while (!this._terminated && performance.now() - startedMs < MAX_MONITORING_DURATION_MS) {
const isCompleted = !(await isFileUsed(fullPath, 'testman'));
const {size} = await fs.stat(fullPath, {bigint: true});
if (bytesRead < size) {
const handle = await fs.open(fullPath, 'r');
try {
while (bytesRead < size) {
const bufferSize = Number(
size - bytesRead > BUFFER_SIZE ? BUFFER_SIZE : size - bytesRead,
);
const buf = Buffer.alloc(bufferSize);
await fs.read(handle, buf as any, 0, bufferSize, bytesRead as any);
this._ee.emit(BIDI_EVENT_NAME, toNativeVideoChunkAddedEvent(uuid, buf));
bytesRead += BigInt(bufferSize);
}
} finally {
await fs.close(handle);
}
}
if (isCompleted) {
this._log.debug(
`The native video recording identified by ${uuid} has been detected as completed`,
);
return;
}
await B.delay(MONITORING_INTERVAL_DURATION_MS);
}
this._log.warn(
`Stopped monitoring of the native video recording identified by ${uuid} ` +
`because of the timeout`,
);
}
private async _wait(timeoutMs: number): Promise<void> {
if (!this.hasPublishers) {
return;
}
const timer = setTimeout(() => {
this._terminated = true;
}, timeoutMs);
const publishingErrors: string[] = [];
for (const publisher of this._publishers.values()) {
try {
await publisher;
} catch (e) {
publishingErrors.push(e.message);
}
}
clearTimeout(timer);
if (!_.isEmpty(publishingErrors)) {
throw new Error(publishingErrors.join('\n'));
}
}
private async _cleanup(): Promise<void> {
if (!this.hasPublishers) {
return;
}
const attachments = await listAttachments();
if (_.isEmpty(attachments)) {
return;
}
const tasks: Promise<any>[] = attachments
.map((attachmentPath) => [path.basename(attachmentPath), attachmentPath])
.filter(([name]) => this._publishers.has(name))
.map(([, attachmentPath]) => fs.rimraf(attachmentPath));
if (_.isEmpty(tasks)) {
return;
}
try {
await Promise.all(tasks);
this._log.debug(
`Successfully deleted ${util.pluralize('leftover video recording', tasks.length, true)}`,
);
} catch (e) {
this._log.warn(`Could not cleanup some leftover video recordings: ${e.message}`);
}
}
}
/**
* Initiates a new native screen recording session via XCTest.
* If the screen recording is already running then this call results in noop.
* A screen recording is running until a testing session is finished.
* If a recording has never been stopped explicitly during a test session
* then it would be stopped automatically upon test session termination,
* and leftover videos would be deleted as well.
*
* @since Xcode 15
* @param fps Frame Per Second setting for the resulting screen recording. 24 by default.
* @param codec Possible codec value, where `0` means H264 (the default setting), `1` means HEVC
* @param displayId Valid display identifier to record the video from. Main display is assumed
* by default.
* @returns The information about the asynchronously running video recording.
*/
export async function macosStartNativeScreenRecording(
this: Mac2Driver,
fps?: number,
codec?: number,
displayId?: number,
): Promise<ActiveVideoInfo> {
const result = (await this.wda.proxy.command('/wda/video/start', 'POST', {
fps,
codec,
displayId,
})) as ActiveVideoInfo;
this._videoChunksBroadcaster.schedule(result.uuid);
return result;
}
/**
* @since Xcode 15
* @returns The information about the asynchronously running video recording or
* null if no native video recording has been started.
*/
export async function macosGetNativeScreenRecordingInfo(
this: Mac2Driver,
): Promise<ActiveVideoInfo | null> {
return (await this.wda.proxy.command('/wda/video', 'GET')) as ActiveVideoInfo | null;
}
/**
* Stops native screen recordind.
* If no screen recording has been started before then the method throws an exception.
*
* @since Xcode 15
* @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
* @param ignorePayload Whether to ignore the resulting video payload
* and return an empty string. Useful if you prefer to fetch
* video chunks via a BiDi web socket.
* @returns Base64-encoded content of the recorded media file if 'remotePath'
* parameter is falsy or an empty string or ignorePayload is set to `true`.
* @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 macosStopNativeScreenRecording(
this: Mac2Driver,
remotePath?: string,
user?: string,
pass?: string,
method?: string,
headers?: StringRecord | [string, any][],
fileFieldName?: string,
formFields?: StringRecord | [string, string][],
ignorePayload?: boolean,
): Promise<string> {
const response: ActiveVideoInfo | null = (await this.wda.proxy.command(
'/wda/video/stop',
'POST',
{},
)) as ActiveVideoInfo | null;
if (!response || !_.isPlainObject(response)) {
throw new Error(
'There is no active screen recording, thus nothing to stop. Did you start it before?',
);
}
const {uuid} = response;
try {
await B.resolve(this._videoChunksBroadcaster.waitFor(uuid)).timeout(5000);
} catch (e) {
if (e instanceof TimeoutError) {
this.log.debug(
`The BiDi chunks broadcaster for the native screen recording identified ` +
`by ${uuid} cannot complete within 5000ms timeout`,
);
} else {
this.log.debug(e.stack);
}
}
if (ignorePayload) {
return '';
}
const matchedVideoPath = _.first((await listAttachments()).filter((name) => name.endsWith(uuid)));
if (!matchedVideoPath) {
throw new Error(
`The screen recording identified by ${uuid} cannot be retrieved. ` +
`Make sure the Appium Server process or its parent process (e.g. Terminal) ` +
`has Full Disk Access permission enabled in 'System Preferences' -> 'Privacy & Security' tab. ` +
`You may verify the presence of the recorded video manually by running the ` +
`'find "$HOME/Library/Daemon Containers/" -type f -name "${uuid}"' command from Terminal ` +
`if the latter has been granted the above access permission.`,
);
}
const options = {
user,
pass,
method,
headers,
fileFieldName,
formFields,
};
return await uploadRecordedMedia.bind(this)(matchedVideoPath, remotePath, options);
}
/**
* Fetches information about available displays
*
* @returns A map where keys are display identifiers and values are display infos
*/
export async function macosListDisplays(this: Mac2Driver): Promise<StringRecord<DisplayInfo>> {
return (await this.wda.proxy.command('/wda/displays/list', 'GET')) as StringRecord<DisplayInfo>;
}
// #region Private functions
async function listAttachments(): Promise<string[]> {
// The expected path looks like
// $HOME/Library/Daemon Containers/EFDD24BF-F856-411F-8954-CD5F0D6E6F3E/Data/Attachments/CAE7E5E2-5AC9-4D33-A47B-C491D644DE06
const deamonContainersRoot = path.resolve(
process.env.HOME as string,
'Library',
'Daemon Containers',
);
return await fs.glob(`*/Data/Attachments/*`, {
cwd: deamonContainersRoot,
absolute: true,
});
}
async function isFileUsed(fpath: string, userProcessName: string): Promise<boolean> {
const {stdout} = await exec('lsof', [fpath]);
return stdout.includes(userProcessName);
}
interface ActiveVideoInfo {
fps: number;
codec: number;
displayId: number;
uuid: string;
startedAt: number;
}
interface DisplayInfo {
id: number;
isMain: boolean;
}
// #endregion