diff --git a/common/Resources/ti.internal/extensions/node/fs.js b/common/Resources/ti.internal/extensions/node/fs.js new file mode 100644 index 00000000000..da14332985f --- /dev/null +++ b/common/Resources/ti.internal/extensions/node/fs.js @@ -0,0 +1,1704 @@ +import assertArgumentType from './_errors'; +import path from './path'; + +const isWindows = Ti.Platform.name === 'windows'; +const isAndroid = Ti.Platform.name === 'android'; + +// Keep track of printing out one-time warning messages for unsupported operations/options/arguments +const printedWarnings = {}; +function oneTimeWarning(key, msg) { + if (!printedWarnings[key]) { + console.warn(msg); + printedWarnings[key] = true; + } +} +/** + * Prints a one-time warning message that we do not support the given API and performs an effective no-op + * @param {string} moduleName name of the module/object + * @param {string} name name of the function.property we don't support + * @returns {Function} no-op function + */ +function unsupportedNoop(moduleName, name) { + return () => { + const fqn = `${moduleName}.${name}`; + oneTimeWarning(fqn, `"${fqn}" is not supported yet on Titanium and uses a no-op fallback.`); + return undefined; + }; +} + +/** + * @param {string} moduleName name of the module/object + * @param {string} name name of the function.property we don't support + * @param {Function} callback async callback we call in a quick setTimeout + */ +function asyncUnsupportedNoop(moduleName, name, callback) { + callback = maybeCallback(callback); // enforce we have a valid callback + unsupportedNoop(moduleName, name)(); + setTimeout(callback, 1); +} + +// Used to choose the buffer/chunk size when pumping bytes during copies +const COPY_FILE_CHUNK_SIZE = 8092; // what should we use here? + +// Keep track of integer -> FileStream mappings +const fileDescriptors = new Map(); +let fileDescriptorCount = 4; // global counter used to report file descriptor integers + +// Map file system access flags to Ti.Filesystem.MODE_* constants +const FLAGS_TO_TI_MODE = new Map(); +FLAGS_TO_TI_MODE.set('a', Ti.Filesystem.MODE_APPEND); +FLAGS_TO_TI_MODE.set('ax', Ti.Filesystem.MODE_APPEND); +FLAGS_TO_TI_MODE.set('a+', Ti.Filesystem.MODE_APPEND); +FLAGS_TO_TI_MODE.set('ax+', Ti.Filesystem.MODE_APPEND); +FLAGS_TO_TI_MODE.set('as+', Ti.Filesystem.MODE_APPEND); +FLAGS_TO_TI_MODE.set('r', Ti.Filesystem.MODE_READ); +FLAGS_TO_TI_MODE.set('r+', Ti.Filesystem.MODE_READ); +FLAGS_TO_TI_MODE.set('rs+', Ti.Filesystem.MODE_READ); +FLAGS_TO_TI_MODE.set('w', Ti.Filesystem.MODE_WRITE); +FLAGS_TO_TI_MODE.set('wx', Ti.Filesystem.MODE_WRITE); +FLAGS_TO_TI_MODE.set('w+', Ti.Filesystem.MODE_WRITE); +FLAGS_TO_TI_MODE.set('wx+', Ti.Filesystem.MODE_WRITE); + +// Common errors +const permissionDenied = (syscall, path) => makeError('EACCES', 'permission denied', -13, syscall, path); +const noSuchFile = (syscall, path) => makeError('ENOENT', 'no such file or directory', -2, syscall, path); +const fileAlreadyExists = (syscall, path) => makeError('EEXIST', 'file already exists', -17, syscall, path); +const notADirectory = (syscall, path) => makeError('ENOTDIR', 'not a directory', -20, syscall, path); +const directoryNotEmpty = (syscall, path) => makeError('ENOTEMPTY', 'directory not empty', -66, syscall, path); +const illegalOperationOnADirectory = (syscall, path) => makeError('EISDIR', 'illegal operation on a directory', -21, syscall, path); + +const fs = { + constants: { + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + S_IFMT: 61440, + S_IFREG: 32768, + S_IFDIR: 16384, + S_IFCHR: 8192, + S_IFBLK: 24576, + S_IFIFO: 4096, + S_IFLNK: 40960, + S_IFSOCK: 49152, + O_CREAT: 512, + O_EXCL: 2048, + O_NOCTTY: 131072, + O_TRUNC: 1024, + O_APPEND: 8, + O_DIRECTORY: 1048576, + O_NOFOLLOW: 256, + O_SYNC: 128, + O_DSYNC: 4194304, + O_SYMLINK: 2097152, + O_NONBLOCK: 4, + S_IRWXU: 448, + S_IRUSR: 256, + S_IWUSR: 128, + S_IXUSR: 64, + S_IRWXG: 56, + S_IRGRP: 32, + S_IWGRP: 16, + S_IXGRP: 8, + S_IRWXO: 7, + S_IROTH: 4, + S_IWOTH: 2, + S_IXOTH: 1, + F_OK: 0, + R_OK: 4, + W_OK: 2, + X_OK: 1, + UV_FS_COPYFILE_EXCL: 1, + COPYFILE_EXCL: 1 + } +}; + +class Stats { + constructor (path) { + this._file = null; + this.dev = 0; + this.ino = 0; + this.mode = 0; + this.nlink = 0; + this.uid = 0; + this.gid = 0; + this.rdev = 0; + this.size = 0; + this.blksize = 4096; // FIXME: https://stackoverflow.com/questions/1315311/what-is-the-block-size-of-the-iphone-filesystem + this.blocks = 0; + this.atimeMs = this.mtimeMs = this.ctimeMs = this.birthtimeMs = 0; + this.atime = this.mtime = this.ctime = this.birthtime = new Date(0); + + if (path) { + this._file = getTiFileFromPathLikeValue(path); + + // TODO: use lazy getters here? + this.ctime = this.birthtime = this._file.createdAt(); + this.atime = this.mtime = this._file.modifiedAt(); + this.atimeMs = this.atime.getTime(); + this.birthtimeMs = this.birthtime.getTime(); + this.ctimeMs = this.ctime.getTime(); + this.mtimeMs = this.mtime.getTime(); + this.size = this._file.size; + this.blocks = Math.ceil(this.size / this.blksize); + // TODO: Can we fake out the mode based on the readonly/writable/executable properties? + } + } + + isFile() { + return this._file.isFile(); + } + + isDirectory() { + return this._file.isDirectory(); + } + + isBlockDevice() { + return false; + } + + isCharacterDevice() { + return false; + } + + isSymbolicLink() { + return this._file.symbolicLink; + } + + isFIFO() { + return false; + } + + isSocket() { + return false; + } +} +fs.Stats = Stats; + +class ReadStream { + +} +fs.ReadStream = ReadStream; + +class WriteStream { + +} +fs.WriteStream = WriteStream; + +/** + * @callback statsCallback + * @param {Error} err - Error if one occurred + * @param {fs.Stats} stats - file stats + */ + +/** + * @param {string|URL|Buffer} path file path + * @param {integer} [mode=fs.constants.F_OK] accessibility mode/check + * @param {function} callback async callback + */ +fs.access = function (path, mode, callback) { + if (typeof mode === 'function') { + callback = mode; + mode = fs.constants.F_OK; + } + callback = maybeCallback(callback); + + setTimeout(() => { + try { + fs.accessSync(path, mode); + } catch (e) { + callback(e); + return; + } + callback(); + }, 1); +}; + +/** + * @param {string|URL|Buffer} path file path + * @param {integer} [mode=fs.constants.F_OK] accessibility mode/check + */ +fs.accessSync = function (path, mode = fs.constants.F_OK) { + // F_OK is just whether file exists or not, no permissions check + // R_OK is read check + // W_OK is write check + // X_OK is execute check (acts like F_OK on Windows) + const fileHandle = getTiFileFromPathLikeValue(path); + if (!fileHandle.exists()) { + throw noSuchFile('access', path); + } + + // TODO: We have no means of testing if a file is readable. It's assumed all files that exist under the app are? + if ((mode & fs.constants.W_OK) && !fileHandle.writable) { + throw permissionDenied('access', path); + } + if (!isWindows && (mode & fs.constants.X_OK) && !fileHandle.executable && fileHandle.isFile()) { + throw permissionDenied('access', path); + } +}; + +/** + * Asynchronously append data to a file, creating the file if it does not yet exist. data can be a string or a Buffer. + * @param {string|Buffer|URL|FileStream} file filepath to file + * @param {string|Buffer} data data to append to file + * @param {object|string} [options] options + * @param {string} [options.encoding='utf8'] encoding to use + * @param {integer} [options.mode=0o666] mode to create file, if not created + * @param {string} [options.flag='a'] file system flag + * @param {Function} callback function to call back with error if failed + */ +fs.appendFile = (file, data, options, callback) => { + callback = maybeCallback(callback || options); + options = mergeDefaultOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'a' }); + fs.writeFile(file, data, options, callback); +}; + +/** + * Synchronously append data to a file, creating the file if it does not yet exist. data can be a string or a Buffer. + * @param {string|Buffer|URL|FileStream} file filepath to file + * @param {string|Buffer} data data to append to file + * @param {object|string} [options] options + * @param {string} [options.encoding='utf8'] encoding to use + * @param {integer} [options.mode=0o666] mode to create file, if not created + * @param {string} [options.flag='a'] file system flag + */ +fs.appendFileSync = (file, data, options) => { + options = mergeDefaultOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'a' }); + fs.writeFileSync(file, data, options); + // TODO: Use Ti.Filesystem.File.append() instead? +}; + +fs.chmod = (path, mode, callback) => asyncUnsupportedNoop('fs', 'chmod', callback); +fs.chmodSync = unsupportedNoop('fs', 'chmodSync'); + +/** + * Callback for functions that can only throw errors + * + * @callback errorCallback + * @param {Error} [err] - Error thrown + */ + +/** + * @param {integer} fd file descriptor + * @param {errorCallback} callback callback function + */ +fs.close = (fd, callback) => { + callback = maybeCallback(callback); + setTimeout(() => { + try { + fs.closeSync(fd); + } catch (e) { + callback(e); + return; + } + callback(); + }, 1); +}; + +/** + * @param {integer} fd file descriptor + */ +fs.closeSync = (fd) => { + const stream = streamForDescriptor(fd); + stream.close(); +}; + +// Rather than use a hack to wrap sync version in setTimeout, use actual async APIs! +/** + * @param {string|Buffer|URL} src source filename to copy + * @param {string|Buffer|URL} dest destination filename of the copy operation + * @param {number} [flags=0] modifiers for copy operation + * @param {errorCallback} callback callback called at end of operation + */ +fs.copyFile = function (src, dest, flags, callback) { + if (typeof flags === 'function') { + callback = flags; + flags = 0; + } + callback = maybeCallback(callback); + + // FIXME: I don't know why, but changing this to use Ti.Filesystem.openStream(mode, path) fails (at least on iOS) + const srcFile = Ti.Filesystem.getFile(src); + const srcStream = srcFile.open(Ti.Filesystem.MODE_READ); + const destFile = Ti.Filesystem.getFile(dest); + const destStream = destFile.open(Ti.Filesystem.MODE_WRITE); + + pipe(srcStream, destStream, callback); +}; + +/** + * @param {string|Buffer|URL} src source filename to copy + * @param {string|Buffer|URL} dest destination filename of the copy operation + * @param {number} [flags=0] modifiers for copy operation + */ +fs.copyFileSync = function (src, dest, flags = 0) { + const srcFile = Ti.Filesystem.getFile(src); + if (flags === fs.constants.COPYFILE_EXCL && fs.existsSync(dest)) { + throw fileAlreadyExists('copyFile', dest); + } + if (!srcFile.copy(dest)) { + throw new Error(`Unable to copy ${src} to ${dest}`); // FIXME: What error should we give? + } +}; + +// TODO: fs.createReadStream(path, options) +// /** +// * @param {string|Buffer|URL} path path like +// * @param {string|object} [options] options, if a string, it's the encoding +// * @param {string} [options.flags='r'] See support of file system flags. +// * @param {string} [options.encoding=null] encoding +// * @param {integer} [options.fd=null] file descriptor, if specified, `path` is ignored +// * @param {integer} [options.mode=0o666] permissions to set if file is created +// * @param {boolean} [options.autoClose=true] if false, file descriptor will not be closed; if true even on error it will be closed +// * @param {integer} [options.start] start index of range of bytes to read from file +// * @param {integer} [options.end=Infinity] end index of range of bytes to read from file +// * @param {integer} [options.highWaterMark=64 * 1024] +// * @returns {fs.ReadStream} +// */ +// fs.createReadStream = (path, options) => { +// options = mergeDefaultOptions(options, { flags: 'r', encoding: null, fd: null, mode: 0o666, autoClose: true, end: Infinity, highWaterMark: 64 * 1024 }); + +// // FIXME: If options.fd, use that in place of path! +// const tiFile = getTiFileFromPathLikeValue(path); +// }; +// TODO: fs.createWriteStream(path, options) + +/** + * @callback existsCallback + * @param {boolean} exists - whether path exists + */ + +/** + * @param {string} path path to check + * @param {existsCallback} callback callback function + * @returns {void} + */ +fs.exists = function (path, callback) { + callback = maybeCallback(callback); + setTimeout(() => { + callback(fs.existsSync(path)); + }, 1); +}; + +/** + * @param {string} path path to check + * @returns {boolean} whether a file or directory exists at that path + */ +fs.existsSync = function (path) { + try { + fs.accessSync(path); + return true; + } catch (e) { + return false; + } +}; + +fs.fchmod = (fd, mode, callback) => asyncUnsupportedNoop('fs', 'fchmod', callback); +fs.fchmodSync = unsupportedNoop('fs', 'fchmodSync'); + +fs.fchown = (fd, uid, gid, callback) => asyncUnsupportedNoop('fs', 'fchown', callback); +fs.fchownSync = unsupportedNoop('fs', 'fchownSync'); + +fs.fdatasync = (fd, callback) => asyncUnsupportedNoop('fs', 'fdatasync', callback); +fs.fdatasyncSync = unsupportedNoop('fs', 'fdatasyncSync'); + +/** + * @param {integer} fd file descriptor + * @param {object} [options] options + * @param {boolean} [options.bigint] whether stat values should be bigint + * @param {function} callback async callback function + */ +fs.fstat = (fd, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + callback = maybeCallback(callback); + + setTimeout(() => { + let stats; + try { + stats = fs.fstatSync(fd, options); + } catch (e) { + callback(e); + return; + } + callback(null, stats); + }, 1); +}; +/** + * @param {integer} fd file descriptor + * @param {object} [_options] options + * @param {boolean} [_options.bigint] whether stat values should be bigint + * @returns {fs.Stats} stats for file descriptor + */ +fs.fstatSync = (fd, _options) => { + const path = pathForFileDescriptor(fd); + return fs.statSync(path); +}; + +// TODO: Add versions of these APIs: +// fs.fsync(fd, callback) +// fs.fsyncSync(fd) +// fs.ftruncate(fd[, len], callback) +// fs.ftruncateSync(fd[, len]) +// fs.futimes(fd, atime, mtime, callback) +// fs.futimesSync(fd, atime, mtime) +// fs.lchmod(path, mode, callback) +// fs.lchmodSync(path, mode) +// fs.lchown(path, uid, gid, callback) +// fs.lchownSync(path, uid, gid) +// fs.link(existingPath, newPath, callback) +// fs.linkSync(existingPath, newPath) + +// FIXME: If symbolic link we need to follow link to target to get stats! Our API doesn't support that! +fs.lstat = (path, options, callback) => fs.stat(path, options, callback); +fs.lstatSync = (path, options) => fs.statSync(path, options); + +/** + * @param {string|Buffer|URL} path file path + * @param {string|object} [options] options + * @param {boolean} [options.recursive=false] recursivley create dirs? + * @param {integer} [options.mode=0o777] permissions + * @param {errorCallback} callback async callback + */ +fs.mkdir = (path, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = { recursive: false, mode: 0o777 }; + } + callback = maybeCallback(callback); + + setTimeout(() => { + try { + fs.mkdirSync(path, options); + } catch (e) { + callback(e); + return; + } + callback(null); + }, 1); +}; + +/** + * @param {string|Buffer|URL} path file path + * @param {string|object} [options] options + * @param {boolean} [options.recursive=false] recursivley create dirs? + * @param {integer} [options.mode=0o777] permissions + */ +fs.mkdirSync = (path, options) => { + const tiFile = getTiFileFromPathLikeValue(path); + if (typeof options === 'number') { + options = { recursive: false, mode: options }; + } else { + options = mergeDefaultOptions(options, { recursive: false, mode: 0o777 }); + } + if (!tiFile.createDirectory(options.recursive) && !options.recursive) { + if (tiFile.exists()) { + // already existed! + throw fileAlreadyExists('mkdir', path); + } + // We failed, probably because we didn't ask for recursive and parent doesn't exist, so reproduce node's error + throw noSuchFile('mkdir', path); + } +}; + +/** + * @callback tempDirCallback + * @param {Error} err - Error if one occurred + * @param {string} folder - generated folder name + */ + +/** + * @param {string} prefix directory name prefix + * @param {string|object} [options] options + * @param {string} [options.encoding='utf-8'] prefix encoding + * @param {tempDirCallback} callback async callback + */ +fs.mkdtemp = (prefix, options, callback) => { + assertArgumentType(prefix, 'prefix', 'string'); + if (typeof options === 'function') { + callback = options; + options = {}; + } + callback = maybeCallback(callback); + options = mergeDefaultOptions(options, { encoding: 'utf-8' }); + + // try to be all async + const tryMkdtemp = () => { + const generated = randomCharacters(6, options.encoding); // generate six random characters + const path = `${prefix}${generated}`; + fs.mkdir(path, 0o700, err => { + if (err) { + if (err.code === 'EEXIST') { + // retry! + setTimeout(tryMkdtemp, 1); + return; + } + // bubble up error + callback(err); + return; + } + // succeeded! Hurray! + callback(null, path); + }); + }; + setTimeout(tryMkdtemp, 1); +}; + +/** + * Creates a unique temporary directory. + * @param {string} prefix directory name prefix + * @param {string|object} [options] options + * @param {string} [options.encoding='utf-8'] prefix encoding + * @returns {string} path to created directory + */ +fs.mkdtempSync = (prefix, options) => { + assertArgumentType(prefix, 'prefix', 'string'); + options = mergeDefaultOptions(options, { encoding: 'utf-8' }); + + let retryCount = 0; + const MAX_RETRIES = 100; + while (retryCount < MAX_RETRIES) { + const generated = randomCharacters(6, options.encoding); // generate six random characters + const path = `${prefix}${generated}`; + try { + fs.mkdirSync(path, 0o700); // don't try recursive + return path; + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; // bubble up error + } + // name was not unique, so retry + retryCount++; + } + } + throw new Error(`Failed to create a unique directory name with prefix ${prefix}`); +}; + +/** + * @callback fileDescriptorCallback + * @param {Error} err - Error if one occurred + * @param {integer} fileDescriptor - generated file descriptor + */ + +/** + * @param {string|Buffer|URL} path path to file + * @param {string} [flags='r'] file system access flags + * @param {integer} [mode=0o666] file mode to use when creating file + * @param {fileDescriptorCallback} callback async callback + */ +fs.open = (path, flags, mode, callback) => { + // flags and mode are optional, we need to handle if not supplied! + if (typeof flags === 'function') { + callback = flags; + flags = 'r'; + mode = 0o666; + } else if (typeof mode === 'function') { + callback = mode; + mode = 0o666; + } + callback = maybeCallback(callback); + + setTimeout(() => { + let fileDescriptor; + try { + fileDescriptor = fs.openSync(path, flags, mode); + } catch (e) { + callback(e); + return; + } + callback(null, fileDescriptor); + }, 1); +}; + +/** + * @param {string|Buffer|URL} path path to file + * @param {string} [flags='r'] file system access flags + * @param {integer} [_mode=0o666] file mode to use when creating file + * @returns {integer} + */ +fs.openSync = (path, flags = 'r', _mode = 0o666) => { + const tiFile = getTiFileFromPathLikeValue(path); + if (!tiFile.exists()) { + // TODO: Support creating file with specific mode + oneTimeWarning('fs.openSync.mode', 'fs.openSync\'s mode parameter is unsupported in Titanium and will be ignored'); + + if (!tiFile.createFile()) { + // Oh crap, we failed to create the file. why? + if (!tiFile.parent.exists()) { + // parent does not exist! + throw noSuchFile('open', path); + } + + throw new Error(`failed to create file at path ${path}`); + } + } else if (flags) { + // file/dir exists... + if ((flags.charAt(0) === 'w' || flags.charAt(0) === 'a') && tiFile.isDirectory()) { + // If user is trying to write or append and it's a directory, fail + throw illegalOperationOnADirectory('open', path); + } + if (flags.length > 1 && flags.charAt(1) === 'x') { + // If user has "exclusive" flag on, fail if file already exists + throw fileAlreadyExists('open', path); + } + } + const tiMode = FLAGS_TO_TI_MODE.get(flags); + if (tiMode === undefined) { + // TODO: Make use of common error type/code for this once we have internal/errors.js + const err = new TypeError(`The value "${String(flags)}" is invalid for option "flags"`); + err.code = 'ERR_INVALID_OPT_VALUE'; + throw err; + } + return createFileDescriptor(path, tiFile.open(tiMode)); +}; + +/** + * @callback readCallback + * @param {Error} err - Error if one occurred + * @param {integer} bytesRead - number of bytes read + * @param {Buffer} buffer buffer + */ + +/** + * @param {integer} fd file descriptor + * @param {Buffer|Ti.Buffer} buffer buffer to read into + * @param {integer} offset the offset in the buffer to start writing at. + * @param {integer} length integer specifying the number of bytes to read. + * @param {integer} position where to begin reading from in the file + * @param {readCallback} callback async callback + */ +fs.read = (fd, buffer, offset, length, position, callback) => { + callback = maybeCallback(callback); + + const tiFileStream = streamForDescriptor(fd); + if (!Buffer.isBuffer(buffer)) { + buffer = Buffer.from(buffer); + } + // FIXME: Allow using position argument! + if (position !== null) { + oneTimeWarning('fs.readSync.position', 'fs.readSync\'s position argument is unsupported by Titanium and will be treated as null'); + } + tiFileStream.read(buffer.toTiBuffer(), offset, length, readObj => { + if (!readObj.success) { + callback(new Error(readObj.error)); + return; + } + callback(null, readObj.bytesProcessed, buffer); + }); +}; + +/** + * @param {integer} fd file descriptor + * @param {Buffer|Ti.Buffer} buffer buffer to read into + * @param {integer} offset the offset in the buffer to start writing at. + * @param {integer} length integer specifying the number of bytes to read. + * @param {integer} _position where to begin reading from in the file + * @returns {integer} bytes read + */ +fs.readSync = (fd, buffer, offset, length, _position) => { + const fileStream = streamForDescriptor(fd); + if (!Buffer.isBuffer(buffer)) { + buffer = Buffer.from(buffer); + } + + // FIXME: Allow using position argument! + if (_position !== null) { + oneTimeWarning('fs.readSync.position', 'fs.readSync\'s position argument is unsupported by Titanium and will be treated as null'); + } + return fileStream.read(buffer.toTiBuffer(), offset, length); +}; + +/** + * @callback filesCallback + * @param {Error} err - Error if one occurred + * @param {string[]|Buffer[]|fs.Dirent[]} files - file listing + */ + +/** + * @param {string} path directory to list + * @param {string|object} [options] optional options + * @param {string} [options.encoding='utf8'] encoding to use for filenames, if `'buffer'`, returns `Buffer` objects + * @param {boolean} [options.withFileTypes=false] if true, returns `fs.Dirent` objects + * @param {filesCallback} callback async callback + */ +fs.readdir = (path, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + callback = maybeCallback(callback); + + setTimeout(() => { + let result; + try { + result = fs.readdirSync(path, options); + } catch (e) { + callback(e); + return; + } + callback(null, result); + }, 1); +}; + +/** + * @param {string} filepath directory to list + * @param {string|object} [options] optional options + * @param {string} [options.encoding='utf8'] encoding to use for filenames, if `'buffer'`, returns `Buffer` objects + * @param {boolean} [options.withFileTypes=false] if true, returns `fs.Dirent` objects + * @returns {string[]|Buffer[]|fs.Dirent[]} + */ +fs.readdirSync = (filepath, options) => { + const file = getTiFileFromPathLikeValue(filepath); + if (!file.exists()) { + throw noSuchFile('scandir', filepath); + } + if (!file.isDirectory()) { + throw notADirectory('scandir', filepath); + } + options = mergeDefaultOptions(options, { encoding: 'utf-8', withFileTypes: false }); + const listing = file.getDirectoryListing(); + if (options.withFileTypes === true) { + // TODO: if options.withFileTypes === true, return fs.Dirent objects + oneTimeWarning('fs.readdir\'s options.withFileTypes is unsupported by Titanium and strings will be returned'); + } else if (options.encoding === 'buffer') { + return listing.map(name => Buffer.from(name)); + } + + return listing; +}; + +/** + * @callback readFilePostOpenCallback + * @param {Error} err - Error if one occurred + * @param {Ti.Buffer} buffer + */ +/** + * @param {integer} fileDescriptor file descriptor + * @param {readFilePostOpenCallback} callback async callback + */ +function readFilePostOpen(fileDescriptor, callback) { + callback = maybeCallback(callback); + fs.fstat(fileDescriptor, (err, stats) => { + if (err) { + callback(err); + return; + } + + const fileSize = stats.size; + + // Create a Ti.Buffer to read into + const buffer = Ti.createBuffer({ length: fileSize }); + + // Use Ti.Stream.readAll(sourceStream, buffer, callback) which spins off a separate thread to read in while loop! + const sourceStream = streamForDescriptor(fileDescriptor); + Ti.Stream.readAll(sourceStream, buffer, readAllObj => { + if (!readAllObj.success) { + callback(new Error(readAllObj.error)); + return; + } + callback(null, buffer); + }); + }); +} + +/** + * @callback readFileCallback + * @param {Error} err - Error if one occurred + * @param {string|Buffer} data + */ +/** + * Asynchronously read entire contents of file + * @param {string|Buffer|URL|integer} path filename or file descriptor + * @param {object|string} [options] options + * @param {string} [options.encoding=null] encoding to use + * @param {string} [options.flag='r'] file system flag + * @param {readFileCallback} callback async callback + */ +fs.readFile = (path, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = { encoding: null, flag: 'r' }; + } else { + options = mergeDefaultOptions(options, { encoding: null, flag: 'r' }); + } + callback = maybeCallback(callback); + + const wasFileDescriptor = (typeof path === 'number'); + + let fileDescriptor = path; // may be overriden later + /** + * @param {Error} err possible Error + * @param {Ti.Buffer} buffer Ti.Buffer instance + */ + const handleBuffer = (err, buffer) => { + if (err) { + callback(err); + return; + } + + // fs.closeSync if it was not originally a file descriptor + if (!wasFileDescriptor) { + fs.closeSync(fileDescriptor); + } + + // TODO: trim buffer if we didn't read full size? + + callback(null, encodeBuffer(options.encoding, buffer)); + }; + + if (!wasFileDescriptor) { + fs.open(path, options.flag, (err, fd) => { + if (err) { + callback(err); + return; + } + fileDescriptor = fd; + readFilePostOpen(fd, handleBuffer); + }); + } else { + readFilePostOpen(path, handleBuffer); + } +}; + +/** + * Returns the contents of the path. + * @param {string|Buffer|URL|integer} path path to file + * @param {object|string} [options] options + * @param {string} [options.encoding=null] encoding to use + * @param {string} [options.flag='r'] file system flag + * @returns {string|Buffer} string if encoding is specified, otherwise Buffer + */ +fs.readFileSync = (path, options) => { + options = mergeDefaultOptions(options, { encoding: null, flag: 'r' }); + + const wasFileDescriptor = (typeof path === 'number'); + const fileDescriptor = wasFileDescriptor ? path : fs.openSync(path, options.flag); // use default mode + + const tiFileStream = streamForDescriptor(fileDescriptor); + // Just use our own API that reads full stream in + const buffer = Ti.Stream.readAll(tiFileStream); + + // fs.closeSync if it was not originally a file descriptor + if (!wasFileDescriptor) { + fs.closeSync(fileDescriptor); + } + + // TODO: trim buffer if we didn't read full size? + + return encodeBuffer(options.encoding, buffer); +}; + +// TODO: fs.readlink(path[, options], callback) +// TODO: fs.readlinkSync(path[, options]) + +/** + * @callback realpathCallback + * @param {Error} err - Error if one occurred + * @param {string|Buffer} resolvedPath the resolved path + */ +/** + * @param {string|Buffer|URL} filepath original filepath + * @param {object} [options] optiosn object + * @param {string} [options.encoding='utf8'] encoding used for returned object. If 'buffer", we'll return a Buffer in palce of a string + * @param {realpathCallback} callback async callback + */ +fs.realpath = (filepath, options, callback) => { + callback = maybeCallback(callback || options); + options = mergeDefaultOptions(options, { encoding: 'utf8' }); + setTimeout(() => { + // FIXME: This assumes no symlinks, which we really don't have full support for in our SDK anyways. + const result = path.normalize(filepath); + fs.exists(result, resultExists => { + if (resultExists) { + if (options.encoding === 'buffer') { + return callback(null, Buffer.from(result)); + } + return callback(null, result); + } + + // this path doesn't exist, try each segment until we find first that doesn't + const segments = result.split(path.sep); // FIXME: Drop last segment as we already know the full path doesn't exist? + let partialFilePath = ''; + let index = 0; + // handle typical case of empty first segment so we don't need to do an async setTimeout to get to first real case + if (segments[index].length === 0) { + index++; + } + setTimeout(tryPath, 1); + + function tryPath() { + if (index >= segments.length) { + // don't run past end of segments, throw error for resolved path + return callback(noSuchFile(result)); + } + + // grab next segment + const segment = segments[index++]; + if (segment.length === 0) { // if it's an empty segment... + // try again at next index + return setTimeout(tryPath, 1); + } + + // normal case + partialFilePath += path.sep + segment; + // check if path up to this point exists... + fs.exists(partialFilePath, partialExists => { + if (!partialExists) { // nope, throw the Error + return callback(noSuchFile('lstat', partialFilePath)); + } + // try again at next depth of dir tree + setTimeout(tryPath, 1); + }); + } + }); + }, 1); +}; +fs.realpath.native = (path, options, callback) => { + fs.realpath(path, options, callback); +}; + +/** + * @param {string|Buffer|URL} filepath original filepath + * @param {object} [options] options object + * @param {string} [options.encoding='utf8'] encoding used for returned object. If 'buffer", we'll return a Buffer in palce of a string + * @returns {string|Buffer} + */ +fs.realpathSync = (filepath, options) => { + options = mergeDefaultOptions(options, { encoding: 'utf8' }); + // FIXME: This assumes no symlinks, which we really don't have full support for in our SDK anyways. + const result = path.normalize(filepath); + if (!fs.existsSync(result)) { + // this path doesn't exist, try each segment until we find first that doesn't + const segments = result.split(path.sep); + let partialFilePath = ''; + for (const segment of segments) { + if (segment.length === 0) { + continue; + } + partialFilePath += path.sep + segment; + if (!fs.existsSync(partialFilePath)) { + throw noSuchFile('lstat', partialFilePath); + } + } + } + if (options.encoding === 'buffer') { + return Buffer.from(result); + } + return result; +}; +fs.realpathSync.native = (path, options) => { + fs.realpathSync(path, options); +}; + +/** + * @param {string|Buffer|URL} oldPath source filepath + * @param {string|Buffer|URL} newPath destination filepath + * @param {errorCallback} callback async callback + */ +fs.rename = (oldPath, newPath, callback) => { + callback = maybeCallback(callback); + setTimeout(() => { + try { + fs.renameSync(oldPath, newPath); + } catch (e) { + callback(e); + return; + } + callback(); + }, 1); +}; + +/** + * @param {string|Buffer|URL} oldPath source filepath + * @param {string|Buffer|URL} newPath destination filepath + */ +fs.renameSync = (oldPath, newPath) => { + const tiFile = getTiFileFromPathLikeValue(oldPath); + // src doesn't actually exist? + if (!tiFile.exists()) { + const err = noSuchFile('rename', oldPath); + err.message = `${err.message} -> '${newPath}'`; + err.dest = newPath; + throw err; + } + + const destFile = getTiFileFromPathLikeValue(newPath); + if (destFile.isDirectory()) { + // dest is a directory that already exists + const err = illegalOperationOnADirectory('rename', oldPath); + err.message = `${err.message} -> '${newPath}'`; + err.dest = newPath; + throw err; + } + + let tempPath; + if (destFile.isFile()) { + // destination file exists, we should overwrite + // Our APIs will fail if we try, so first let's make a backup copy and delete the the original + tempPath = path.join(fs.mkdtempSync(path.join(Ti.Filesystem.tempDirectory, 'rename-')), path.basename(newPath)); + destFile.move(tempPath); + } + + let success = false; + try { + success = tiFile.move(newPath); + } finally { + if (tempPath) { + // we temporarily copied the existing destination to back it up... + if (success) { + // move worked, so we can wipe it away whenever... + fs.unlink(tempPath, _err => {}); + } else { + // move it back, because we failed! + const tmpFile = getTiFileFromPathLikeValue(tempPath); + tmpFile.move(newPath); + } + } + } +}; + +/** + * @param {string|Buffer|URL} path file path + * @param {errorCallback} callback async callback + */ +fs.rmdir = (path, callback) => { + callback = maybeCallback(callback); + setTimeout(() => { + try { + fs.rmdirSync(path); + } catch (e) { + callback(e); + return; + } + callback(); + }, 1); +}; +/** + * @param {string|Buffer|URL} path file path + */ +fs.rmdirSync = path => { + const tiFile = getTiFileFromPathLikeValue(path); + if (!tiFile.deleteDirectory(false)) { // do not delete contents! + // we failed to delete, but why? + // does it exist? + if (!tiFile.exists()) { + throw noSuchFile('rmdir', path); + } + // is it a file? + if (tiFile.isFile()) { + throw notADirectory('rmdir', path); + } + // is it not empty? + const subFiles = tiFile.getDirectoryListing(); + if (subFiles && subFiles.length > 0) { + throw directoryNotEmpty('rmdir', path); + } + } +}; + +/** + * @param {string|Buffer|URL} path file path + * @param {object} [options] options + * @param {boolean} [options.bigint] whether stat values should be bigint + * @param {statsCallback} callback async callback + */ +fs.stat = (path, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + callback = maybeCallback(callback); + + setTimeout(() => { + callback(null, new fs.Stats(path)); + }, 1); +}; +/** + * @param {string|Buffer|URL|integer} path filepath or file descriptor + * @param {object} [_options] options + * @param {boolean} [_options.bigint] whether stat values should be bigint + * @returns {fs.Stats} + */ +fs.statSync = (path, _options) => new fs.Stats(path); + +fs.symlink = (target, path, type, callback) => asyncUnsupportedNoop('fs', 'symlink', callback); +fs.symlinkSync = unsupportedNoop('fs', 'symlinkSync'); + +/** + * @param {string} path file path + * @param {integer} [len=0] bytes to trim to + * @param {errorCallback} callback async callback + */ +fs.truncate = (path, len, callback) => { + callback = maybeCallback(callback || len); + if (typeof len !== 'number') { + len = 0; + } + + if (len <= 0) { + fs.writeFile(path, '', callback); // empty the file + return; + } + + // we have to retain some of the file! + // yuck, so let's read what we need to retain, then overwrite file with it + fs.open(path, (err, fd) => { + if (err) { + return callback(err); + } + const buffer = Buffer.alloc(len); + fs.read(fd, buffer, 0, len, null, (err, bytesRead, buffer) => { + if (err) { + fs.closeSync(fd); + return callback(err); + } + fs.close(fd, err => { + if (err) { + return callback(err); + } + fs.writeFile(path, buffer, callback); + }); + }); + }); +}; + +/** + * @param {string} path file path + * @param {integer} [len=0] bytes to trim to + */ +fs.truncateSync = (path, len = 0) => { + if (len <= 0) { + // empty the file + fs.writeFileSync(path, ''); + return; + } + + // we have to retain some of the file! + // yuck, so let's read what we need to retain, then overwrite file with it + const fd = fs.openSync(path); + const buffer = Buffer.alloc(len); + fs.readSync(fd, buffer, 0, len, null); + fs.closeSync(fd); + fs.writeFileSync(path, buffer); +}; + +/** + * @param {string|Buffer|URL} path file path + * @param {errorCallback} callback async callback + */ +fs.unlink = (path, callback) => { + callback = maybeCallback(callback); + setTimeout(() => { + try { + fs.unlinkSync(path); + } catch (err) { + callback(err); + return; + } + callback(); + }, 1); +}; +/** + * @param {string|Buffer|URL} path file path + * @returns {undefined} + */ +fs.unlinkSync = (path) => { + const tiFile = getTiFileFromPathLikeValue(path); + if (!tiFile.deleteFile()) { + // we failed, but why? + if (!tiFile.exists()) { + throw noSuchFile('unlink', path); + } + if (tiFile.isDirectory()) { + throw illegalOperationOnADirectory('unlink', path); + } + } +}; + +fs.unwatchFile = unsupportedNoop('fs', 'unwatchFile'); +fs.utimes = (path, atime, mtime, callback) => asyncUnsupportedNoop('fs', 'utimes', callback); +fs.utimesSync = unsupportedNoop('fs', 'utimesSync'); +fs.watch = unsupportedNoop('fs', 'watch'); +fs.watchFile = unsupportedNoop('fs', 'watchFile'); + +/** + * @param {string|Buffer|URL|integer} file file path or descriptor + * @param {string|Buffer|TypedArray|DataView} data data to write + * @param {object|string} [options] options, encoding if string + * @param {string|null} [options.encoding='utf-8'] options + * @param {object} [options.mode=0o666] options + * @param {object} [options.flag='w'] options + * @param {errorCallback} callback async callback + */ +fs.writeFile = (file, data, options, callback) => { + callback = maybeCallback(callback || options); + options = mergeDefaultOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); + + // Turn into file descriptor + const wasFileDescriptor = typeof file === 'number'; + + let fileDescriptor = file; // may be overriden later + const finish = (err) => { + if (err) { + callback(err); + return; + } + + if (wasFileDescriptor) { + callback(); + return; + } + + // fs.close if it was not originally a file descriptor + fs.close(fileDescriptor, callback); + }; + + if (!wasFileDescriptor) { + fs.open(file, options.flag, options.mode, (err, fd) => { + if (err) { + callback(err); + return; + } + fileDescriptor = fd; + fs.write(fileDescriptor, data, finish); + }); + } else { + fs.write(fileDescriptor, data, finish); + } +}; + +/** + * @param {string|Buffer|URL|integer} file file path or descriptor + * @param {string|Buffer|TypedArray|DataView} data data to write + * @param {object|string} [options] options, encoding if string + * @param {string} [options.encoding='utf-8'] options + * @param {object} [options.mode=0o666] options + * @param {object} [options.flag='w'] options + */ +fs.writeFileSync = (file, data, options) => { + options = mergeDefaultOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); + + // Turn into file descriptor + const wasFileDescriptor = typeof file === 'number'; + const fileDescriptor = wasFileDescriptor ? file : fs.openSync(file, options.flag, options.mode); + + // if data is a string, make it a buffer first + if (!Buffer.isBuffer(data)) { + data = Buffer.from('' + data, options.encoding); // force data to be a string, handles case where it's undefined and writes 'undefined' to file! + } + fs.writeSync(fileDescriptor, data); + + // close if user didn't give us file descriptor + if (!wasFileDescriptor) { + fs.closeSync(fileDescriptor); + } +}; + +/** + * @callback writeTiFileStreamCallback + * @param {Error} err - Error if one occurred + * @param {integer} written - bytes written + */ + +/** + * @param {Ti.Filesystem.FileStream} tiFileStream file stream + * @param {Buffer} buffer buffer we're writing + * @param {writeTiFileStreamCallback} callback async callback + */ +function writeTiFileStream(tiFileStream, buffer, callback) { + callback = maybeCallback(callback); + Ti.Stream.write(tiFileStream, buffer.toTiBuffer(), (writeObj) => { + if (!writeObj.success) { + callback(new Error(writeObj.error)); + return; + } + callback(null, writeObj.bytesProcessed); + }); +} + +/** + * @param {integer} fd file descriptor + * @param {string|Buffer} buffer contents to write: Buffer or string + * @param {integer} [offset] offset within Buffer to write; OR offset from the beginning of the file where this data should be written (if string) + * @param {string|integer} [length] length of bytes to write if Buffer; OR expected string encoding + * @param {writeCallback|integer} [position] offset from the beginning of the file where this data should be written (if Buffer); OR async callback if string + * @param {writeCallback} [callback] async callback (if Buffer) + */ +fs.write = (fd, buffer, offset, length, position, callback) => { + const isBuffer = Buffer.isBuffer(buffer); + if (isBuffer) { + writeBuffer(fd, buffer, offset, length, position, callback); + } else { + writeString(fd, buffer, offset, length, position); + } +}; + +/** + * @param {integer} fd file descriptor + * @param {string|Buffer} buffer contents to write + * @param {integer} [offset] offset from the beginning of the file where this data should be written + * @param {string|integer} [length] expected string encoding + * @param {integer} [position] position + * @returns {integer} number of bytes written + */ +fs.writeSync = (fd, buffer, offset, length, position) => { + const isBuffer = Buffer.isBuffer(buffer); + if (isBuffer) { + return writeBufferSync(fd, buffer, offset, length, position); + } + return writeStringSync(fd, buffer, offset, length); +}; + +// TODO: Add FileHandle class to match Node's wrapper for file descriptors. Re-purpose our own wrapper? +// TODO: Add the fs.promises API! + +// TODO: Define fs.Dirent class, which can simply wrap a Ti.Filesystem.File (and is very similar to fs.Stats!) + +// Helper functions +// -------------------------------------------------------- + +/** + * Tracks the pairing of the number we use to represent the file externally, the filepath it's pointing at, and the stream pointing at it. + */ +class FileDescriptor { + constructor (number, path, stream) { + this.path = path; + this.number = number; + this.stream = stream; + } +} + +/** + * @param {Ti.IOStream} srcStream input stream we're reading from + * @param {Ti.IOStream} destStream output stream we're writing to + * @param {errorCallback} callback async callback + */ +function pipe(srcStream, destStream, callback) { + if (isAndroid) { + // Android is probably better off with Ti.Stream.writeStream, less overhead back and forth the bridge + // Though Android does support the Ti.Stream.pump/Ti.Stream.write pattern using both APIs async + pipeViaWriteStream(srcStream, destStream, callback); + return; + } + // iOS has some... issues with writeStream calling the callback every iteration of the loop *and* at the end + // it also doesn't play as expected when doing Ti.Stream.pump and Ti.Stream.write async each + // it ends up doing all reads first and then all writes + // so we have to hack here and do Ti.Stream.pump async, but each time the read callback happens we do a *sync* write inside it + // See https://jira.appcelerator.org/browse/TIMOB-27321 + pipeViaPump(srcStream, destStream, callback); +} + +/** + * @param {Ti.IOStream} srcStream input stream we're reading from + * @param {Ti.IOStream} destStream output stream we're writing to + * @param {errorCallback} callback async callback + */ +function pipeViaWriteStream(srcStream, destStream, callback) { + Ti.Stream.writeStream(srcStream, destStream, COPY_FILE_CHUNK_SIZE, result => { + if (!result.success) { + return callback(new Error(result.error)); + } + + // Android will only call this at the end or error, so we can safely assume we're done here. + // iOS will call per loop iteration, see https://jira.appcelerator.org/browse/TIMOB-27320 + callback(); + }); +} + +/** + * @param {Ti.IOStream} srcStream input stream we're reading from + * @param {Ti.IOStream} destStream output stream we're writing to + * @param {errorCallback} callback async callback + */ +function pipeViaPump(srcStream, destStream, callback) { + Ti.Stream.pump(srcStream, obj => { + if (!obj.success) { + return callback(new Error(obj.error)); // TODO: set code via writeObj.code? + } + + if (obj.bytesProcessed === -1) { // reached EOF + return callback(); + } + + // we read some segment of the input stream and have not reached EOF yet + let bytesWritten = 0; + let offset = 0; + let length = obj.bytesProcessed; + try { + while (true) { + // try to write all of the current buffer + const bytesWrittenThisChunk = destStream.write(obj.buffer, offset, length); + bytesWritten += bytesWrittenThisChunk; + if (bytesWritten === obj.bytesProcessed) { + // wrote same amount of bytes as we read, move on + break; + } + // NOTE: This shouldn't ever happen because our APIs should write the entire byte array or fail, but just in case... + // we didn't write it all, so move on to try and write the rest of buffer... + offset = bytesWritten; + length = obj.bytesProcessed - bytesWritten; + } + } catch (e) { + return callback(e); + } + }, COPY_FILE_CHUNK_SIZE, true); +} + +/** + * @param {string|Buffer|URL} path file path + * @param {Ti.Filesystem.FileStream} fileStream file stream + * @returns {integer} file descriptor + */ +function createFileDescriptor(path, fileStream) { + const pointer = fileDescriptorCount++; // increment global counter + const fd = new FileDescriptor(pointer, path, fileStream); + fileDescriptors.set(pointer, fd); // use it to refer to this file stream as the "descriptor" + return pointer; +} + +/** + * @param {integer} fd file descriptor + * @returns {Ti.Filesystem.FileStream} matching stream + */ +function streamForDescriptor(fd) { + const wrapper = fileDescriptors.get(fd); + return wrapper.stream; +} + +/** + * @param {integer} fd file descriptor + * @returns {string} matching stream + */ +function pathForFileDescriptor(fd) { + const wrapper = fileDescriptors.get(fd); + return wrapper.path; +} + +/** + * Used to merge the user-supplied options with the defaults for a function. Special cases a string to be encoding. + * @param {*} options user-supplied options + * @param {object} defaults defaults to use + * @return {object} + */ +function mergeDefaultOptions(options, defaults) { + if (options === null) { + return defaults; + } + + const optionsType = typeof options; + switch (optionsType) { + case 'undefined': + case 'function': + return defaults; + case 'string': + // Use copy of defaults but with encoding set to the 'options' value! + const merged = Object.assign({}, defaults); + merged.encoding = options; + return merged; + case 'object': + return options; + default: + assertArgumentType(options, 'options', 'object'); + return null; // should never get reached + } +} + +/** + * Enforces that we have a valid callback function. Throws TypeError if not. + * @param {*} cb possible callback function + * @returns {Function} + * @throws {TypeError} + */ +function maybeCallback(cb) { + if (typeof cb === 'function') { + return cb; + } + + const err = new TypeError(`Callback must be a function. Received ${cb}`); + err.code = 'ERR_INVALID_CALLBACK'; + throw err; +} + +/** + * returns randomly generated characters of given length 1-16 + * @param {integer} length 1 - 16 + * @param {string} [_encoding='utf8'] encoding of the string generated + * @returns {string} + */ +function randomCharacters(length, _encoding = 'utf8') { + // FIXME: use the encoding specified! + return (Math.random().toString(36) + '00000000000000000').slice(2, length + 2); +} + +function makeError(code, message, errno, syscall, path) { + const error = new Error(`${code}: ${message}, ${syscall} '${path}'`); + error.errno = errno; + error.syscall = syscall; + error.code = code; + error.path = path; + return error; +} + +/** + * @param {string} encoding what we're encoding to + * @param {Ti.Buffer} tiBuffer Ti.Buffer instance + * @returns {Buffer} node-compatible Buffer instance + */ +function encodeBuffer(encoding, tiBuffer) { + const buffer = Buffer.from(tiBuffer); + switch (encoding) { + case 'buffer': + case null: + case undefined: + return buffer; + default: + return buffer.toString(encoding); + } +} + +/** + * @param {string|Buffer|URL} path file path + * @return {Ti.Filesystem.File} + */ +function getTiFileFromPathLikeValue(path) { + // This is a hack that is likely to work in most cases? + // Basically assumes Buffer is holding a utf-8 string filename/path + // Node just copies the bytes from the buffer as-is on the native side and adds a null terminator + if (Buffer.isBuffer(path)) { + path = path.toString(); // assumes utf-8 string + } + // FIXME: Handle URLs! We don't have an URL shim yet, so no way to handle those yet + assertArgumentType(path, 'path', 'string'); + return Ti.Filesystem.getFile(path); +} + +/** + * @callback writeBufferCallback + * @param {Error} err - Error if one occurred + * @param {integer} written - bytes written + * @param {Buffer} buffer - original Buffer being written + */ + +/** + * @param {integer} fd file descriptor + * @param {Buffer} buffer contents to write + * @param {integer} [offset] offset within Buffer to write + * @param {integer} [length] length of bytes to write if Buffer + * @param {integer} [position] offset from the beginning of the file where this data should be written + * @param {writeBufferCallback} callback async callback + */ +function writeBuffer(fd, buffer, offset, length, position, callback) { + callback = maybeCallback(callback || position || length || offset); + if (typeof offset !== 'number') { + offset = 0; + } + if (typeof length !== 'number') { + length = buffer.length - offset; + } + if (typeof position !== 'number') { + position = null; + } + // ok now what? + const tiFileStream = streamForDescriptor(fd); + // Make use of the buffer slice that's specified by offset/length + if (offset !== 0 || length !== buffer.length) { + buffer = buffer.slice(offset, length); + } + // TODO: Support use of position argument. I assume we'd need a way to add a method to move to stream position somehow + writeTiFileStream(tiFileStream, buffer, (err, bytesProcessed) => { + if (err) { + callback(err); + return; + } + callback(null, bytesProcessed, buffer); + }); +} + +/** + * @param {integer} fd file descriptor + * @param {Buffer} buffer contents to write + * @param {integer} [offset] offset within Buffer to write + * @param {integer} [length] length of bytes to write if Buffer + * @param {integer} [position] offset from the beginning of the file where this data should be written + * @returns {integer} number of bytes written + */ +function writeBufferSync(fd, buffer, offset, length, position) { + if (typeof offset !== 'number') { + offset = 0; + } + if (typeof length !== 'number') { + length = buffer.length - offset; + } + if (typeof position !== 'number') { + position = null; + } + // ok now what? + const tiFileStream = streamForDescriptor(fd); + // Make use of the buffer slice that's specified by offset/length + if (offset !== 0 || length !== buffer.length) { + buffer = buffer.slice(offset, length); + } + // TODO: Support use of position argument. I assume we'd need a way to add a method to move to stream position somehow + return tiFileStream.write(buffer.toTiBuffer()); +} + +/** + * @callback writeStringCallback + * @param {Error} err - Error if one occurred + * @param {integer} written - bytes written + * @param {string} string - original string being written + */ + +/** + * @param {integer} fd file descriptor + * @param {string} string contents to write + * @param {integer} [position] offset from the beginning of the file where this data should be written + * @param {string} [encoding='utf8'] expected string encoding + * @param {writeStringCallback} [callback] async callback + */ +function writeString(fd, string, position, encoding, callback) { + callback = maybeCallback(callback || encoding || position); + // position could be: number, function (callback) + if (typeof position !== 'number') { + position = null; + } + // encoding could be: function (callback) or string + if (typeof encoding !== 'string') { + encoding = 'utf8'; + } + const tiFileStream = streamForDescriptor(fd); + string += ''; // coerce to string + const buffer = Buffer.from(string, encoding); + // TODO: Support use of position argument. I assume we'd need a way to add a method to move to stream position somehow + writeTiFileStream(tiFileStream, buffer, (err, bytesProcessed) => { + if (err) { + callback(err); + return; + } + callback(null, bytesProcessed, string); + }); +} + +/** + * @param {integer} fd file descriptor + * @param {string} string contents to write + * @param {integer} [position] offset from the beginning of the file where this data should be written + * @param {string} [encoding='utf8'] expected string encoding + * @returns {integer} number of bytes written + */ +function writeStringSync(fd, string, position, encoding) { + if (typeof position !== 'number') { + position = null; + } + if (typeof encoding !== 'string') { + encoding = 'utf8'; + } + const tiFileStream = streamForDescriptor(fd); + string += ''; // coerce to string + const buffer = Buffer.from(string, encoding); + // TODO: Support use of position argument. I assume we'd need a way to add a method to move to stream position somehow + return tiFileStream.write(buffer.toTiBuffer()); +} + +export default fs; diff --git a/common/Resources/ti.internal/extensions/node/index.js b/common/Resources/ti.internal/extensions/node/index.js index b434d485935..9e77b63186d 100644 --- a/common/Resources/ti.internal/extensions/node/index.js +++ b/common/Resources/ti.internal/extensions/node/index.js @@ -8,6 +8,7 @@ import assert from './assert'; import events from './events'; import BufferModule from './buffer'; import StringDecoder from './string_decoder'; +import fs from './fs'; // hook our implementations to get loaded by require import { register } from '../binding'; @@ -19,6 +20,7 @@ register('assert', assert); register('events', events); register('buffer', BufferModule); register('string_decoder', StringDecoder); +register('fs', fs); // Register require('buffer').Buffer as global global.Buffer = BufferModule.Buffer; diff --git a/tests/Resources/fs.addontest.js b/tests/Resources/fs.addontest.js new file mode 100644 index 00000000000..da37d640e03 --- /dev/null +++ b/tests/Resources/fs.addontest.js @@ -0,0 +1,1493 @@ +/* + * Appcelerator Titanium Mobile + * Copyright (c) 2011-Present by Appcelerator, Inc. All Rights Reserved. + * Licensed under the terms of the Apache Public License + * Please see the LICENSE included with this distribution for details. + */ +/* eslint-env mocha */ +/* eslint no-unused-expressions: "off" */ +'use strict'; +const should = require('./utilities/assertions'); +const path = require('path'); + +/** + * Just using __filename fails at least on iOS, I don't think we support assumption of resources dir by default for absolute paths! + */ +// eslint-disable-next-line no-path-concat +const thisFilePath = Ti.Filesystem.resourcesDirectory + __filename; +const thisFile = Ti.Filesystem.getFile(thisFilePath); + +let fs; + +describe('fs', function () { + it('is required as a core module', () => { + fs = require('fs'); + should(fs).be.ok; + }); + + describe('.constants', () => { + it('is an object', () => { + should(fs.constants).be.an.Object; + }); + + it('.F_OK equals 0', () => { + should(fs.constants.F_OK).eql(0); + }); + + it('.R_OK equals 4', () => { + should(fs.constants.R_OK).eql(4); + }); + + it('.W_OK equals 2', () => { + should(fs.constants.W_OK).eql(2); + }); + + it('.X_OK equals 1', () => { + should(fs.constants.X_OK).eql(1); + }); + }); + + describe('#access()', () => { + it('is a function', () => { + should(fs.access).be.a.Function; + }); + + it('checks that this file exists properly', finished => { + fs.access(thisFilePath, fs.constants.F_OK, err => { + finished(err); + }); + }); + + it('throws when trying to access file that doesn\'t exist', finished => { + fs.access('/madeup', err => { + try { + should(err).be.ok; // aka, there is an error + // TODO Verify the error.code value is EACCESS! + finished(); + } catch (e) { + finished(e); + } + }); + }); + + it('uses mode F_OK by default', finished => { + fs.access(thisFilePath, err => { + finished(err); + }); + }); + + it('checks that this file is readable properly', finished => { + fs.access(thisFilePath, fs.constants.R_OK, err => { + finished(err); + }); + }); + + it('checks that this file is NOT writable properly', finished => { + fs.access(thisFilePath, fs.constants.W_OK, err => { + try { + should(err).be.ok; // aka, there is an error + // TODO Verify the error.code value is EACCESS! + finished(); + } catch (e) { + finished(e); + } + }); + }); + + it('checks that this file is NOT executable properly', finished => { + fs.access(thisFilePath, fs.constants.X_OK, err => { + try { + should(err).be.ok; // aka, there is an error + // TODO Verify the error.code value is EACCESS! + finished(); + } catch (e) { + finished(e); + } + }); + }); + }); + + describe('#accessSync()', () => { + it('is a function', () => { + should(fs.accessSync).be.a.Function; + }); + + it('checks that this file exists properly', () => { + fs.accessSync(thisFilePath, fs.constants.F_OK); + }); + + it('throws when trying to access file that doesn\'t exist', () => { + should.throws(() => { + fs.accessSync('/madeup'); + }, Error); + // TODO: Verify format of error! + }); + + it('uses mode F_OK by default', () => { + fs.accessSync(thisFilePath); + }); + + it('checks that this file is readable properly', () => { + fs.accessSync(thisFilePath, fs.constants.R_OK); + }); + + it.allBroken('checks that this file is NOT writable properly', () => { + // FIXME: This isn't throwing an error like we expect! + // How can we test this? resourcesDirectory is not supposed to be writable on device, but can be on simulator. + // (and this may be platform-specific behavior) + should.throws(() => { + fs.accessSync(thisFilePath, fs.constants.W_OK); + }, Error); + // TODO Verify the error.code value is EACCESS! + }); + + it('checks that this file is NOT executable properly', () => { + should.throws(() => { + fs.accessSync(thisFilePath, fs.constants.X_OK); + }, Error); + // TODO Verify the error.code value is EACCESS! + }); + }); + + // TODO: #appendFile() + + describe('#appendFileSync()', () => { + it('is a function', () => { + should(fs.appendFileSync).be.a.Function; + }); + }); + + // TODO: #close() + + describe('#closeSync()', () => { + it('is a function', () => { + should(fs.closeSync).be.a.Function; + }); + + it('returns undefined', () => { + const fd = fs.openSync(thisFilePath); + const result = fs.closeSync(fd); + should(result).be.undefined(); + }); + }); + + describe('#copyFile()', () => { + it('is a function', () => { + should(fs.copyFile).be.a.Function; + }); + + it('copies file asynchronously to destination', function (finished) { + this.slow(2000); + this.timeout(5000); + + const dest = path.join(Ti.Filesystem.tempDirectory, 'fs.addontest.js'); + // ensure file doesn't already exist + const destFile = Ti.Filesystem.getFile(dest); + if (destFile.exists()) { + should(destFile.deleteFile()).eql(true); + } + should(destFile.exists()).eql(false); + + fs.copyFile(thisFilePath, dest, err => { + try { + should(err).not.be.ok; + fs.existsSync(dest).should.eql(true); + // TODO: Read in the file and compare contents? Check filesize matches? + finished(); + } catch (e) { + finished(e); + } + }); + }); + }); + + describe('#copyFileSync()', () => { + it('is a function', () => { + should(fs.copyFileSync).be.a.Function; + }); + + it('copies file synchronously to destination', () => { + const dest = Ti.Filesystem.tempDirectory + 'fs.addontest.js'; + // ensure file doesn't already exist + const destFile = Ti.Filesystem.getFile(dest); + if (destFile.exists()) { + should(destFile.deleteFile()).eql(true); + } + should(destFile.exists()).eql(false); + + fs.copyFileSync(thisFilePath, dest); + fs.existsSync(dest).should.eql(true); + // TODO: Read in the file and compare contents? Check filesize matches? + }); + + // TODO: Check that we fail if file already exists with flag fs.constants.COPYFILE_EXCL + // TODO: Check that we overwrite byudefault if file already exists without flag + }); + + describe('#exists()', () => { + it('is a function', () => { + should(fs.exists).be.a.Function; + }); + + it('checks that this file exists properly', finished => { + fs.exists(thisFilePath, exists => { + try { + exists.should.eql(true); + finished(); + } catch (e) { + finished(e); + } + }); + }); + + it('checks that non-existent file returns false', finished => { + fs.exists('/some/made/up/path', exists => { + try { + exists.should.eql(false); + finished(); + } catch (e) { + finished(e); + } + }); + }); + }); + + describe('#existsSync()', () => { + it('is a function', () => { + should(fs.existsSync).be.a.Function; + }); + + it('checks that this file exists properly', () => { + fs.existsSync(thisFilePath).should.eql(true); + }); + + it('checks that non-existent file returns false', () => { + fs.existsSync('/some/made/up/path').should.eql(false); + }); + }); + + describe('#mkdir()', () => { + it('is a function', () => { + should(fs.mkdir).be.a.Function; + }); + + it('creates directory of depth 0', finished => { + const dirPath = path.join(Ti.Filesystem.tempDirectory, `mkdir${Date.now()}`); + should(fs.existsSync(dirPath)).eql(false); // should not exist first! + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); // parent should exist first! + fs.mkdir(dirPath, err => { + try { + should(err).not.exist; + should(fs.existsSync(dirPath)).eql(true); + } catch (e) { + finished(e); + return; + } + finished(); + }); + }); + + it('creates recursively if passed option to', finished => { + const subdirPath = path.join(Ti.Filesystem.tempDirectory, `mkdir_r_${Date.now()}`, 'subdir'); + should(fs.existsSync(subdirPath)).eql(false); // should not exist first! + should(fs.existsSync(path.dirname(subdirPath))).eql(false); // parent should not exist first! + fs.mkdir(subdirPath, { recursive: true }, err => { + try { + should(err).not.exist; + should(fs.existsSync(subdirPath)).eql(true); + } catch (e) { + finished(e); + return; + } + finished(); + }); + }); + + it('does not create recursively by default', finished => { + const subdirPath = path.join(Ti.Filesystem.tempDirectory, `mkdir_r2_${Date.now()}`, 'subdir'); + should(fs.existsSync(subdirPath)).eql(false); // should not exist first! + should(fs.existsSync(path.dirname(subdirPath))).eql(false); // parent should not exist first! + fs.mkdir(subdirPath, err => { + try { + should(err).exist; + err.name.should.eql('Error'); + err.message.should.eql(`ENOENT: no such file or directory, mkdir '${subdirPath}'`); + err.code.should.eql('ENOENT'); + err.errno.should.eql(-2); + err.syscall.should.eql('mkdir'); + err.path.should.eql(subdirPath); + should(fs.existsSync(subdirPath)).eql(false); + } catch (e) { + finished(e); + return; + } + finished(); + }); + }); + }); + + describe('#mkdirSync()', () => { + it('is a function', () => { + should(fs.mkdirSync).be.a.Function; + }); + + it('creates directory of depth 0', () => { + const dirPath = path.join(Ti.Filesystem.tempDirectory, `mkdirSync${Date.now()}`); + should(fs.existsSync(dirPath)).eql(false); // should not exist first! + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); // parent should exist first! + fs.mkdirSync(dirPath); + should(fs.existsSync(dirPath)).eql(true); + }); + + it('creates recursively if passed option to', () => { + const subdirPath = path.join(Ti.Filesystem.tempDirectory, `mkdirSync_r_${Date.now()}`, 'subdir'); + should(fs.existsSync(subdirPath)).eql(false); // should not exist first! + should(fs.existsSync(path.dirname(subdirPath))).eql(false); // parent should not exist first! + fs.mkdirSync(subdirPath, { recursive: true }); + should(fs.existsSync(subdirPath)).eql(true); + }); + + it('does not create recursively by default', () => { + const subdirPath = path.join(Ti.Filesystem.tempDirectory, `mkdirSync_r2_${Date.now()}`, 'subdir'); + should(fs.existsSync(subdirPath)).eql(false); // should not exist first! + should(fs.existsSync(path.dirname(subdirPath))).eql(false); // parent should not exist first! + + try { + fs.mkdirSync(subdirPath); + should.fail(true, false, 'expected fs.mkdirSync to throw Error when parent does not exist and not recursive'); + } catch (err) { + err.name.should.eql('Error'); + err.message.should.eql(`ENOENT: no such file or directory, mkdir '${subdirPath}'`); + err.code.should.eql('ENOENT'); + err.errno.should.eql(-2); + err.syscall.should.eql('mkdir'); + err.path.should.eql(subdirPath); + } + should(fs.existsSync(subdirPath)).eql(false); + }); + + it('throws Error when trying to create a directory that exists', () => { + const targetPath = Ti.Filesystem.tempDirectory; + should(fs.existsSync(targetPath)).eql(true); // should exist first! + + try { + fs.mkdirSync(targetPath); + should.fail(true, false, 'expected fs.mkdirSync to throw Error when trying to create directory that exists'); + } catch (err) { + err.name.should.eql('Error'); + err.message.should.eql(`EEXIST: file already exists, mkdir '${targetPath}'`); + err.code.should.eql('EEXIST'); + err.errno.should.eql(-17); + err.syscall.should.eql('mkdir'); + err.path.should.eql(targetPath); + } + }); + + it('does not throw Error when trying to create a directory that exists if recursive option is true', () => { + const targetPath = Ti.Filesystem.tempDirectory; + should(fs.existsSync(targetPath)).eql(true); // should exist first! + should.doesNotThrow(() => { + fs.mkdirSync(targetPath, { recursive: true }); + }, Error); + }); + }); + + // TODO: #mkdtemp() + + describe('#mkdtempSync()', () => { + it('is a function', () => { + should(fs.mkdtempSync).be.a.Function; + }); + + it('creates directory of depth 0', () => { + const prefix = path.join(Ti.Filesystem.tempDirectory, `mkdtempSync${Date.now()}-`); + const result = fs.mkdtempSync(prefix); + should(result.startsWith(prefix)).eql(true); + should(result).have.length(prefix.length + 6); // 6 characters appended + }); + + it('throws with non-string prefix', () => { + should.throws(() => { + fs.mkdtempSync(123); + }, TypeError); + }); + }); + + describe('#openSync()', () => { + it('is a function', () => { + should(fs.openSync).be.a.Function; + }); + + it('returns integer representing file descriptor', () => { + const fd = fs.openSync(thisFilePath); + try { + should(fd).be.a.Number; + should(fd).be.above(2); // 0, 1, 2 are typical stdin/stdout/stderr numbers + } finally { + fs.closeSync(fd); + } + }); + + // TODO: Test with file: URL + // TODO: Test with Buffer?! + }); + + describe('#readdir()', () => { + it('is a function', () => { + should(fs.readdir).be.a.Function; + }); + + it('returns listing for this directory', finished => { + fs.readdir(Ti.Filesystem.resourcesDirectory, (err, files) => { + try { + should(files).be.an.Array; + should(files.length).be.greaterThan(1); // it should have some files, man + should(files).containEql('app.js'); + + finished(); + } catch (e) { + finished(e); + } + }); + }); + + it('returns Buffers for listing for this directory if encoding === "buffer"', finished => { + fs.readdir(Ti.Filesystem.resourcesDirectory, { encoding: 'buffer' }, (err, files) => { + try { + should(files).be.an.Array; + should(files.length).be.greaterThan(1); // it should have some files, man + should(files).containEql(Buffer.from('app.js')); + + finished(); + } catch (e) { + finished(e); + } + }); + }); + + it('returns Error for non-existent path', finished => { + fs.readdir('/fake/path', (err, files) => { + try { + should(err).be.ok; // aka we have an error + should(err.message).startWith('ENOENT: no such file or directory'); + should(files).not.be.ok; // no files listing + + finished(); + } catch (e) { + finished(e); + } + }); + }); + + it('returns Error for file path', finished => { + fs.readdir(thisFilePath, (err, files) => { + try { + should(err).be.ok; // aka we have an error + should(err.message).startWith('ENOTDIR: not a directory, scandir'); + should(files).not.be.ok; // no files listing + + finished(); + } catch (e) { + finished(e); + } + }); + }); + }); + + describe('#readdirSync()', () => { + it('is a function', () => { + should(fs.readdirSync).be.a.Function; + }); + + it('returns listing for this directory', () => { + const files = fs.readdirSync(Ti.Filesystem.resourcesDirectory); + should(files).be.an.Array; + should(files.length).be.greaterThan(1); // it should have some files, man + should(files).containEql('app.js'); + }); + + it('returns Buffers for listing for this directory if encoding === "buffer"', () => { + const files = fs.readdirSync(Ti.Filesystem.resourcesDirectory, { encoding: 'buffer' }); + should(files).be.an.Array; + should(files.length).be.greaterThan(1); // it should have some files, man + should(files).containEql(Buffer.from('app.js')); + }); + + it('returns Error for non-existent path', () => { + try { + fs.readdirSync('/fake/path'); + should.fail(true, false, 'expected fs.readdirSync to throw Error when path does not exist'); + } catch (err) { + should(err).be.ok; // aka we have an error + should(err.message).startWith('ENOENT: no such file or directory'); + } + }); + + it('returns Error for file path', () => { + try { + fs.readdirSync(thisFilePath); + should.fail(true, false, 'expected fs.readdirSync to throw Error when path is a file'); + } catch (err) { + should(err).be.ok; // aka we have an error + should(err.message).startWith('ENOTDIR: not a directory, scandir'); + } + }); + }); + + describe('#readFile()', () => { + it('is a function', () => { + should(fs.readFile).be.a.Function; + }); + + it('returns Buffer when no encoding set', finished => { + fs.readFile(thisFilePath, (err, result) => { + should(err).not.exist; + should(result).not.be.a.String; + finished(); + }); + }); + + it('returns String when utf-8 encoding set via second argument', finished => { + fs.readFile(thisFilePath, 'utf-8', (err, result) => { + should(err).not.exist; + should(result).be.a.String; + finished(); + }); + }); + + it('returns String when utf-8 encoding set via options object argument', finished => { + fs.readFile(thisFilePath, { encoding: 'utf-8' }, (err, result) => { + should(err).not.exist; + should(result).be.a.String; + finished(); + }); + }); + }); + + describe('#readFileSync()', () => { + it('is a function', () => { + should(fs.readFileSync).be.a.Function; + }); + + it('returns Buffer when no encoding set', () => { + const result = fs.readFileSync(thisFilePath); + should(result).not.be.a.String; + }); + + it('returns String when utf-8 encoding set via second argument', () => { + const result = fs.readFileSync(thisFilePath, 'utf-8'); + should(result).be.a.String; + }); + + it('returns String when utf-8 encoding set via options object argument', () => { + const result = fs.readFileSync(thisFilePath, { encoding: 'utf-8' }); + should(result).be.a.String; + }); + }); + + describe('#read()', () => { + it('is a function', () => { + should(fs.read).be.a.Function; + }); + + it('reads 10 bytes of this file', finished => { + const origBuffer = Buffer.alloc(123); + const fd = fs.openSync(thisFilePath); + fs.read(fd, origBuffer, 0, 10, null, (err, bytesRead, buffer) => { + try { + fs.closeSync(fd); + should(err).not.exist; + should(bytesRead).eql(10); + should(buffer).eql(origBuffer); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + }); + + describe('#readSync()', () => { + it('is a function', () => { + should(fs.readSync).be.a.Function; + }); + + it('reads 10 bytes of this file', finished => { + const origBuffer = Buffer.alloc(123); + const fd = fs.openSync(thisFilePath); + try { + const bytesRead = fs.readSync(fd, origBuffer, 0, 10, null); + should(bytesRead).eql(10); + } catch (e) { + return finished(e); + } finally { + fs.closeSync(fd); + } + finished(); + }); + }); + + describe('#realpath()', () => { + it('is a function', () => { + should(fs.realpath).be.a.Function; + }); + + it('normalizes .', finished => { + // FIXME: On Android, Ti.Filesystem.resourcesDirectory gives us something like "app://", which blows this up! + fs.realpath('node_modules/.', (err, result) => { + try { + should(err).not.exist; + result.should.eql('node_modules'); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + + it('normalizes ..', finished => { + fs.realpath('node_modules/abbrev/..', (err, result) => { + try { + should(err).not.exist; + result.should.eql('node_modules'); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + + it('throws an Error if path doesn\'t exist', finished => { + fs.realpath('/madeup/path', (err, _result) => { + try { + should(err).exist; + should(err).have.properties({ + syscall: 'lstat', + code: 'ENOENT', + path: '/madeup', + errno: -2, + message: 'ENOENT: no such file or directory, lstat \'/madeup\'' + }); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + }); + + describe('#realpathSync()', () => { + it('is a function', () => { + should(fs.realpath).be.a.Function; + }); + + it('normalizes .', () => { + const result = fs.realpathSync('node_modules/.'); + result.should.eql('node_modules'); + }); + + it('normalizes ..', () => { + const result = fs.realpathSync('node_modules/abbrev/..'); + result.should.eql('node_modules'); + }); + + it('throws an Error if path doesn\'t exist', () => { + try { + fs.realpathSync('/madeup/path'); + should.fail(true, false, 'expected fs.realpathSync to throw Error when path does not exist'); + } catch (err) { + should(err).have.properties({ + syscall: 'lstat', + code: 'ENOENT', + path: '/madeup', + errno: -2, + message: 'ENOENT: no such file or directory, lstat \'/madeup\'' + }); + } + }); + }); + + describe('#rename()', () => { + it('is a function', () => { + should(fs.rename).be.a.Function; + }); + // TODO: Try renaming to existing file/dir + // TODO: What other error conditions can we test? rename into path we don't have permissions? + }); + + describe('#renameSync()', () => { + it('is a function', () => { + should(fs.renameSync).be.a.Function; + }); + + it('renames a file', () => { + // create a source file + const file = path.join(Ti.Filesystem.tempDirectory, `renameSync${Date.now()}`); + fs.writeFileSync(file); + should(fs.existsSync(file)).eql(true); + + // make sure destiantion doesn't exist + const newFile = path.join(Ti.Filesystem.tempDirectory, `renameSync-renamed-${Date.now()}`); + should(fs.existsSync(newFile)).eql(false); + + // rename + fs.renameSync(file, newFile); + + // source no longer exists, but dest does + should(fs.existsSync(file)).eql(false); + should(fs.existsSync(newFile)).eql(true); + }); + + // TODO: It appears that node's fs.renameSync is happy to rename a file to a destination name even if the destination already exists! + // I assume we won't...? + it('does not throw trying to rename to existing file', () => { + // create first file + const file = path.join(Ti.Filesystem.tempDirectory, `renameSync_1_${Date.now()}`); + fs.writeFileSync(file, 'yup'); + should(fs.existsSync(file)).eql(true); + + // create destination file + const existingFile = path.join(Ti.Filesystem.tempDirectory, `renameSync_2_${Date.now()}`); + fs.writeFileSync(existingFile, 'hi there'); + should(fs.existsSync(existingFile)).eql(true); + + // rename from source to dest (even though it already exists!) + fs.renameSync(file, existingFile); + + // source no longer exists, but dest does + should(fs.existsSync(file)).eql(false); + should(fs.existsSync(existingFile)).eql(true); + }); + + it('throws trying to rename to existing directory', () => { + // create first file + const file = path.join(Ti.Filesystem.tempDirectory, `renameSync_5_${Date.now()}`); + fs.writeFileSync(file); + should(fs.existsSync(file)).eql(true); + + // create destination dir path + const existingDir = path.join(Ti.Filesystem.tempDirectory, `renameSync_6_${Date.now()}`); + fs.mkdirSync(existingDir); + should(fs.existsSync(existingDir)).eql(true); + + try { + fs.renameSync(file, existingDir); + should.fail(true, false, 'expected fs.renameSync to throw Error when renaming to existing directory'); + } catch (error) { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`EISDIR: illegal operation on a directory, rename '${file}' -> '${existingDir}'`); + error.code.should.eql('EISDIR'); + error.errno.should.eql(-21); + error.syscall.should.eql('rename'); + error.path.should.eql(file); + error.dest.should.eql(existingDir); + } + // Both files should still exist + should(fs.existsSync(file)).eql(true); + should(fs.existsSync(existingDir)).eql(true); + }); + + it('throws trying to rename from non-existent source path', () => { + // make up a source path that doesn't exist + const file = path.join(Ti.Filesystem.tempDirectory, `renameSync_3_${Date.now()}`); + should(fs.existsSync(file)).eql(false); + + const destFile = path.join(Ti.Filesystem.tempDirectory, `renameSync_4_${Date.now()}`); + try { + fs.renameSync(file, destFile); + should.fail(true, false, 'expected fs.renameSync to throw Error when renaming to existing file'); + } catch (error) { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`ENOENT: no such file or directory, rename '${file}' -> '${destFile}'`); + error.code.should.eql('ENOENT'); + error.errno.should.eql(-2); + error.syscall.should.eql('rename'); + error.path.should.eql(file); + error.dest.should.eql(destFile); + } + }); + }); + + describe('#rmdir()', () => { + it('is a function', () => { + should(fs.rmdir).be.a.Function; + }); + + it('deletes directory that is empty', finished => { + const dirName = path.join(Ti.Filesystem.tempDirectory, `rmdir${Date.now()}`); + fs.mkdirSync(dirName); + should(fs.existsSync(dirName)).eql(true); + fs.rmdir(dirName, err => { + should(err).not.exist; + should(fs.existsSync(dirName)).eql(false); + finished(); + }); + }); + + it('throws trying to delete directory that is NOT empty', finished => { + const dirName = path.join(Ti.Filesystem.tempDirectory, `rmdir${Date.now()}`); + fs.mkdirSync(dirName); + should(fs.existsSync(dirName)).eql(true); + const file = path.join(dirName, 'myfile.txt'); + fs.writeFileSync(file, 'Hello World!'); + should(fs.existsSync(file)).eql(true); + + fs.rmdir(dirName, error => { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`ENOTEMPTY: directory not empty, rmdir '${dirName}'`); + error.errno.should.eql(-66); + error.syscall.should.eql('rmdir'); + error.code.should.eql('ENOTEMPTY'); + error.path.should.eql(dirName); + should(fs.existsSync(dirName)).eql(true); + finished(); + }); + }); + + it('throws trying to remove file instead of directory', finished => { + fs.rmdir(thisFilePath, error => { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`ENOTDIR: not a directory, rmdir '${thisFilePath}'`); + error.errno.should.eql(-20); + error.syscall.should.eql('rmdir'); + error.code.should.eql('ENOTDIR'); + error.path.should.eql(thisFilePath); + finished(); + }); + }); + + it('throws trying to remove non-existent directory', finished => { + const dirName = '/made/up/path'; + should(fs.existsSync(dirName)).eql(false); + + fs.rmdir(dirName, error => { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`ENOENT: no such file or directory, rmdir '${dirName}'`); + error.errno.should.eql(-2); + error.syscall.should.eql('rmdir'); + error.code.should.eql('ENOENT'); + error.path.should.eql(dirName); + finished(); + }); + }); + }); + + describe('#rmdirSync()', () => { + it('is a function', () => { + should(fs.rmdirSync).be.a.Function; + }); + + it('deletes directory that is empty', () => { + const dirName = path.join(Ti.Filesystem.tempDirectory, `rmdirSync${Date.now()}`); + fs.mkdirSync(dirName); + should(fs.existsSync(dirName)).eql(true); + fs.rmdirSync(dirName); + should(fs.existsSync(dirName)).eql(false); + }); + + it('throws trying to delete directory that is NOT empty', () => { + const dirName = path.join(Ti.Filesystem.tempDirectory, `rmdirSync_2_${Date.now()}`); + fs.mkdirSync(dirName); + should(fs.existsSync(dirName)).eql(true); + const file = path.join(dirName, 'myfile.txt'); + fs.writeFileSync(file, 'Hello World!'); + should(fs.existsSync(file)).eql(true); // FFIXME: Fails on Android + + try { + fs.rmdirSync(dirName); + should.fail(true, false, 'expected fs.rmdirSync to throw Error when deleting non-empty directory'); + } catch (error) { + error.name.should.eql('Error'); + error.message.should.eql(`ENOTEMPTY: directory not empty, rmdir '${dirName}'`); + error.errno.should.eql(-66); + error.syscall.should.eql('rmdir'); + error.code.should.eql('ENOTEMPTY'); + error.path.should.eql(dirName); + } + should(fs.existsSync(dirName)).eql(true); + }); + + it('throws trying to remove file instead of directory', () => { + try { + fs.rmdirSync(thisFilePath); + should.fail(true, false, 'expected fs.rmdirSync to throw Error when deleting file'); + } catch (error) { + error.name.should.eql('Error'); + error.message.should.eql(`ENOTDIR: not a directory, rmdir '${thisFilePath}'`); + error.errno.should.eql(-20); + error.syscall.should.eql('rmdir'); + error.code.should.eql('ENOTDIR'); + error.path.should.eql(thisFilePath); + } + }); + + it('throws trying to remove non-existent directory', () => { + const dirName = '/made/up/path'; + should(fs.existsSync(dirName)).eql(false); + + try { + fs.rmdirSync(dirName); + should.fail(true, false, 'expected fs.rmdirSync to throw Error when deleting dir that does not exist'); + } catch (error) { + error.name.should.eql('Error'); + error.message.should.eql(`ENOENT: no such file or directory, rmdir '${dirName}'`); + error.errno.should.eql(-2); + error.syscall.should.eql('rmdir'); + error.code.should.eql('ENOENT'); + error.path.should.eql(dirName); + } + }); + }); + + describe('#stat()', () => { + it('is a function', () => { + should(fs.stat).be.a.Function; + }); + + it('returns stats for this file', finished => { + fs.stat(thisFilePath, (err, stats) => { + try { + should(stats).be.ok; + should(stats).be.an.Object; + + // TODO: Verify some of the values? + finished(); + } catch (e) { + finished(e); + } + }); + }); + }); + + describe('#statSync()', () => { + it('is a function', () => { + should(fs.statSync).be.a.Function; + }); + + it('returns stats for this file', () => { + const stats = fs.statSync(thisFilePath); + should(stats).be.ok; + should(stats).be.an.Object; + + stats.size.should.be.above(0); + stats.blocks.should.be.above(0); + + // check ctime and mtime versus Ti.Filesystem.File modifiedAt/createdAt? + stats.ctime.should.eql(thisFile.createdAt()); + stats.mtime.should.eql(thisFile.modifiedAt()); + + stats.isFile().should.eql(true); + stats.isDirectory().should.eql(false); + // TODO Verify isSocket()/isCharacterDevice()/isBlockDevice()/isFIFO()/isSymbolicLink()? + }); + }); + + describe('#truncate()', () => { + it('is a function', () => { + should(fs.truncate).be.a.Function; + }); + + it('truncates to 0 bytes by default', finished => { + const dest = Ti.Filesystem.tempDirectory + `truncate_${Date.now()}.js`; + fs.copyFileSync(thisFilePath, dest); + fs.truncate(dest, err => { + try { + should(err).not.exist; + fs.readFileSync(dest, 'utf8').should.eql(''); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + + it('truncates to specified number of bytes', finished => { + const dest = Ti.Filesystem.tempDirectory + `truncate_bytes_${Date.now()}.js`; + fs.copyFileSync(thisFilePath, dest); + fs.truncate(dest, 16384, err => { + try { + should(err).not.exist; + const buffer = fs.readFileSync(dest); + buffer.length.should.eql(16384); + // TODO: Compare contents somehow? + } catch (e) { + return finished(e); + } + finished(); + }); + }); + }); + + describe('#truncateSync()', () => { + it('is a function', () => { + should(fs.truncateSync).be.a.Function; + }); + + it('truncates to 0 bytes by default', () => { + const dest = Ti.Filesystem.tempDirectory + `truncateSync_${Date.now()}.js`; + fs.copyFileSync(thisFilePath, dest); + fs.truncateSync(dest); + fs.readFileSync(dest, 'utf8').should.eql(''); + }); + + it('truncates to specified number of bytes', () => { + const dest = Ti.Filesystem.tempDirectory + `truncateSync_bytes_${Date.now()}.js`; + fs.copyFileSync(thisFilePath, dest); + fs.truncateSync(dest, 16384); + const buffer = fs.readFileSync(dest); + buffer.length.should.eql(16384); + // TODO: Compare contents somehow? + }); + }); + + describe('#unlink()', () => { + it('is a function', () => { + should(fs.unlink).be.a.Function; + }); + + it('deletes a file', finished => { + const filename = path.join(Ti.Filesystem.tempDirectory, `unlink${Date.now()}.txt`); + fs.writeFileSync(filename, 'Hello World!'); + // file should now exist + should(fs.existsSync(filename)).eql(true); + // delete it + fs.unlink(filename, err => { + try { + should(err).not.exist; + // no longer should exist + should(fs.existsSync(filename)).eql(false); + finished(); + } catch (e) { + return finished(e); + } + }); + }); + + it('throws trying to delete a directory', finished => { + const dir = Ti.Filesystem.tempDirectory; + // dir should exist + should(fs.existsSync(dir)).eql(true); + // try to delete it + fs.unlink(dir, error => { + try { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`EISDIR: illegal operation on a directory, unlink '${dir}'`); + error.code.should.eql('EISDIR'); + error.errno.should.eql(-21); + error.syscall.should.eql('unlink'); + error.path.should.eql(dir); + // should still exist + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + + it('throws trying to delete a non-existent path', finished => { + const dir = '/made/up/path'; + // dir should not exist + should(fs.existsSync(dir)).eql(false); + // try to delete it + fs.unlink(dir, error => { + try { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`ENOENT: no such file or directory, unlink '${dir}'`); + error.code.should.eql('ENOENT'); + error.errno.should.eql(-2); + error.syscall.should.eql('unlink'); + error.path.should.eql(dir); + } catch (e) { + return finished(e); + } + finished(); + }); + }); + + // TODO: Try to delete a file/dir that we don't have permissions on + }); + + describe('#unlinkSync()', () => { + it('is a function', () => { + should(fs.unlinkSync).be.a.Function; + }); + + it('deletes a file', () => { + const filename = path.join(Ti.Filesystem.tempDirectory, `unlinkSync${Date.now()}.txt`); + fs.writeFileSync(filename, 'Hello World!'); + // file should now exist + should(fs.existsSync(filename)).eql(true); + // delete it + fs.unlinkSync(filename); + // no longer should exist + should(fs.existsSync(filename)).eql(false); + }); + + it('throws trying to delete a directory', () => { + const dir = Ti.Filesystem.tempDirectory; + // dir should exist + should(fs.existsSync(dir)).eql(true); + // try to delete it + try { + fs.unlinkSync(dir); + should.fail(true, false, 'expected fs.unlinkSync to throw Error when deleting an existing directory\'s path'); + } catch (error) { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`EISDIR: illegal operation on a directory, unlink '${dir}'`); + error.code.should.eql('EISDIR'); + error.errno.should.eql(-21); + error.syscall.should.eql('unlink'); + error.path.should.eql(dir); + } + // should still exist + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + }); + + it('throws trying to delete a non-existent path', () => { + const dir = '/made/up/path'; + // dir should not exist + should(fs.existsSync(dir)).eql(false); + // try to delete it + try { + fs.unlinkSync(dir); + should.fail(true, false, 'expected fs.unlinkSync to throw Error when deleting a non-existent path'); + } catch (error) { + should(error).exist; + error.name.should.eql('Error'); + error.message.should.eql(`ENOENT: no such file or directory, unlink '${dir}'`); + error.code.should.eql('ENOENT'); + error.errno.should.eql(-2); + error.syscall.should.eql('unlink'); + error.path.should.eql(dir); + } + }); + + // TODO: Try to delete a file/dir that we don't have permissions on + }); + + describe('#write()', () => { + it('is a function', () => { + should(fs.write).be.a.Function; + }); + + it('writes a string to a file descriptor', finish => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeString${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + const fd = fs.openSync(filename, 'w'); + const contents = 'Hello write with a string!'; + fs.write(fd, contents, (err, bytes, string) => { + try { + should(err).not.exist; + // file should now exist + should(fs.existsSync(filename)).eql(true); + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents); + string.should.eql(contents); // callback should get the contents we wrote + } catch (e) { + return finish(e); + } finally { + fs.closeSync(fd); + } + finish(); + }); + }); + + it('writes a Buffer to a file descriptor', finish => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeBuffer${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + const fd = fs.openSync(filename, 'w'); + const buffer = Buffer.from('Hello write with a Buffer!'); + fs.write(fd, buffer, (err, bytes, bufferFromCallback) => { + try { + should(err).not.exist; + // file should now exist + should(fs.existsSync(filename)).eql(true); + bufferFromCallback.should.eql(buffer); // callback should get the contents we wrote + } catch (e) { + return finish(e); + } finally { + fs.closeSync(fd); + } + finish(); + }); + }); + + // TODO: Test with range of a Buffer! + }); + + describe('#writeSync()', () => { + it('is a function', () => { + should(fs.writeSync).be.a.Function; + }); + + it('writes a string to a file descriptor', () => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeSyncString${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + const fd = fs.openSync(filename, 'w'); + const contents = 'Hello write with a string!'; + try { + const bytesWritten = fs.writeSync(fd, contents); + bytesWritten.should.be.above(0); + // file should now exist + should(fs.existsSync(filename)).eql(true); + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents); + } finally { + fs.closeSync(fd); + } + }); + + it('writes a Buffer to a file descriptor', () => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeSyncBuffer${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + const fd = fs.openSync(filename, 'w'); + const buffer = Buffer.from('Hello write with a Buffer!'); + try { + const bytesWritten = fs.writeSync(fd, buffer); + bytesWritten.should.be.above(0); + // file should now exist + should(fs.existsSync(filename)).eql(true); + // contents should match what we wrote + should(fs.readFileSync(filename)).eql(buffer); + + } finally { + fs.closeSync(fd); + } + }); + + // TODO: Test with range of a Buffer! + }); + + describe('#writeFile()', () => { + it('is a function', () => { + should(fs.writeFile).be.a.Function; + }); + + it('writes a string to a non-existent file', finish => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeFile${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + // ensure file does not + should(fs.existsSync(filename)).eql(false); + const contents = 'Hello World!'; + fs.writeFile(filename, contents, err => { + try { + should(err).not.exist; + // file should now exist + should(fs.existsSync(filename)).eql(true); + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents); + } catch (e) { + return finish(e); + } + finish(); + }); + }); + + it('writes a string to existing file, replaces it', finish => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeFile${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + // ensure file does not + should(fs.existsSync(filename)).eql(false); + const contents = 'Hello World!'; + fs.writeFile(filename, contents, err => { + try { + should(err).not.exist; + + // file should now exist + should(fs.existsSync(filename)).eql(true); // FIXME: fails on Android + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents); + + // Now replace it's contents by writing again + const contents2 = 'I replaced you!'; + fs.writeFile(filename, contents2, err2 => { + try { + should(err2).not.exist; + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents2); + } catch (e) { + return finish(e); + } + finish(); + }); + } catch (e) { + return finish(e); + } + }); + }); + + it('throws if trying to write to path of an existing directory', finish => { + const dirname = path.join(Ti.Filesystem.tempDirectory, `writeFile_d_${Date.now()}`); + fs.mkdirSync(dirname); + // ensure dir exists + should(fs.existsSync(dirname)).eql(true); + + fs.writeFile(dirname, 'Hello World!', error => { + try { + should(error).exist; + // verify error + error.should.have.properties({ + name: 'Error', + message: `EISDIR: illegal operation on a directory, open '${dirname}'`, + errno: -21, + syscall: 'open', + code: 'EISDIR', + path: dirname + }); + } catch (e) { + return finish(e); + } + finish(); + }); + }); + + // TODO: What if parent dir does not exist? + // TODO: what if target path exists but is a directory? + // TODO: What if data is a Buffer? + }); + + describe('#writeFileSync()', () => { + it('is a function', () => { + should(fs.writeFileSync).be.a.Function; + }); + + it('writes a string to a non-existent file', () => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeFileSync${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + // ensure file does not + should(fs.existsSync(filename)).eql(false); + const contents = 'Hello World!'; + fs.writeFileSync(filename, contents); + // file should now exist + should(fs.existsSync(filename)).eql(true); + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents); + }); + + it('writes undefined to file is no data value passed', () => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeFileSync_undefined_${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + // ensure file does not + should(fs.existsSync(filename)).eql(false); + fs.writeFileSync(filename); + // file should now exist + should(fs.existsSync(filename)).eql(true); + // contents should match literal 'undefined' + should(fs.readFileSync(filename, 'utf-8')).eql('undefined'); + }); + + it('writes a string to existing file, replaces it', () => { + const filename = path.join(Ti.Filesystem.tempDirectory, `writeFileSync${Date.now()}.txt`); + // ensure parent dir exists + should(fs.existsSync(Ti.Filesystem.tempDirectory)).eql(true); + // ensure file does not + should(fs.existsSync(filename)).eql(false); + const contents = 'Hello World!'; + fs.writeFileSync(filename, contents); + // file should now exist + should(fs.existsSync(filename)).eql(true); // FIXME: fails on Android + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents); + + // Now replace it's contents by writing again + const contents2 = 'I replaced you!'; + fs.writeFileSync(filename, contents2); + // contents should match what we wrote + should(fs.readFileSync(filename, 'utf-8')).eql(contents2); + }); + + it('throws if trying to write to path of an existing directory', () => { + const dirname = path.join(Ti.Filesystem.tempDirectory, `writeFileSync_d_${Date.now()}`); + fs.mkdirSync(dirname); + // ensure dir exists + should(fs.existsSync(dirname)).eql(true); + + try { + fs.writeFileSync(dirname, 'Hello World!'); + should.fail(true, false, 'expected fs.writeFileSync to throw Error when writing to existing directory\'s path'); + } catch (error) { + // verify error + error.should.have.properties({ + name: 'Error', + message: `EISDIR: illegal operation on a directory, open '${dirname}'`, + errno: -21, + syscall: 'open', + code: 'EISDIR', + path: dirname + }); + } + }); + + // TODO: What if parent dir does not exist? + // TODO: what if target path exists but is a directory? + // TODO: What if data is a Buffer? + }); + + // No-op stubs ////////////////////////// + // TODO: Verify the async ones call the callback! + + describe('#chmod()', () => { + it('is a function', () => { + should(fs.chmod).be.a.Function; + }); + }); + + describe('#chmodSync()', () => { + it('is a function', () => { + should(fs.chmodSync).be.a.Function; + }); + }); + + describe('#chown()', () => { + it('is a function', () => { + should(fs.chown).be.a.Function; + }); + }); + + describe('#chownSync()', () => { + it('is a function', () => { + should(fs.chownSync).be.a.Function; + }); + }); + + describe('#fdatasync()', () => { + it('is a function', () => { + should(fs.fdatasync).be.a.Function; + }); + }); + + describe('#fdatasyncSync()', () => { + it('is a function', () => { + should(fs.fdatasyncSync).be.a.Function; + }); + }); + + describe('#unwatchFile()', () => { + it('is a function', () => { + should(fs.unwatchFile).be.a.Function; + }); + }); + + describe('#watchFile()', () => { + it('is a function', () => { + should(fs.watchFile).be.a.Function; + }); + }); +});