mirror of
https://github.com/appium/appium.git
synced 2026-10-27 03:25:54 +08:00
* fix(support): preserve exclamation marks with apostrophes * test(support): use async shell execution and document workaround
704 lines
24 KiB
TypeScript
704 lines
24 KiB
TypeScript
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 string = string> = T extends '' ? never : T;
|
|
|
|
/**
|
|
* @deprecated This helper is slated for removal. Please migrate callers away from UUID v1.
|
|
*/
|
|
export function uuidV1(...args: Parameters<typeof uuidV1Lib>): ReturnType<typeof uuidV1Lib> {
|
|
return uuidV1Lib(...args);
|
|
}
|
|
|
|
/**
|
|
* @deprecated This helper is slated for removal. Please migrate callers away from UUID v3.
|
|
*/
|
|
export function uuidV3(...args: Parameters<typeof uuidV3Lib>): ReturnType<typeof uuidV3Lib> {
|
|
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<typeof uuidV5Lib>): ReturnType<typeof uuidV5Lib> {
|
|
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<T>(val: T): val is NonNullable<T> {
|
|
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<Fn extends (...args: any[]) => any>(
|
|
fn: Fn,
|
|
resolver?: (...args: Parameters<Fn>) => unknown,
|
|
): Fn & {cache: Map<unknown, ReturnType<Fn>>} {
|
|
const memoizedFn = function (this: unknown, ...args: Parameters<Fn>) {
|
|
const key = resolver ? resolver.apply(this, args) : args[0];
|
|
if (memoizedFn.cache.has(key)) {
|
|
return memoizedFn.cache.get(key) as ReturnType<Fn>;
|
|
}
|
|
const result = fn.apply(this, args);
|
|
memoizedFn.cache.set(key, result);
|
|
return result;
|
|
} as unknown as Fn & {cache: Map<unknown, ReturnType<Fn>>};
|
|
memoizedFn.cache = new Map<unknown, ReturnType<Fn>>();
|
|
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<string, unknown> {
|
|
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<T>(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<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.
|
|
*
|
|
* @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<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 = typeof replacer === 'function' ? 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 {
|
|
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<T extends Record<string, unknown>>(
|
|
obj: T,
|
|
predicate?: ((value: unknown, obj: T) => boolean) | unknown,
|
|
): Partial<T> {
|
|
const newObj = {...obj} as Record<string, unknown>;
|
|
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<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` 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<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 = ['==', '!=', '>', '<', '>=', '<=', '='];
|
|
|
|
/** 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<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>};
|
|
|
|
/**
|
|
* 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<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 = createBase64EncodeStream();
|
|
const resultWriteStreamPromise = new Promise<void>((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<void>((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<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 {
|
|
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;
|
|
}
|