import {randomUUID} from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import stream from 'node:stream'; import {isDeepStrictEqual, promisify} from 'node:util'; import {asyncmap} from 'asyncbox'; import B from 'bluebird'; import pluralizeLib from 'pluralize'; import * as semver from 'semver'; import {parse as shellParse, quote as shellQuote} from 'shell-quote'; import {fs} from './fs'; import {createBase64EncodeStream} from './internal'; export {shellParse}; import type {Element} from '@appium/types'; import * as _lockfile from 'lockfile'; import {v1 as uuidV1Lib, v3 as uuidV3Lib, v5 as uuidV5Lib} from 'uuid'; /** 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 '' ? never : T; /** * @deprecated This helper is slated for removal. Please migrate callers away from UUID v1. */ export function uuidV1(...args: Parameters): ReturnType { return uuidV1Lib(...args); } /** * @deprecated This helper is slated for removal. Please migrate callers away from UUID v3. */ export function uuidV3(...args: Parameters): ReturnType { return uuidV3Lib(...args); } /** * Generates a v4 UUID using Node.js crypto. * * @returns A UUID v4 string */ export function uuidV4(): string { return randomUUID(); } /** * @deprecated This helper is slated for removal. Please migrate callers away from UUID v5. */ export function uuidV5(...args: Parameters): ReturnType { return uuidV5Lib(...args); } /** * 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 typeof val === 'string' && 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(val: T): val is NonNullable { if (typeof val === 'number') { return !Number.isNaN(val); } return val !== undefined && val !== null; } /** * Creates a memoized version of a function. * * @param fn - Function to memoize * @param resolver - Optional cache key resolver. If omitted, the first argument is used as the cache key. * @returns Memoized function with a mutable `.cache` map (compatible with lodash-style cache resets in tests). */ export function memoize any>( fn: Fn, resolver?: (...args: Parameters) => unknown, ): Fn & {cache: Map>} { const memoizedFn = function (this: unknown, ...args: Parameters) { const key = resolver ? resolver.apply(this, args) : args[0]; if (memoizedFn.cache.has(key)) { return memoizedFn.cache.get(key) as ReturnType; } const result = fn.apply(this, args); memoizedFn.cache.set(key, result); return result; } as unknown as Fn & {cache: Map>}; memoizedFn.cache = new Map>(); return memoizedFn; } /** * Returns true if the value is a plain object (Object prototype or null prototype). * * @param value - Value to check * @returns `true` if the value is a plain object */ export function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== 'object') { return false; } const prototype = Object.getPrototypeOf(value); return prototype === null || prototype === Object.prototype; } /** * Returns true when the value has no elements/properties. * * @param value - Value to check * @returns `true` if the value is empty */ export function isEmpty(value: unknown): boolean { if (value == null) { return true; } if (typeof value === 'string' || Array.isArray(value) || Buffer.isBuffer(value)) { return value.length === 0; } if (value instanceof Map || value instanceof Set) { return value.size === 0; } if (typeof value === 'object' || typeof value === 'function') { return Object.keys(value).length === 0; } return true; } /** * Performs a deep equality check between two values. * * @param left - First value * @param right - Second value * @returns `true` when values are deeply equal */ export function isEqual(left: unknown, right: unknown): boolean { return isDeepStrictEqual(left, right); } /** * Escapes RegExp special characters in a string. * * @param value - Input string * @returns Escaped string safe for RegExp source */ export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Returns a duplicate-free copy of the input array. * * @param values - Input array * @returns New array with unique values preserving input order */ export function uniq(values: readonly T[]): T[] { return [...new Set(values)]; } /** * Truncates a string to a maximum length. * * @param value - Input string * @param options - Truncation options or max length * @returns Truncated string */ export function truncateString(value: string, options: TruncateStringOptions | number = {}): string { const normalizedOptions = typeof options === 'number' ? {length: options} : options; const {length, omission = '…'} = normalizedOptions; const stringValue = value == null ? '' : typeof value === 'number' && Object.is(value, -0) ? '-0' : String(value); const maxLength = length ?? 30; if (maxLength <= 0) { return omission; } if (stringValue.length <= maxLength) { return stringValue; } const contentLength = maxLength - omission.length; return contentLength > 0 ? `${stringValue.slice(0, contentLength)}${omission}` : omission; } /** * 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. * * @deprecated This helper is slated for removal. Consider implementing this functionality on your side. * @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()`. * * @deprecated This helper is slated for removal. Please migrate callers away from Bluebird. * @param ms - Delay in milliseconds before the promise resolves * @returns A Bluebird promise with a `cancel()` method; cancel rejects with CancellationError */ export function cancellableDelay(ms: number): B & {cancel: () => void} { let timer: NodeJS.Timeout; let resolve: () => void; let reject: (err: Error) => void; const delay = new B((_resolve, _reject) => { resolve = _resolve; reject = _reject; timer = setTimeout(() => resolve(), ms); }); (delay as B & {cancel: () => void}).cancel = function () { clearTimeout(timer); reject(new B.CancellationError()); }; return delay as B & {cancel: () => void}; } /** * Resolves each root path with the given path segments, returning an array of absolute paths. * * @deprecated This helper is slated for removal. Consider implementing this functionality on your side. * @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(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 = typeof replacer === 'function' ? replacer : (_k: string, v: unknown) => v; const bufferToJSON = Buffer.prototype.toJSON; delete (Buffer.prototype as Record).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).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 { if (isPlainObject(el)) { for (const propName of [W3C_WEB_ELEMENT_IDENTIFIER, 'ELEMENT']) { if (Object.hasOwn(el, propName)) { return el[propName] as string; } } } 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>( obj: T, predicate?: ((value: unknown, obj: T) => boolean) | unknown, ): Partial { const newObj = {...obj} as Record; let pred: (v: unknown, o: T) => boolean; if (predicate === undefined) { pred = (v) => v !== undefined; } else if (typeof predicate !== 'function') { 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; } /** * 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` or is equal to it * @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`); } } // An empty relative path means both arguments point to the same location. // Comparing the strings instead would accept any sibling whose name starts // with the root folder name, like '/root-backup' for the '/root' root. const relativePath = pathObj.relative(root, originalPath); return relativePath !== '..' && !relativePath.startsWith(`..${pathObj.sep}`) && !pathObj.isAbsolute(relativePath); } /** * 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 { 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 = ['==', '!=', '>', '<', '>=', '<=', '=']; /** Options for pluralize(). */ export interface PluralizeOptions { /** If true, prefix the result with the count (e.g. "3 ducks"). */ inclusive?: boolean; } /** Options for toInMemoryBase64(). */ export interface EncodingOptions { /** Maximum size of the resulting buffer in bytes. Default 1GB. */ maxSize?: number; } /** 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; } /** Options for truncateString(). */ export interface TruncateStringOptions { /** Maximum length of the resulting string. Default 30. */ length?: number; /** Suffix appended to truncated strings. Default "…". */ omission?: string; } /** Guard function that runs the given behavior under the lock. */ type LockFileGuardFn = (behavior: () => Promise | T) => Promise; /** Return type of getLockFileGuard: guard function with a .check() method. */ type LockFileGuard = LockFileGuardFn & {check: () => Promise}; /** * 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 (Array.isArray(args) ? args : [args]) .map((arg) => { // shell-quote escapes ! inside double quotes when the argument contains an // apostrophe. Non-interactive POSIX shells preserve that extra backslash. // Temporary workaround; remove once the dependency includes the released fix: // https://github.com/KazuCocoa/shell-quote/commit/9a70e24 // Keep the regression tests when switching back to shellQuote. if (typeof arg === 'string' && arg.includes("'") && arg.includes('!')) { return `'${arg.replace(/'/g, `'"'"'`)}'`; } return shellQuote([arg]); }) .join(' '); } /** * 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 (typeof options === 'boolean') { inclusive = options; } else if (typeof options?.inclusive === 'boolean') { inclusive = options.inclusive; } return pluralizeLib(word, count, inclusive); } /** * 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 { 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 = createBase64EncodeStream(); const resultWriteStreamPromise = new Promise((resolve, reject) => { resultWriteStream.once('error', (e: Error) => { readerStream.unpipe(base64EncoderStream); base64EncoderStream.unpipe(resultWriteStream); readerStream.destroy(); reject(e); }); resultWriteStream.once('finish', () => resolve()); }); const readStreamPromise = new Promise((resolve, reject) => { readerStream.once('close', () => resolve()); readerStream.once('error', (e: Error) => reject(new Error(`Failed to read '${srcPath}': ${e.message}`))); }); readerStream.pipe(base64EncoderStream); base64EncoderStream.pipe(resultWriteStream); await Promise.all([readStreamPromise, resultWriteStreamPromise]); return Buffer.concat(resultBuffers); } /** * 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(lockFile: string, opts: LockFileOptions = {}): LockFileGuard { const {timeout = 120, tryRecovery = false} = opts; const lock = promisify(_lockfile.lock) as (lockfile: string, opts: {wait: number}) => Promise; const checkLock = promisify(_lockfile.check) as (lockfile: string) => Promise; const unlock = promisify(_lockfile.unlock) as (lockfile: string) => Promise; const guard: LockFileGuard = Object.assign( async (behavior: () => Promise | T): Promise => { let triedRecovery = false; let acquired = false; while (!acquired) { try { try { _lockfile.lockSync(lockFile); } catch (e) { const lockErr = e as NodeJS.ErrnoException; if (lockErr.code !== 'EEXIST') { throw lockErr; } // Someone else is holding the lock (possibly grabbed it between us checking and // locking) -- wait for them to release it instead of failing outright. await lock(lockFile, {wait: timeout * 1000}); } acquired = true; } catch (e) { const err = e as Error; if (err.message?.includes('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}`, {cause: e}, ); } } } try { return await behavior(); } finally { await unlock(lockFile); } }, {check: () => checkLock(lockFile)}, ); return guard; }