util.js 14.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455
import B from 'bluebird';
import _ from 'lodash';
import os from 'os';
import path from 'path';
import fs from './fs';
import semver from 'semver';
import {
  // https://www.npmjs.com/package/shell-quote
  quote as shellQuote,
  parse as shellParse,
} from 'shell-quote';
import pluralizeLib from 'pluralize';
import stream from 'stream';
import { Base64Encode } from 'base64-stream';
import {
  // https://www.npmjs.com/package/uuid
  v1 as uuidV1, v3 as uuidV3,
  v4 as uuidV4, v5 as uuidV5
} from 'uuid';


const W3C_WEB_ELEMENT_IDENTIFIER = 'element-6066-11e4-a52e-4f735466cecf';
const KiB = 1024;
const MiB = KiB * 1024;
const GiB = MiB * 1024;

export function hasContent (val) {
  return _.isString(val) && val !== '';
}

// return true if the the value is not undefined, null, or NaN.
function hasValue (val) {
  let hasVal = false;
  // avoid incorrectly evaluating `0` as false
  if (_.isNumber(val)) {
    hasVal = !_.isNaN(val);
  } else {
    hasVal = !_.isUndefined(val) && !_.isNull(val);
  }

  return hasVal;
}

// escape spaces in string, for commandline calls
function escapeSpace (str) {
  return str.split(/ /).join('\\ ');
}

