UNPKG

@appium/support

Version:

Support libs used across Appium packages

497 lines (462 loc) 14.8 kB
// @ts-check import B from 'bluebird'; import crypto from 'crypto'; import { close, constants, createReadStream, createWriteStream, promises as fsPromises, read, write, rmSync, open, } from 'fs'; import { glob } from 'glob'; import klaw from 'klaw'; import _ from 'lodash'; import ncp from 'ncp'; import path from 'path'; import pkgDir from 'pkg-dir'; import readPkg from 'read-pkg'; import sanitize from 'sanitize-filename'; import which from 'which'; import log from './logger'; import {Timer} from './timing'; import {isWindows} from './system'; import {pluralize} from './util'; const ncpAsync = /** @type {(source: string, dest: string, opts: ncp.Options|undefined) => B<void>} */ ( B.promisify(ncp) ); const findRootCached = _.memoize(pkgDir.sync); const fs = { /** * Resolves `true` if `path` is _readable_, which differs from Node.js' default behavior of "can we see it?" * * On Windows, ACLs are not supported, so this becomes a simple check for existence. * * This function will never reject. * @param {PathLike} path * @returns {Promise<boolean>} */ async hasAccess(path) { try { await fsPromises.access(path, constants.R_OK); } catch { return false; } return true; }, /** * Resolves `true` if `path` is executable; `false` otherwise. * * On Windows, this function delegates to {@linkcode fs.hasAccess}. * * This function will never reject. * @param {PathLike} path * @returns {Promise<boolean>} */ async isExecutable(path) { try { if (isWindows()) { return await fs.hasAccess(path); } await fsPromises.access(path, constants.R_OK | constants.X_OK); } catch { return false; } return true; }, /** * Alias for {@linkcode fs.hasAccess} * @param {PathLike} path */ async exists(path) { return await fs.hasAccess(path); }, /** * Remove a directory and all its contents, recursively * @param {PathLike} filepath * @returns Promise<void> * @see https://nodejs.org/api/fs.html#fspromisesrmpath-options */ async rimraf(filepath) { return await fsPromises.rm(filepath, {recursive: true, force: true}); }, /** * Remove a directory and all its contents, recursively in sync * @param {PathLike} filepath * @returns undefined * @see https://nodejs.org/api/fs.html#fsrmsyncpath-options */ rimrafSync(filepath) { return rmSync(filepath, {recursive: true, force: true}); }, /** * Like Node.js' `fsPromises.mkdir()`, but will _not_ reject if the directory already exists. * * @param {string|Buffer|URL} filepath * @param {import('fs').MakeDirectoryOptions} [opts] * @returns {Promise<string|undefined>} * @see https://nodejs.org/api/fs.html#fspromisesmkdirpath-options */ async mkdir(filepath, opts = {}) { try { return await fsPromises.mkdir(filepath, opts); } catch (err) { if (err?.code !== 'EEXIST') { throw err; } } }, /** * Copies files _and entire directories_ * @param {string} source - Source to copy * @param {string} destination - Destination to copy to * @param {ncp.Options} [opts] - Additional arguments to pass to `ncp` * @see https://npm.im/ncp * @returns {Promise<void>} */ async copyFile(source, destination, opts = {}) { if (!(await fs.hasAccess(source))) { throw new Error(`The file at '${source}' does not exist or is not accessible`); } return await ncpAsync(source, destination, opts); }, /** * Create an MD5 hash of a file. * @param {PathLike} filePath * @returns {Promise<string>} */ async md5(filePath) { return await fs.hash(filePath, 'md5'); }, /** * Move a file or a folder * * @param {string} from Source file/folder * @param {string} to Destination file/folder * @param {mv} [opts] Move options * @returns {Promise<void>} */ async mv(from, to, opts) { const ensureDestination = async (/** @type {import('fs').PathLike} */ p) => { if (opts?.mkdirp && !(await this.exists(p))) { await fsPromises.mkdir(p, { recursive: true }); return true; } return false; }; const renameFile = async ( /** @type {import('fs').PathLike} */ src, /** @type {import('fs').PathLike} */ dst, /** @type {boolean} */ skipExistenceCheck ) => { if (!skipExistenceCheck && await this.exists(dst)) { if (opts?.clobber === false) { const err = new Error(`The destination path '${dst}' already exists`); // @ts-ignore Legacy compat err.code = 'EEXIST'; throw err; } await this.rimraf(dst); } try { await fsPromises.rename(src, dst); } catch (err) { // Handle cross-device link error by falling back to copy-and-delete if (err.code === 'EXDEV') { await this.copyFile(String(src), String(dst)); await this.rimraf(src); } else { throw err; } } }; /** @type {import('fs').Stats} */ let fromStat; try { fromStat = await fsPromises.stat(from); } catch (err) { if (err.code === 'ENOENT') { throw new Error(`The source path '${from}' does not exist or is not accessible`); } throw err; } if (fromStat.isFile()) { const dstRootWasCreated = await ensureDestination(path.dirname(to)); await renameFile(from, to, dstRootWasCreated); } else if (fromStat.isDirectory()) { const dstRootWasCreated = await ensureDestination(to); const items = await fsPromises.readdir(from, { withFileTypes: true }); for (const item of items) { const srcPath = path.join(from, item.name); const destPath = path.join(to, item.name); if (item.isDirectory()) { await this.mv(srcPath, destPath, opts); } else if (item.isFile()) { await renameFile(srcPath, destPath, dstRootWasCreated); } } } else { return; } await this.rimraf(from); }, /** * Find path to an executable in system `PATH` * @see https://github.com/npm/node-which */ which, /** * Given a glob pattern, resolve with list of files matching that pattern * @see https://github.com/isaacs/node-glob */ glob: /** @type {(pattern: string, opts?: import('glob').GlobOptions) => B<string[]>} */ ( (pattern, options) => B.resolve(options ? glob(pattern, options) : glob(pattern)) ), /** * Sanitize a filename * @see https://github.com/parshap/node-sanitize-filename */ sanitizeName: sanitize, /** * Create a hex digest of some file at `filePath` * @param {PathLike} filePath * @param {string} [algorithm] * @returns {Promise<string>} */ async hash(filePath, algorithm = 'sha1') { return await new B((resolve, reject) => { const fileHash = crypto.createHash(algorithm); const readStream = createReadStream(filePath); readStream.on('error', (e) => reject( new Error( `Cannot calculate ${algorithm} hash for '${filePath}'. Original error: ${e.message}` ) ) ); readStream.on('data', (chunk) => fileHash.update(chunk)); readStream.on('end', () => resolve(fileHash.digest('hex'))); }); }, /** * Returns an `Walker` instance, which is a readable stream (and thusly an async iterator). * * @param {string} dir - Dir to start walking at * @param {import('klaw').Options} [opts] * @returns {import('klaw').Walker} * @see https://www.npmjs.com/package/klaw */ walk(dir, opts) { return klaw(dir, opts); }, /** * Recursively create a directory. * @param {PathLike} dir * @returns {Promise<string|undefined>} */ async mkdirp(dir) { return await fs.mkdir(dir, {recursive: true}); }, /** * Walks a directory given according to the parameters given. The callback will be invoked with a path joined with the dir parameter * @param {string} dir Directory path where we will start walking * @param {boolean} recursive Set it to true if you want to continue walking sub directories * @param {WalkDirCallback} callback The callback to be called when a new path is found * @throws {Error} If the `dir` parameter contains a path to an invalid folder * @returns {Promise<string?>} returns the found path or null if the item was not found */ // eslint-disable-next-line promise/prefer-await-to-callbacks async walkDir(dir, recursive, callback) { let isValidRoot = false; let errMsg = null; try { isValidRoot = (await fs.stat(dir)).isDirectory(); } catch (e) { errMsg = e.message; } if (!isValidRoot) { throw Error( `'${dir}' is not a valid root directory` + (errMsg ? `. Original error: ${errMsg}` : '') ); } let walker; let fileCount = 0; let directoryCount = 0; const timer = new Timer().start(); return await new B(function (resolve, reject) { /** @type {Promise} */ let lastFileProcessed = B.resolve(); walker = klaw(dir, { depthLimit: recursive ? -1 : 0, }); walker .on('data', function (item) { walker.pause(); if (!item.stats.isDirectory()) { fileCount++; } else { directoryCount++; } lastFileProcessed = (async () => { try { // eslint-disable-next-line promise/prefer-await-to-callbacks const done = await callback(item.path, item.stats.isDirectory()); if (done) { return resolve(item.path); } walker.resume(); } catch (err) { return reject(err); } })(); }) .on('error', function (err, item) { log.warn(`Got an error while walking '${item.path}': ${err.message}`); // klaw cannot get back from an ENOENT error if (err.code === 'ENOENT') { log.warn('All files may not have been accessed'); reject(err); } }) .on('end', function () { (async () => { try { const file = await lastFileProcessed; return resolve(/** @type {string|undefined} */ (file) ?? null); } catch (err) { log.warn(`Unexpected error: ${err.message}`); return reject(err); } })(); }); }).finally(function () { log.debug( `Traversed ${pluralize('directory', directoryCount, true)} ` + `and ${pluralize('file', fileCount, true)} ` + `in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` ); if (walker) { walker.destroy(); } }); }, /** * Reads the closest `package.json` file from absolute path `dir`. * @param {string} dir - Directory to search from * @param {import('read-pkg').Options} [opts] - Additional options for `read-pkg` * @throws {Error} If there were problems finding or reading a `package.json` file * @returns {import('read-pkg').NormalizedPackageJson} A parsed `package.json` */ readPackageJsonFrom(dir, opts = {}) { const cwd = fs.findRoot(dir); try { return readPkg.sync( /** @type {import('read-pkg').NormalizeOptions} */ ({normalize: true, ...opts, cwd}) ); } catch (err) { err.message = `Failed to read a \`package.json\` from dir \`${dir}\`:\n\n${err.message}`; throw err; } }, /** * Finds the project root directory from `dir`. * @param {string} dir - Directory to search from * @throws {TypeError} If `dir` is not a nonempty string or relative path * @throws {Error} If there were problems finding the project root * @returns {string} The closeset parent dir containing `package.json` */ findRoot(dir) { if (!dir || !path.isAbsolute(dir)) { throw new TypeError('`findRoot()` must be provided a non-empty, absolute path'); } const result = findRootCached(dir); if (!result) { throw new Error(`\`findRoot()\` could not find \`package.json\` from ${dir}`); } return result; }, // add the supported `fs` functions access: fsPromises.access, appendFile: fsPromises.appendFile, chmod: fsPromises.chmod, close: B.promisify(close), constants, createWriteStream, createReadStream, lstat: fsPromises.lstat, /** * Warning: this is a promisified {@linkcode open fs.open}. * It resolves w/a file descriptor instead of a {@linkcode fsPromises.FileHandle FileHandle} object, as {@linkcode fsPromises.open} does. Use {@linkcode fs.openFile} if you want a `FileHandle`. * @type {(path: PathLike, flags: import('fs').OpenMode, mode?: import('fs').Mode) => Promise<number>} */ open: B.promisify(open), openFile: fsPromises.open, readdir: fsPromises.readdir, read: /** * @type {ReadFn<NodeJS.ArrayBufferView>} */ (/** @type {unknown} */ (B.promisify(read))), readFile: fsPromises.readFile, readlink: fsPromises.readlink, realpath: fsPromises.realpath, rename: fsPromises.rename, stat: fsPromises.stat, symlink: fsPromises.symlink, unlink: fsPromises.unlink, write: B.promisify(write), writeFile: fsPromises.writeFile, // deprecated props /** * Use `constants.F_OK` instead. * @deprecated */ F_OK: constants.F_OK, /** * Use `constants.R_OK` instead. * @deprecated */ R_OK: constants.R_OK, /** * Use `constants.W_OK` instead. * @deprecated */ W_OK: constants.W_OK, /** * Use `constants.X_OK` instead. * @deprecated */ X_OK: constants.X_OK, }; export {fs}; export default fs; /** * The callback function which will be called during the directory walking * @callback WalkDirCallback * @param {string} itemPath The path of the file or folder * @param {boolean} isDirectory Shows if it is a directory or a file * @return {boolean|void} return true if you want to stop walking */ /** * @typedef {import('glob')} glob * @typedef {import('fs').PathLike} PathLike */ /** * @typedef {Object} mv * @property {boolean} [mkdirp=false] Whether to automatically create the destination folder structure * @property {boolean} [clobber=true] Set it to false if you want an exception to be thrown * if the destination file already exists * @property {number} [limit=16] Legacy deprecated property, not used anymore */ /** * @template {NodeJS.ArrayBufferView} TBuffer * @callback ReadFn * @param {number} fd * @param {TBuffer|import('node:fs').ReadAsyncOptions<TBuffer>} buffer * @param {number} [offset] * @param {number} [length] * @param {number?} [position] * @returns {B<{bytesRead: number, buffer: TBuffer}>} */