@appium/support
Version:
Support libs used across appium packages
421 lines (389 loc) • 12.3 kB
JavaScript
// @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 mv from 'mv';
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
*/
mv: /** @type {(from: string, to: string, opts?: mv.Options) => B<void>} */ (B.promisify(mv)),
/**
* 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('mv')} mv
* @typedef {import('fs').PathLike} PathLike
*/
/**
* @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}>}
*/