function escapeSpecialChars (str, quoteEscape) {
  if (typeof str !== 'string') {
    return str;
  }
  if (typeof quoteEscape === 'undefined') {
    quoteEscape = false;
  }
  str = str
    .replace(/[\\]/g, '\\\\')
    .replace(/[\/]/g, '\\/') // eslint-disable-line no-useless-escape
    .replace(/[\b]/g, '\\b')
    .replace(/[\f]/g, '\\f')
    .replace(/[\n]/g, '\\n')
    .replace(/[\r]/g, '\\r')
    .replace(/[\t]/g, '\\t')
    .replace(/[\"]/g, '\\"') // eslint-disable-line no-useless-escape
    .replace(/\\'/g, "\\'");
  if (quoteEscape) {
    let re = new RegExp(quoteEscape, 'g');
    str = str.replace(re, `\\${quoteEscape}`);
  }
  return str;
}

function localIp () {
  let ip = _.chain(os.networkInterfaces())
    .values()
    .flatten()
    .filter(function (val) {
      return (val.family === 'IPv4' && val.internal === false);
    })
    .map('address')
    .first()
    .value();
  return ip;
}

/*
 * Creates a promise that is cancellable, and will timeout
 * after `ms` delay
 */
function cancellableDelay (ms) {
  let timer;
  let resolve;
  let reject;

  const delay = new B.Promise((_resolve, _reject) => {
    resolve = _resolve;
    reject = _reject;
    timer = setTimeout(function () {
      resolve();
    }, ms);
  });

  // override Bluebird's `cancel`, which does not work when using `await` on
  // a promise, since `resolve`/`reject` are never called
  delay.cancel = function () {
    clearTimeout(timer);
    reject(new B.CancellationError());
  };
  return delay;
}

function multiResolve (roots, ...args) {
  return roots.map((root) => {
    return path.resolve(root, ...args);
  });
}

/*
 * Parses an object if possible. Otherwise returns the object without parsing.
 */
function safeJsonParse (obj) {
  try {
    return JSON.parse(obj);
  } catch (ign) {
    // ignore: this is not json parsable
    return obj;
  }
}

/*
 * Stringifies the object passed in, converting Buffers into Strings for better
 * display. This mimics JSON.stringify (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
 * except the `replacer` argument can only be a function.
 *
 * @param {object} obj - the object to be serialized
 * @param {?function} replacer - function to transform the properties added to the
 *                               serialized object
 * @param {?number|string} space - used to insert white space into the output JSON
 *                                 string for readability purposes. Defaults to 2
 * returns {string} - the JSON object serialized as a string
 */
function jsonStringify (obj, replacer, space = 2) {
  // if no replacer is passed, or it is not a function, just use a pass-through
  if (!_.isFunction(replacer)) {
    replacer = (k, v) => v;
  }

  // Buffers cannot be serialized in a readable way
  const bufferToJSON = Buffer.prototype.toJSON;
  delete Buffer.prototype.toJSON;
  try {
    return JSON.stringify(obj, (key, value) => {
      const updatedValue = Buffer.isBuffer(value)
        ? value.toString('utf8')
        : value;
      return replacer(key, updatedValue);
    }, space);
  } finally {
    // restore the function, so as to not break further serialization
    Buffer.prototype.toJSON = bufferToJSON;
  }
}

/*
 * Removes the wrapper from element, if it exists.
 *   { ELEMENT: 4 } becomes 4
 *   { element-6066-11e4-a52e-4f735466cecf: 5 } becomes 5
 */
function unwrapElement (el) {
  for (const propName of [W3C_WEB_ELEMENT_IDENTIFIER, 'ELEMENT']) {
    if (_.has(el, propName)) {
      return el[propName];
    }
  }
  return el;
}

function wrapElement (elementId) {
  return {
    ELEMENT: elementId,
    [W3C_WEB_ELEMENT_IDENTIFIER]: elementId,
  };
}

/*
 * Returns object consisting of all properties in the original element
 * which were truthy given the predicate.
 * If the predicate is
 *   * missing - it will remove all properties whose values are `undefined`
 *   * a scalar - it will test all properties' values against that value
 *   * a function - it will pass each value and the original object into the function
 */
function filterObject (obj, predicate) {
  let newObj = _.clone(obj);
  if (_.isUndefined(predicate)) {
    // remove any element from the object whose value is undefined
    predicate = (v) => !_.isUndefined(v);
  } else if (!_.isFunction(predicate)) {
    // make predicate into a function
    const valuePredicate = predicate;
    predicate = (v) => v === valuePredicate;
  }
  for (const key of Object.keys(obj)) {
    if (!predicate(obj[key], obj)) {
      delete newObj[key];
    }
  }
  return newObj;
}

/**
 * Converts number of bytes to a readable size string.
 *
 * @param {number|string} bytes - The actual number of bytes.
 * @returns {string} The actual string representation, for example
 *                   '1.00 KB' for '1024 B'
 * @throws {Error} If bytes count cannot be converted to an integer or
 *                 if it is less than zero.
 */
function toReadableSizeString (bytes) {
  const intBytes = parseInt(bytes, 10);
  if (isNaN(intBytes) || intBytes < 0) {
    throw new Error(`Cannot convert '${bytes}' to a readable size format`);
  }
  if (intBytes >= GiB) {
    return `${parseFloat(intBytes / (GiB * 1.0)).toFixed(2)} GB`;
  } else if (intBytes >= MiB) {
    return `${parseFloat(intBytes / (MiB * 1.0)).toFixed(2)} MB`;
  } else if (intBytes >= KiB) {
    return `${parseFloat(intBytes / (KiB * 1.0)).toFixed(2)} KB`;
  }
  return `${intBytes} B`;
}

/**
 * Checks whether the given path is a subpath of the
 * particular root folder. Both paths can include .. and . specifiers
 *
 * @param {string} originalPath The absolute file/folder path
 * @param {string} root The absolute root folder path
 * @param {?boolean} forcePosix Set it to true if paths must be interpreted in POSIX format
 * @returns {boolean} true if the given original path is the subpath of the root folder
 * @throws {Error} if any of the given paths is not absolute
 */
function isSubPath (originalPath, root, forcePosix = null) {
  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 are pointing to the same file system
 * destination.
 *
 * @param {string} path1 - Absolute or relative path to a file/folder
 * @param {string} path2 - Absolute or relative path to a file/folder
 * @param {...string} pathN - Zero or more absolute or relative paths to files/folders
 * @returns {boolean} true if all paths are pointing to the same file system item
 */
async function isSameDestination (path1, path2, ...pathN) {
  const allPaths = [path1, path2, ...pathN];
  if (!await B.reduce(allPaths, async (a, b) => a && await fs.exists(b), true)) {
    return false;
  }

  const areAllItemsEqual = (arr) => !!arr.reduce((a, b) => a === b ? a : NaN);
  if (areAllItemsEqual(allPaths)) {
    return true;
  }

  // Node 10.5.0 introduced bigint support in stat, which allows for more precision
  // however below that the options get interpreted as the callback
  // TODO: remove when Node 10 is no longer supported
  let mapCb = async (x) => await fs.stat(x, {
    bigint: true,
  }).ino;
  if (semver.lt(process.version, '10.5.0')) {
    mapCb = async (x) => await fs.stat(x).ino;
  }
  return areAllItemsEqual(await B.map(allPaths, mapCb));
}

/**
 * Coerces the given number/string to a valid version string
 *
 * @param {string|number} ver - Version string to coerce
 * @param {boolean} strict [true] - If true then an exception will be thrown
 * if `ver` cannot be coerced
 * @returns {string} Coerced version number or null if the string cannot be
 * coerced and strict mode is disabled
 * @throws {Error} if strict mode is enabled and `ver` cannot be coerced
 */
function coerceVersion (ver, strict = true) {
  const 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
 *
 * @param {string|number} ver1 - The first version number to compare. Should be a valid
 * version number supported by semver parser.
 * @param {string|number} ver2 - The second version number to compare. Should be a valid
 * version number supported by semver parser.
 * @param {string} operator - One of supported version number operators:
 * ==, !=, >, <, <=, >=, =
 * @returns {boolean} true or false depending on the actual comparison result
 * @throws {Error} if an unsupported operator is supplied or any of the supplied
 * version strings cannot be coerced
 */
function compareVersions (ver1, operator, ver2) {
  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 result = semver.satisfies(coerceVersion(ver1), `${semverOperator}${coerceVersion(ver2)}`);
  return operator === '!=' ? !result : result;
}

/**
 * Add appropriate quotes to command arguments. See https://github.com/substack/node-shell-quote
 * for more details
 *
 * @param {string|Array<string>} - The arguments that will be parsed
 * @returns {string} - The arguments, quoted
 */
function quote (args) {
  return shellQuote(args);
}

/**
 * This function is necessary to workaround unexpected memory leaks
 * caused by NodeJS string interning
 * behavior described in https://bugs.chromium.org/p/v8/issues/detail?id=2869
 *
 * @param {*} s - The string to unleak
 * @return {string} Either the unleaked string or the original object converted to string
 */
function unleakString (s) {
  return ` ${s}`.substr(1);
}


/**
 * @typedef {Object} PluralizeOptions
 * @property {?boolean} inclusive [false] - Whether to prefix with the number (e.g., 3 ducks)
 */

/**
 * Get the form of a word appropriate to the count
 *
 * @param {string} word - The word to pluralize
 * @param {number} count - How many of the word exist
 * @param {?PluralizeOptions|boolean} options|inclusive - options for word pluralization,
 *   or a boolean indicating the options.inclusive property
 * @returns {string} The word pluralized according to the number
 */
function pluralize (word, count, options = {}) {
  let inclusive = false;
  if (_.isBoolean(options)) {
    // if passed in as a boolean
    inclusive = options;
  } else if (_.isBoolean(options?.inclusive)) {
    // if passed in as an options hash
    inclusive = options.inclusive;
  }
  return pluralizeLib(word, count, inclusive);
}

/**
 * @typedef {Object} EncodingOptions
 * @property {number} maxSize [1073741824] The maximum size of
 * the resulting buffer in bytes. This is set to 1GB by default, because
 * Appium limits the maximum HTTP body size to 1GB. Also, the NodeJS heap
 * size must be enough to keep the resulting object (usually this size is
 * limited to 1.4 GB)
 */

/**
 * Converts contents of a local file to an in-memory base-64 encoded buffer.
 * The operation is memory-usage friendly and should be used while encoding
 * large files to base64
 *
 * @param {string} srcPath The full path to the file being encoded
 * @param {EncodingOptions} opts
 * @returns {Buffer} base64-encoded content of the source file as memory buffer
 * @throws {Error} if there was an error while reading the source file
 * or the source file is too
 */
async function toInMemoryBase64 (srcPath, opts = {}) {
  if (!(await fs.exists(srcPath)) || (await fs.stat(srcPath)).isDirectory()) {
    throw new Error(`No such file: ${srcPath}`);
  }

  const {
    maxSize = 1 * GiB,
  } = opts;
  const resultBuffers = [];
  let resultBuffersSize = 0;
  const resultWriteStream = new stream.Writable({
    write: (buffer, encoding, next) => {
      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 resultWriteStreamPromise = new B((resolve, reject) => {
    resultWriteStream.once('error', (e) => {
      readerStream.unpipe(base64EncoderStream);
      base64EncoderStream.unpipe(resultWriteStream);
      readerStream.destroy();
      reject(e);
    });
    resultWriteStream.once('finish', resolve);
  });
  const readStreamPromise = new B((resolve, reject) => {
    readerStream.once('close', resolve);
    readerStream.once('error', (e) => reject(
      new Error(`Failed to read '${srcPath}': ${e.message}`)));
  });
  readerStream.pipe(base64EncoderStream);
  base64EncoderStream.pipe(resultWriteStream);

  await B.all([readStreamPromise, resultWriteStreamPromise]);
  return Buffer.concat(resultBuffers);
}

export {
  hasValue, escapeSpace, escapeSpecialChars, localIp, cancellableDelay,
  multiResolve, safeJsonParse, wrapElement, unwrapElement, filterObject,
  toReadableSizeString, isSubPath, W3C_WEB_ELEMENT_IDENTIFIER,
  isSameDestination, compareVersions, coerceVersion, quote, unleakString,
  jsonStringify, pluralize, GiB, MiB, KiB, toInMemoryBase64,
  uuidV1, uuidV3, uuidV4, uuidV5, shellParse,
};