@appium/support
Version:
Support libs used across Appium packages
562 lines (518 loc) • 18.8 kB
text/typescript
import B from 'bluebird';
import _ from 'lodash';
import os from 'node:os';
import path from 'node:path';
import stream from 'node:stream';
import {promisify} from 'node:util';
import {asyncmap} from 'asyncbox';
import {fs} from './fs';
import * as semver from 'semver';
import {quote as shellQuote, parse as shellParse} from 'shell-quote';
export {shellParse};
import pluralizeLib from 'pluralize';
import {Base64Encode} from 'base64-stream';
export {v1 as uuidV1, v3 as uuidV3, v4 as uuidV4, v5 as uuidV5} from 'uuid';
import * as _lockfile from 'lockfile';
import type {Element} from '@appium/types';
/** W3C WebDriver element identifier key used in element objects. */
export const W3C_WEB_ELEMENT_IDENTIFIER = 'element-6066-11e4-a52e-4f735466cecf';
/** Size of one kibibyte in bytes (1024). */
export const KiB = 1024;
/** Size of one mebibyte in bytes (1024 * 1024). */
export const MiB = KiB * 1024;
/** Size of one gibibyte in bytes (1024 * 1024 * 1024). */
export const GiB = MiB * 1024;
/** A string which is never `''`. */
export type NonEmptyString<T extends string = string> = T extends '' ? never : T;
/**
* Type guard: returns true if the value is a non-empty string.
*
* @param val - Value to check
* @returns `true` if `val` is a string with at least one character
*/
export function hasContent(val: unknown): val is NonEmptyString {
return _.isString(val) && val !== '';
}
/**
* Type guard: returns true if the value is not `undefined`, `null`, or `NaN`.
*
* @param val - Value to check
* @returns `true` if `val` is non-null and non-undefined (and not NaN for numbers)
*/
export function hasValue<T>(val: T): val is NonNullable<T> {
if (_.isNumber(val)) {
return !_.isNaN(val);
}
return !_.isUndefined(val) && !_.isNull(val);
}
/**
* Escapes spaces in a string for use in command-line arguments (e.g. ` ` → `\ `).
*
* @param str - String that may contain spaces
* @returns String with spaces escaped by a backslash
*/
export function escapeSpace(str: string): string {
return str.split(/ /).join('\\ ');
}
/**
* Escapes special characters in a string (backslash, slash, quotes, control chars).
* If `quoteEscape` is provided, that character is also escaped.
*
* @param str - String to escape, or non-string value (returned unchanged)
* @param quoteEscape - Optional character to escape, or `false` to skip
* @returns Escaped string, or original value if `str` is not a string
*/
export function escapeSpecialChars(
str: string | unknown,
quoteEscape?: string | false
): string | unknown {
if (typeof str !== 'string') {
return str;
}
const result = str
.replace(/[\\]/g, '\\\\')
.replace(/[/]/g, '\\/')
.replace(/[\b]/g, '\\b')
.replace(/[\f]/g, '\\f')
.replace(/[\n]/g, '\\n')
.replace(/[\r]/g, '\\r')
.replace(/[\t]/g, '\\t')
.replace(/["]/g, '\\"')
.replace(/\\'/g, "\\'");
if (!quoteEscape) {
return result;
}
const re = new RegExp(quoteEscape, 'g');
return result.replace(re, `\\${quoteEscape}`);
}
/**
* Returns the first non-internal IPv4 address of the machine, if any.
*
* @returns The local IPv4 address, or `undefined` if none found
*/
export function localIp(): string | undefined {
const ifaces = os.networkInterfaces();
for (const addrs of Object.values(ifaces)) {
if (!addrs) {
continue;
}
for (const iface of addrs) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return undefined;
}
/**
* Creates a promise that resolves after a delay and can be cancelled via `.cancel()`.
*
* @param ms - Delay in milliseconds before the promise resolves
* @returns A Bluebird promise with a `cancel()` method; cancel rejects with CancellationError
*/
// TODO: replace with a native implementation in Appium 4
export function cancellableDelay(ms: number): B<void> & {cancel: () => void} {
let timer: NodeJS.Timeout;
let resolve: () => void;
let reject: (err: Error) => void;
const delay = new B<void>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
timer = setTimeout(() => resolve(), ms);
});
(delay as B<void> & {cancel: () => void}).cancel = function () {
clearTimeout(timer);
reject(new B.CancellationError());
};
return delay as B<void> & {cancel: () => void};
}
/**
* Resolves each root path with the given path segments, returning an array of absolute paths.
*
* @param roots - Base directory paths to resolve against
* @param args - Path segments to join with each root (e.g. 'foo', 'bar' → root/foo/bar)
* @returns Array of absolute paths, one per root
*/
export function multiResolve(roots: string[], ...args: string[]): string[] {
return roots.map((root) => path.resolve(root, ...args));
}
/**
* Parses a value as JSON if it is a string; otherwise returns the value as-is.
*
* @param obj - String (to parse) or other value (returned unchanged)
* @returns Parsed object or original value
*/
export function safeJsonParse<T>(obj: unknown): T {
try {
return JSON.parse(obj as string) as T;
} catch {
return obj as T;
}
}
/**
* Stringifies an object to JSON, converting Buffers to strings for readable output.
*
* @param obj - Object to serialize
* @param replacer - Optional replacer function (same as JSON.stringify)
* @param space - Indentation for pretty-printing. Defaults to 2
* @returns JSON string
*/
export function jsonStringify(
obj: unknown,
replacer: ((key: string, value: unknown) => unknown) | null = null,
space: number | string = 2
): string {
const replacerFunc = _.isFunction(replacer) ? replacer : (_k: string, v: unknown) => v;
const bufferToJSON = Buffer.prototype.toJSON;
delete (Buffer.prototype as Record<string, unknown>).toJSON;
try {
return JSON.stringify(
obj,
(key, value) => {
const updatedValue = Buffer.isBuffer(value) ? value.toString('utf8') : value;
return replacerFunc(key, updatedValue);
},
space
);
} finally {
(Buffer.prototype as Record<string, unknown>).toJSON = bufferToJSON;
}
}
/**
* Extracts the element ID from a W3C or JSONWP element object, or returns the string if already an ID.
*
* @param el - Element object (with ELEMENT or W3C identifier) or raw element ID string
* @returns The element ID string
*/
export function unwrapElement(el: Element | string): string {
const elObj = el as unknown as Record<string, string>;
for (const propName of [W3C_WEB_ELEMENT_IDENTIFIER, 'ELEMENT']) {
if (_.has(elObj, propName)) {
return elObj[propName];
}
}
return el as string;
}
/**
* Wraps an element ID string in an element object compatible with both W3C and JSONWP.
*
* @param elementId - The element ID to wrap
* @returns Element object with both ELEMENT and W3C identifier keys
*/
export function wrapElement(elementId: string): Element {
return {
ELEMENT: elementId,
[W3C_WEB_ELEMENT_IDENTIFIER]: elementId,
} as Element;
}
/**
* Returns a copy of the object containing only properties that pass the predicate.
* If the predicate is missing, removes properties whose values are undefined.
* If the predicate is a scalar, keeps only properties whose value equals that scalar.
* If the predicate is a function, calls it for each (value, obj) and keeps properties where it returns true.
*
* @param obj - Source object to filter
* @param predicate - Optional filter: undefined (drop undefined values), scalar (value match), or function
* @returns New object with only the properties that pass the predicate
*/
export function filterObject<T extends Record<string, unknown>>(
obj: T,
predicate?: ((value: unknown, obj: T) => boolean) | unknown
): Partial<T> {
const newObj = _.clone(obj) as Record<string, unknown>;
let pred: (v: unknown, o: T) => boolean;
if (_.isUndefined(predicate)) {
pred = (v) => !_.isUndefined(v);
} else if (!_.isFunction(predicate)) {
const valuePredicate = predicate;
pred = (v) => v === valuePredicate;
} else {
pred = predicate as (v: unknown, o: T) => boolean;
}
for (const key of Object.keys(obj)) {
if (!pred(obj[key], obj)) {
delete newObj[key];
}
}
return newObj as Partial<T>;
}
/**
* Converts a byte count to a human-readable size string (e.g. "1.50 MB").
*
* @param bytes - Number of bytes (or string coercible to a number)
* @returns Formatted string like "123 B", "1.50 KB", "2.00 MB", "3.00 GB"
* @throws {Error} If bytes cannot be converted to a non-negative integer
*/
export function toReadableSizeString(bytes: number | string): string {
const intBytes = parseInt(String(bytes), 10);
if (isNaN(intBytes) || intBytes < 0) {
throw new Error(`Cannot convert '${bytes}' to a readable size format`);
}
if (intBytes >= GiB) {
return `${(intBytes / (GiB * 1.0)).toFixed(2)} GB`;
} else if (intBytes >= MiB) {
return `${(intBytes / (MiB * 1.0)).toFixed(2)} MB`;
} else if (intBytes >= KiB) {
return `${(intBytes / (KiB * 1.0)).toFixed(2)} KB`;
}
return `${intBytes} B`;
}
/**
* Checks whether the given path is a subpath of the given root folder.
*
* @param originalPath - The absolute file or folder path to test
* @param root - The absolute root folder path
* @param forcePosix - If true, interpret paths in POSIX format (e.g. on Windows)
* @returns `true` if `originalPath` is under `root`
* @throws {Error} If either path is not absolute
*/
export function isSubPath(
originalPath: string,
root: string,
forcePosix: boolean | null = null
): boolean {
const pathObj = forcePosix ? path.posix : path;
for (const p of [originalPath, root]) {
if (!pathObj.isAbsolute(p)) {
throw new Error(`'${p}' is expected to be an absolute path`);
}
}
const normalizedRoot = pathObj.normalize(root);
const normalizedPath = pathObj.normalize(originalPath);
return normalizedPath.startsWith(normalizedRoot);
}
/**
* Checks whether the given paths refer to the same file system entity (same inode).
* All paths must exist.
*
* @param path1 - First path
* @param path2 - Second path
* @param pathN - Additional paths to compare
* @returns `true` if all paths resolve to the same file/directory
*/
export async function isSameDestination(
path1: string,
path2: string,
...pathN: string[]
): Promise<boolean> {
const allPaths = [path1, path2, ...pathN];
if (!(await asyncmap(allPaths, async (p) => fs.exists(p))).every(Boolean)) {
return false;
}
const areAllItemsEqual = (arr: unknown[]) => !!arr.reduce((a, b) => (a === b ? a : NaN));
if (areAllItemsEqual(allPaths)) {
return true;
}
const mapCb = async (x: string) => (await fs.stat(x, {bigint: true})).ino;
return areAllItemsEqual(await asyncmap(allPaths, mapCb));
}
/**
* Coerces a value to a valid semver string (e.g. "1.0" → "1.0.0").
*
* @param ver - Version string or number to coerce
* @param strict - If true, throws when coercion fails; if false, returns null
* @returns Valid semver string, or null when strict is false and coercion fails
* @throws {Error} When strict is true and ver cannot be coerced
*/
export function coerceVersion(ver: string, strict: true): string;
export function coerceVersion(ver: string, strict?: false): string | null;
export function coerceVersion(ver: string, strict = true): string | null {
let result = semver.valid(`${ver}`);
if (!result) {
result = semver.valid(semver.coerce(`${ver}`));
}
if (strict && !result) {
throw new Error(`'${ver}' cannot be coerced to a valid version number`);
}
return result;
}
const SUPPORTED_OPERATORS = ['==', '!=', '>', '<', '>=', '<=', '='];
/**
* Compares two version strings using the given operator.
*
* @param ver1 - First version string
* @param operator - One of: ==, !=, >, <, >=, <=, =
* @param ver2 - Second version string
* @returns `true` if ver1 operator ver2 holds (e.g. "2.0.0" >= "1.0.0")
* @throws {Error} If operator is unsupported or either version cannot be coerced
*/
export function compareVersions(
ver1: string,
operator: string,
ver2: string
): boolean {
if (!SUPPORTED_OPERATORS.includes(operator)) {
throw new Error(
`The '${operator}' comparison operator is not supported. ` +
`Only '${JSON.stringify(SUPPORTED_OPERATORS)}' operators are supported`
);
}
const semverOperator = ['==', '!='].includes(operator) ? '=' : operator;
const v1 = coerceVersion(ver1, true);
const v2 = coerceVersion(ver2, true);
const result = semver.satisfies(v1, `${semverOperator}${v2}`);
return operator === '!=' ? !result : result;
}
/**
* Quotes and escapes command-line arguments so they can be safely passed to a shell.
*
* @param args - Single argument or array of arguments to quote
* @returns Quoted string suitable for shell parsing
*/
export function quote(args: string | string[]): string {
return shellQuote(_.castArray(args));
}
/** Options for pluralize(). */
export interface PluralizeOptions {
/** If true, prefix the result with the count (e.g. "3 ducks"). */
inclusive?: boolean;
}
/**
* Returns the plural or singular form of a word appropriate to the count (e.g. "duck" + 1 → "duck", + 2 → "ducks").
*
* @param word - The word to pluralize (or singularize when count is 1)
* @param count - The count used to choose singular vs plural
* @param options - Options object or boolean: use `inclusive: true` (or `true`) to prefix with the number (e.g. "3 ducks")
* @returns The correctly inflected word, optionally prefixed with the count
*/
export function pluralize(
word: string,
count: number,
options: PluralizeOptions | boolean = {}
): string {
let inclusive = false;
if (_.isBoolean(options)) {
inclusive = options;
} else if (_.isBoolean(options?.inclusive)) {
inclusive = options.inclusive;
}
return pluralizeLib(word, count, inclusive);
}
/** Options for toInMemoryBase64(). */
export interface EncodingOptions {
/** Maximum size of the resulting buffer in bytes. Default 1GB. */
maxSize?: number;
}
/**
* Reads a file and returns its contents as a base64-encoded buffer.
*
* @param srcPath - Full path to the file to encode
* @param opts - Encoding options (e.g. maxSize to cap buffer size)
* @returns Buffer containing the base64-encoded file content
* @throws {Error} If the file does not exist, is a directory, cannot be read, or exceeds maxSize
*/
export async function toInMemoryBase64(
srcPath: string,
opts: EncodingOptions = {}
): Promise<Buffer> {
if (!(await fs.exists(srcPath)) || (await fs.stat(srcPath)).isDirectory()) {
throw new Error(`No such file: ${srcPath}`);
}
const {maxSize = 1 * GiB} = opts;
const resultBuffers: Buffer[] = [];
let resultBuffersSize = 0;
const resultWriteStream = new stream.Writable({
write(buffer: Buffer, _encoding: string, next: (err?: Error) => void) {
resultBuffers.push(buffer);
resultBuffersSize += buffer.length;
if (maxSize > 0 && resultBuffersSize > maxSize) {
resultWriteStream.emit(
'error',
new Error(
`The size of the resulting buffer must not be greater than ${toReadableSizeString(maxSize)}`
)
);
}
next();
},
});
const readerStream = fs.createReadStream(srcPath);
const base64EncoderStream = new Base64Encode();
const encoderWritable = base64EncoderStream as NodeJS.WritableStream;
const encoderReadable = base64EncoderStream as NodeJS.ReadableStream;
const resultWriteStreamPromise = new Promise<void>((resolve, reject) => {
resultWriteStream.once('error', (e: Error) => {
readerStream.unpipe(encoderWritable);
encoderReadable.unpipe(resultWriteStream);
readerStream.destroy();
reject(e);
});
resultWriteStream.once('finish', () => resolve());
});
const readStreamPromise = new Promise<void>((resolve, reject) => {
readerStream.once('close', () => resolve());
readerStream.once('error', (e: Error) =>
reject(new Error(`Failed to read '${srcPath}': ${e.message}`))
);
});
readerStream.pipe(encoderWritable);
encoderReadable.pipe(resultWriteStream);
await Promise.all([readStreamPromise, resultWriteStreamPromise]);
return Buffer.concat(resultBuffers);
}
/** Options for getLockFileGuard(). */
export interface LockFileOptions {
/** Max time in seconds to wait for the lock. Default 120. */
timeout?: number;
/** If true, attempt to unlock and retry once if the first acquisition times out (e.g. stale lock). */
tryRecovery?: boolean;
}
/** Guard function that runs the given behavior under the lock. */
type LockFileGuardFn<T> = (behavior: () => Promise<T> | T) => Promise<T>;
/** Return type of getLockFileGuard: guard function with a .check() method. */
type LockFileGuard<T> = LockFileGuardFn<T> & {check: () => Promise<boolean>};
/**
* Creates a guard that serializes access to a critical section using a lock file.
* The returned function acquires the lock, runs the given behavior, then releases the lock.
* Also exposes `.check()` to test whether the lock is currently held.
*
* @param lockFile - Full path to the lock file
* @param opts - Options (see {@link LockFileOptions})
* @returns Async function that accepts a callback to run under the lock, plus a `.check()` method
*/
export function getLockFileGuard<T>(
lockFile: string,
opts: LockFileOptions = {}
): LockFileGuard<T> {
const {timeout = 120, tryRecovery = false} = opts;
const lock = promisify(_lockfile.lock) as (
lockfile: string,
opts: {wait: number}
) => Promise<void>;
const checkLock = promisify(_lockfile.check) as (lockfile: string) => Promise<boolean>;
const unlock = promisify(_lockfile.unlock) as (lockfile: string) => Promise<void>;
const guard: LockFileGuard<T> = Object.assign(
async (behavior: () => Promise<T> | T): Promise<T> => {
let triedRecovery = false;
let acquired = false;
while (!acquired) {
try {
if (_lockfile.checkSync(lockFile)) {
await lock(lockFile, {wait: timeout * 1000});
} else {
_lockfile.lockSync(lockFile);
}
acquired = true;
} catch (e) {
const err = e as Error;
if (_.includes(err.message, 'EEXIST') && tryRecovery && !triedRecovery) {
_lockfile.unlockSync(lockFile);
triedRecovery = true;
} else {
throw new Error(
`Could not acquire lock on '${lockFile}' after ${timeout}s. ` +
`Original error: ${err.message}`
);
}
}
}
try {
return await behavior();
} finally {
await unlock(lockFile);
}
},
{check: () => checkLock(lockFile)}
);
return guard;
}