appium-mac2-driver
Version:
XCTest-based Appium driver for macOS apps automation
309 lines • 13.2 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.NativeVideoChunksBroadcaster = void 0;
exports.macosStartNativeScreenRecording = macosStartNativeScreenRecording;
exports.macosGetNativeScreenRecordingInfo = macosGetNativeScreenRecordingInfo;
exports.macosStopNativeScreenRecording = macosStopNativeScreenRecording;
exports.macosListDisplays = macosListDisplays;
const lodash_1 = __importDefault(require("lodash"));
const bluebird_1 = __importStar(require("bluebird"));
const node_path_1 = __importDefault(require("node:path"));
const support_1 = require("appium/support");
const helpers_1 = require("./helpers");
const asyncbox_1 = require("asyncbox");
const teen_process_1 = require("teen_process");
const constants_1 = require("./bidi/constants");
const models_1 = require("./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
class NativeVideoChunksBroadcaster {
_ee;
_log;
_publishers;
_terminated;
constructor(ee, log) {
this._ee = ee;
this._log = log;
this._publishers = new Map();
this._terminated = false;
}
get hasPublishers() {
return this._publishers.size > 0;
}
schedule(uuid) {
if (!this._publishers.has(uuid)) {
this._publishers.set(uuid, this._createPublisher(uuid));
}
}
async waitFor(uuid) {
const publisher = this._publishers.get(uuid);
if (publisher) {
await publisher;
}
}
async shutdown(timeoutMs) {
try {
await this._wait(timeoutMs);
}
catch (e) {
this._log.warn(e.message);
}
await this._cleanup();
this._publishers = new Map();
}
async _createPublisher(uuid) {
let fullPath = '';
let bytesRead = 0n;
try {
await (0, asyncbox_1.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 support_1.fs.stat(fullPath, { bigint: true });
if (bytesRead < size) {
const handle = await support_1.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 support_1.fs.read(handle, buf, 0, bufferSize, bytesRead);
this._ee.emit(constants_1.BIDI_EVENT_NAME, (0, models_1.toNativeVideoChunkAddedEvent)(uuid, buf));
bytesRead += BigInt(bufferSize);
}
}
finally {
await support_1.fs.close(handle);
}
}
if (isCompleted) {
this._log.debug(`The native video recording identified by ${uuid} has been detected as completed`);
return;
}
await bluebird_1.default.delay(MONITORING_INTERVAL_DURATION_MS);
}
this._log.warn(`Stopped monitoring of the native video recording identified by ${uuid} ` +
`because of the timeout`);
}
async _wait(timeoutMs) {
if (!this.hasPublishers) {
return;
}
const timer = setTimeout(() => {
this._terminated = true;
}, timeoutMs);
const publishingErrors = [];
for (const publisher of this._publishers.values()) {
try {
await publisher;
}
catch (e) {
publishingErrors.push(e.message);
}
}
clearTimeout(timer);
if (!lodash_1.default.isEmpty(publishingErrors)) {
throw new Error(publishingErrors.join('\n'));
}
}
async _cleanup() {
if (!this.hasPublishers) {
return;
}
const attachments = await listAttachments();
if (lodash_1.default.isEmpty(attachments)) {
return;
}
const tasks = attachments
.map((attachmentPath) => [node_path_1.default.basename(attachmentPath), attachmentPath])
.filter(([name]) => this._publishers.has(name))
.map(([, attachmentPath]) => support_1.fs.rimraf(attachmentPath));
if (lodash_1.default.isEmpty(tasks)) {
return;
}
try {
await Promise.all(tasks);
this._log.debug(`Successfully deleted ${support_1.util.pluralize('leftover video recording', tasks.length, true)}`);
}
catch (e) {
this._log.warn(`Could not cleanup some leftover video recordings: ${e.message}`);
}
}
}
exports.NativeVideoChunksBroadcaster = NativeVideoChunksBroadcaster;
/**
* 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.
*/
async function macosStartNativeScreenRecording(fps, codec, displayId) {
const result = (await this.wda.proxy.command('/wda/video/start', 'POST', {
fps,
codec,
displayId,
}));
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.
*/
async function macosGetNativeScreenRecordingInfo() {
return (await this.wda.proxy.command('/wda/video', 'GET'));
}
/**
* 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.
*/
async function macosStopNativeScreenRecording(remotePath, user, pass, method, headers, fileFieldName, formFields, ignorePayload) {
const response = (await this.wda.proxy.command('/wda/video/stop', 'POST', {}));
if (!response || !lodash_1.default.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 bluebird_1.default.resolve(this._videoChunksBroadcaster.waitFor(uuid)).timeout(5000);
}
catch (e) {
if (e instanceof bluebird_1.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 = lodash_1.default.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 helpers_1.uploadRecordedMedia.bind(this)(matchedVideoPath, remotePath, options);
}
/**
* Fetches information about available displays
*
* @returns A map where keys are display identifiers and values are display infos
*/
async function macosListDisplays() {
return (await this.wda.proxy.command('/wda/displays/list', 'GET'));
}
// #region Private functions
async function listAttachments() {
// The expected path looks like
// $HOME/Library/Daemon Containers/EFDD24BF-F856-411F-8954-CD5F0D6E6F3E/Data/Attachments/CAE7E5E2-5AC9-4D33-A47B-C491D644DE06
const deamonContainersRoot = node_path_1.default.resolve(process.env.HOME, 'Library', 'Daemon Containers');
return await support_1.fs.glob(`*/Data/Attachments/*`, {
cwd: deamonContainersRoot,
absolute: true,
});
}
async function isFileUsed(fpath, userProcessName) {
const { stdout } = await (0, teen_process_1.exec)('lsof', [fpath]);
return stdout.includes(userProcessName);
}
// #endregion
//# sourceMappingURL=native-record-screen.js.map