├── .nvmrc ├── .npmignore ├── .gitignore ├── src ├── parser │ ├── index.js │ ├── option-parser.js │ └── command-parser.js ├── emulator-output │ ├── index.js │ ├── output-type.js │ └── output-factory.js ├── fs │ ├── index.js │ ├── util │ │ ├── glob-util.js │ │ ├── file-util.js │ │ ├── permission-util.js │ │ └── path-util.js │ ├── fs-error.js │ ├── operations-with-permissions │ │ ├── file-operations.js │ │ └── directory-operations.js │ └── operations │ │ ├── base-operations.js │ │ └── file-operations.js ├── commands │ ├── clear.js │ ├── pwd.js │ ├── whoami.js │ ├── index.js │ ├── util │ │ └── _head_tail_util.js │ ├── history.js │ ├── rmdir.js │ ├── echo.js │ ├── mkdir.js │ ├── head.js │ ├── cat.js │ ├── tail.js │ ├── touch.js │ ├── printenv.js │ ├── cd.js │ ├── rm.js │ ├── ls.js │ └── cp.js ├── emulator-state │ ├── index.js │ ├── file-system.js │ ├── util.js │ ├── history.js │ ├── outputs.js │ ├── environment-variables.js │ ├── EmulatorState.js │ └── command-mapping.js ├── emulator │ ├── emulator-error.js │ ├── plugins │ │ ├── BoundedHistoryIterator.js │ │ └── HistoryKeyboardPlugin.js │ ├── command-runner.js │ ├── auto-complete.js │ └── index.js └── index.js ├── .editorconfig ├── .babelrc ├── test ├── commands │ ├── test-helper.js │ ├── mapping │ │ └── index.spec.js │ ├── pwd.spec.js │ ├── clear.spec.js │ ├── whoami.spec.js │ ├── history.spec.js │ ├── printenv.spec.js │ ├── echo.spec.js │ ├── mkdir.spec.js │ ├── touch.spec.js │ ├── head.spec.js │ ├── tail.spec.js │ ├── rmdir.spec.js │ ├── cd.spec.js │ ├── cat.spec.js │ ├── ls.spec.js │ └── rm.spec.js ├── _plugins │ └── state-equality-plugin.js ├── os │ ├── fs-error.spec.js │ ├── mocks │ │ ├── mock-fs.js │ │ └── mock-fs-permissions.js │ ├── util │ │ ├── permission-util.spec.js │ │ ├── file-util.spec.js │ │ └── glob-util.spec.js │ ├── operations │ │ ├── base-operations.spec.js │ │ └── file-operations.spec.js │ └── operations-with-permissions │ │ ├── file-operations.spec.js │ │ └── directory-operations.spec.js ├── emulator │ ├── emulator-error.spec.js │ ├── command-runner.spec.js │ ├── plugins │ │ ├── BoundedHistoryIterator.spec.js │ │ └── history-keyboard.spec.js │ ├── auto-complete.spec.js │ └── emulator.spec.js ├── parser │ ├── opt-parser.spec.js │ └── command-parser.spec.js ├── library.spec.js ├── emulator-state │ ├── file-system.spec.js │ ├── history.spec.js │ ├── outputs.spec.js │ ├── EmulatorState.spec.js │ ├── environment-variables.spec.js │ └── command-mapping.spec.js └── emulator-output │ └── output-factory.spec.js ├── CHANGELOG.md ├── demo-cli ├── index.js ├── cli-evaluator.spec.js └── cli-evaluator.js ├── demo-web ├── index.html ├── css │ ├── main.css │ └── normalize.css └── js │ └── main.js ├── .gitlab-ci.yml ├── LICENSE ├── webpack.config.js ├── package.json └── .eslintrc /.nvmrc: -------------------------------------------------------------------------------- 1 | v6.10 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | .nyc_output 4 | *.log 5 | -------------------------------------------------------------------------------- /src/parser/index.js: -------------------------------------------------------------------------------- 1 | import * as OptionParser from 'parser/option-parser'; 2 | 3 | export default { 4 | OptionParser 5 | }; 6 | -------------------------------------------------------------------------------- /src/emulator-output/index.js: -------------------------------------------------------------------------------- 1 | import * as OutputFactory from 'emulator-output/output-factory'; 2 | import * as OutputType from 'emulator-output/output-type'; 3 | 4 | export default { 5 | OutputFactory, OutputType 6 | }; 7 | -------------------------------------------------------------------------------- /src/fs/index.js: -------------------------------------------------------------------------------- 1 | import * as DirOp from './operations-with-permissions/directory-operations'; 2 | import * as FileOp from './operations-with-permissions/file-operations'; 3 | 4 | export default { 5 | DirOp, 6 | FileOp 7 | }; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = LF 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "modules": "commonjs" 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | "add-module-exports", 12 | "@babel/plugin-proposal-object-rest-spread", 13 | ] 14 | } -------------------------------------------------------------------------------- /test/commands/test-helper.js: -------------------------------------------------------------------------------- 1 | import EmulatorState from 'emulator-state/EmulatorState'; 2 | import { create as createFileSystem } from 'emulator-state/file-system'; 3 | 4 | export const makeFileSystemTestState = (jsFS) => EmulatorState.create({ 5 | fs: createFileSystem(jsFS) 6 | }); 7 | -------------------------------------------------------------------------------- /src/emulator-output/output-type.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Types of output which can be used to display content to the user 3 | * @type {String} 4 | */ 5 | export const TEXT_OUTPUT_TYPE = 'TEXT_OUTPUT'; 6 | export const TEXT_ERROR_OUTPUT_TYPE = 'TEXT_ERROR_OUTPUT'; 7 | export const HEADER_OUTPUT_TYPE = 'HEADER_OUTPUT_TYPE'; 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.1 2 | * Add ability to add custom error message on missing command 3 | 4 | ## 1.0.3 5 | * Update dependencies, including migration to Babel 7 6 | 7 | ## 1.0.2 8 | * Fix autocomplete of single folder 9 | 10 | ## 1.0.1 11 | * Fix NPM entry-point 12 | 13 | ## 1.0.0 14 | * Initial release 15 | -------------------------------------------------------------------------------- /src/commands/clear.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes all terminal output 3 | * Usage: clear 4 | */ 5 | import { create as createOutputs } from 'emulator-state/outputs'; 6 | 7 | export const optDef = {}; 8 | 9 | export default (state, commandOptions) => { 10 | return { 11 | state: state.setOutputs(createOutputs()) 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /demo-cli/index.js: -------------------------------------------------------------------------------- 1 | const repl = require('repl'); 2 | const getTerminalEvaluator = require('./cli-evaluator'); 3 | 4 | const replEvaluator = getTerminalEvaluator(); 5 | 6 | return repl.start({ 7 | prompt: 'emulator$ ', 8 | eval: (cmd, context, filename, callback) => { 9 | const outputStr = replEvaluator(cmd); 10 | 11 | console.log(outputStr); 12 | 13 | callback(); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /test/_plugins/state-equality-plugin.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | 4 | chai.use(chaiImmutable); 5 | 6 | chai.use((_chai, utils) => { 7 | const { Assertion } = _chai; 8 | 9 | Assertion.addMethod('toEqualState', function (value) { 10 | const obj = this._obj; 11 | 12 | new Assertion(obj.getImmutable()).to.equal(value.getImmutable()); 13 | }); 14 | }); 15 | 16 | export default chai; 17 | -------------------------------------------------------------------------------- /src/commands/pwd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints out the current working directory (cwd). 3 | * Usage: pwd 4 | */ 5 | import * as OutputFactory from 'emulator-output/output-factory'; 6 | import { getEnvironmentVariable } from 'emulator-state/environment-variables'; 7 | 8 | export const optDef = {}; 9 | 10 | export default (state, commandOptions) => { 11 | return { 12 | output: OutputFactory.makeTextOutput( 13 | getEnvironmentVariable(state.getEnvVariables(), 'cwd') 14 | ) 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/emulator-state/index.js: -------------------------------------------------------------------------------- 1 | import * as CommandMapping from 'emulator-state/command-mapping'; 2 | import * as EnvironmentVariables from 'emulator-state/environment-variables'; 3 | import * as FileSystem from 'emulator-state/file-system'; 4 | import * as History from 'emulator-state/history'; 5 | import * as Outputs from 'emulator-state/outputs'; 6 | import EmulatorState from 'emulator-state/EmulatorState'; 7 | 8 | export default { 9 | EmulatorState, 10 | CommandMapping, 11 | EnvironmentVariables, 12 | FileSystem, 13 | History, 14 | Outputs 15 | }; 16 | -------------------------------------------------------------------------------- /src/parser/option-parser.js: -------------------------------------------------------------------------------- 1 | import getOpts from 'get-options'; 2 | 3 | /** 4 | * Creates an options object with bindings based on optDefs 5 | * @param {string} commandOptions string representation of command arguments 6 | * @param {object} optDef see get-options documentation for schema details 7 | * @return {object} options object 8 | */ 9 | export const parseOptions = (commandOptions, optDef) => 10 | getOpts(commandOptions, optDef, { 11 | noAliasPropagation: 'first-only' 12 | }); 13 | 14 | export default parseOptions; 15 | -------------------------------------------------------------------------------- /src/commands/whoami.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints the username of the logged in user 3 | * Usage: whoami 4 | */ 5 | import * as OutputFactory from 'emulator-output/output-factory'; 6 | import { getEnvironmentVariable } from 'emulator-state/environment-variables'; 7 | 8 | const FALLBACK_USERNAME = 'root'; 9 | 10 | export const optDef = {}; 11 | 12 | export default (state, commandOptions) => { 13 | return { 14 | output: OutputFactory.makeTextOutput( 15 | getEnvironmentVariable(state.getEnvVariables(), 'user') || FALLBACK_USERNAME 16 | ) 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/commands/index.js: -------------------------------------------------------------------------------- 1 | export const commandNames = [ 2 | 'cat', 3 | 'cd', 4 | 'clear', 5 | 'cp', 6 | 'echo', 7 | 'head', 8 | 'history', 9 | 'ls', 10 | 'mkdir', 11 | 'printenv', 12 | 'pwd', 13 | 'rm', 14 | 'rmdir', 15 | 'tail', 16 | 'touch', 17 | 'whoami' 18 | ]; 19 | 20 | export default commandNames.reduce((mapping, commandName) => { 21 | return { 22 | ...mapping, 23 | [commandName]: { 24 | function: require(`commands/${commandName}`).default, 25 | optDef: require(`commands/${commandName}`).optDef 26 | } 27 | }; 28 | }, {}); 29 | -------------------------------------------------------------------------------- /src/emulator-state/file-system.js: -------------------------------------------------------------------------------- 1 | import * as FileUtil from 'fs/util/file-util'; 2 | import * as DirOp from 'fs/operations/directory-operations'; 3 | import { fromJS } from 'immutable'; 4 | 5 | const DEFAULT_FILE_SYSTEM = { 6 | '/': FileUtil.makeDirectory() 7 | }; 8 | 9 | /** 10 | * Creates an immutable data structure for a file system 11 | * @param {object} jsFs a file system in a simple JavaScript object 12 | * @return {Map} an immutable file system 13 | */ 14 | export const create = (jsFs = DEFAULT_FILE_SYSTEM) => { 15 | return DirOp.fillGaps(fromJS(jsFs)); 16 | }; 17 | -------------------------------------------------------------------------------- /test/commands/mapping/index.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import commandMapping, { commandNames } from 'commands'; 6 | 7 | describe('commands', () => { 8 | describe('default command mapping', () => { 9 | it('should have all command functions', () => { 10 | for (const commandName of commandNames) { 11 | const cmd = commandMapping[commandName]; 12 | 13 | chai.assert.isFunction(cmd.function); 14 | chai.assert.isObject(cmd.optDef); 15 | } 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /demo-web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Terminal Emulator 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/emulator-state/util.js: -------------------------------------------------------------------------------- 1 | import * as EnvVariableUtil from 'emulator-state/environment-variables'; 2 | import * as PathUtil from 'fs/util/path-util'; 3 | 4 | /** 5 | * Converts a given path to an absolute path using the 6 | * current working directory 7 | * @param {EmulatorState} state emulator state 8 | * @param {string} path path (relative or absolute) 9 | * @return {string} absolute path 10 | */ 11 | export const resolvePath = (state, path) => { 12 | const cwd = EnvVariableUtil.getEnvironmentVariable( 13 | state.getEnvVariables(), 'cwd' 14 | ); 15 | 16 | return PathUtil.toAbsolutePath(path, cwd); 17 | }; 18 | -------------------------------------------------------------------------------- /test/commands/pwd.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 7 | import pwd from 'commands/pwd'; 8 | 9 | describe('pwd', () => { 10 | it('should print the working directory', () => { 11 | const state = EmulatorState.create({ 12 | environmentVariables: createEnvironmentVariables({}, '/dir') 13 | }); 14 | 15 | const {output} = pwd(state, []); 16 | 17 | chai.expect(output.content).to.equal('/dir'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/commands/util/_head_tail_util.js: -------------------------------------------------------------------------------- 1 | import * as FileOp from 'fs/operations-with-permissions/file-operations'; 2 | import * as OutputFactory from 'emulator-output/output-factory'; 3 | 4 | const DEFAULT_LINE_COUNT = 10; 5 | 6 | export const trimFileContent = (fs, filePath, options, trimmingFn) => { 7 | const {file, err} = FileOp.readFile(fs, filePath); 8 | 9 | if (err) { 10 | return { 11 | err: OutputFactory.makeErrorOutput(err) 12 | }; 13 | }; 14 | 15 | const linesCount = options.lines ? Number(options.lines) : DEFAULT_LINE_COUNT; 16 | const trimmedLines = trimmingFn(file.get('content').split('\n'), linesCount); 17 | 18 | return { 19 | content: trimmedLines.join('\n') 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/emulator/emulator-error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emulator error type 3 | * @type {Object} 4 | */ 5 | export const emulatorErrorType = { 6 | COMMAND_NOT_FOUND: 'Command not found', 7 | UNEXPECTED_COMMAND_FAILURE: 'Unhandled command error' 8 | }; 9 | 10 | /** 11 | * Creates an error to display to the user originating from the emulator 12 | * @param {string} emulatorErrorType file system error type 13 | * @param {string} [message=''] optional metadata for developers about the error 14 | * @return {object} internal error object 15 | */ 16 | export const makeError = (emulatorErrorType, message = '') => { 17 | return { 18 | source: 'emulator', 19 | type: emulatorErrorType, 20 | message 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /test/commands/clear.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import EmulatorState from 'emulator-state/EmulatorState'; 4 | import { create as createHistory } from 'emulator-state/history'; 5 | import * as OutputFactory from 'emulator-output/output-factory'; 6 | import clear from 'commands/clear'; 7 | 8 | describe('clear', () => { 9 | it('should clear outputs', () => { 10 | const stateWithOutputs = EmulatorState.create({ 11 | clear: createHistory(OutputFactory.makeTextOutput('a')) 12 | }); 13 | 14 | const {state: actualState} = clear(stateWithOutputs, []); 15 | const expectedState = EmulatorState.createEmpty(); 16 | 17 | chai.expect(actualState).toEqualState(expectedState); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/os/fs-error.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import {fsErrorType, makeError} from 'fs/fs-error'; 4 | 5 | describe('fs-error', () => { 6 | it('should create error object with selected FS error type', () => { 7 | const fsError = makeError(fsErrorType.NO_SUCH_FILE); 8 | 9 | chai.expect(fsError).to.deep.equal({ 10 | source: 'fs', 11 | type: fsErrorType.NO_SUCH_FILE, 12 | message: '' 13 | }); 14 | }); 15 | 16 | it('should create error object with error message', () => { 17 | const fsError = makeError(fsErrorType.IS_A_DIRECTORY, 'my message'); 18 | 19 | chai.expect(fsError).to.deep.equal({ 20 | source: 'fs', 21 | type: fsErrorType.IS_A_DIRECTORY, 22 | message: 'my message' 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/emulator-state/history.js: -------------------------------------------------------------------------------- 1 | import { Stack } from 'immutable'; 2 | 3 | /** 4 | * Creates a new history stack of previous commands that have been run in the 5 | * emulator 6 | * @param {array} [entries=[]] commands which have already been run (if any) 7 | * @return {Stack} history list 8 | */ 9 | export const create = (entries = []) => { 10 | return Stack.of(...entries); 11 | }; 12 | 13 | /** 14 | * Stores a command in history in a stack (i.e., the latest command is on top of 15 | * the history stack) 16 | * @param {Stack} history history 17 | * @param {string} commandRun the command to store 18 | * @return {Stack} history 19 | */ 20 | export const recordCommand = (history, commandRun) => { 21 | return history.push(commandRun); 22 | }; 23 | -------------------------------------------------------------------------------- /demo-web/css/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | box-sizing: border-box; 3 | color: white; 4 | font-size: 1.25em; 5 | } 6 | 7 | *, *:before, *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | body { 12 | padding: 0.5%; 13 | } 14 | 15 | body, #input { 16 | background: #171e1d; 17 | font-family: monospace; 18 | } 19 | 20 | /* Input */ 21 | .input-wrapper { 22 | display: flex; 23 | } 24 | 25 | #input, .input-wrapper { 26 | color: #53eb9b; 27 | } 28 | 29 | #input { 30 | flex: 2; 31 | border: none; 32 | } 33 | 34 | #input:focus { 35 | outline: none; 36 | } 37 | 38 | /* Output */ 39 | #output-wrapper div { 40 | display: inline-block; 41 | width: 100%; 42 | } 43 | 44 | .header-output { 45 | color: #9d9d9d; 46 | } 47 | 48 | .error-output { 49 | color: #ff4e4e; 50 | } 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Emulator from 'emulator'; 2 | import HistoryKeyboardPlugin from 'emulator/plugins/HistoryKeyboardPlugin'; 3 | import { EmulatorState, CommandMapping, EnvironmentVariables, FileSystem, History, Outputs } from 'emulator-state'; 4 | import { OutputFactory, OutputType } from 'emulator-output'; 5 | import { DirOp, FileOp } from 'fs'; 6 | import { OptionParser } from 'parser'; 7 | import defaultCommandMapping from 'commands'; 8 | 9 | // Any class/function exported here forms part of the emulator API 10 | export { 11 | Emulator, HistoryKeyboardPlugin, 12 | defaultCommandMapping, 13 | EmulatorState, CommandMapping, EnvironmentVariables, FileSystem, History, Outputs, // state API 14 | OutputFactory, OutputType, // output API 15 | DirOp, FileOp, // file system API 16 | OptionParser // parser API 17 | }; 18 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:latest 2 | 3 | stages: 4 | - build 5 | - test 6 | - deploy 7 | 8 | before_script: 9 | - yarn install 10 | 11 | # Build stage 12 | build: 13 | script: 14 | - yarn build 15 | artifacts: 16 | paths: 17 | - lib/ 18 | 19 | # Test stage 20 | test: 21 | script: 22 | - yarn build 23 | - yarn test:coverage 24 | artifacts: 25 | paths: 26 | - coverage/ 27 | 28 | # Deploy stage 29 | pages: 30 | stage: deploy 31 | script: 32 | - mkdir public 33 | # Test coverage 34 | - yarn run artifact:test-coverage 35 | - mv coverage/ public/coverage 36 | # Library 37 | - mv lib/ public/lib 38 | # Web demo 39 | - mv demo-web/ public/demo 40 | artifacts: 41 | paths: 42 | - public 43 | expire_in: 30 days 44 | only: 45 | - master 46 | -------------------------------------------------------------------------------- /src/emulator/plugins/BoundedHistoryIterator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes a stack iterator for a point in history. 3 | * 4 | * Can go backwards and forwards through the history and is bounded by 5 | * the size of the stack. 6 | */ 7 | export default class BoundedHistoryIterator { 8 | constructor(historyStack, index = 0) { 9 | this.historyStack = historyStack.push(''); 10 | this.index = index; 11 | } 12 | 13 | hasUp() { 14 | return this.index + 1 < this.historyStack.size; 15 | } 16 | 17 | up() { 18 | if (this.hasUp()) { 19 | this.index++; 20 | } 21 | 22 | return this.historyStack.get(this.index); 23 | } 24 | 25 | hasDown() { 26 | return this.index - 1 >= 0; 27 | } 28 | 29 | down() { 30 | if (this.hasDown()) { 31 | this.index--; 32 | } 33 | 34 | return this.historyStack.get(this.index); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/commands/history.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Lists or clears commands executed in the terminal 3 | * Usage: history -c 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as OutputFactory from 'emulator-output/output-factory'; 7 | import { create as createHistory } from 'emulator-state/history'; 8 | 9 | const clearStateHistory = (state) => 10 | state.setHistory(createHistory()); 11 | 12 | const stringifyStateHistory = (state) => 13 | state.getHistory().join('\n'); 14 | 15 | export const optDef = { 16 | '-c, --clear': '' // remove history entries 17 | }; 18 | 19 | export default (state, commandOptions) => { 20 | const {options} = parseOptions(commandOptions, optDef); 21 | 22 | if (options.clear) { 23 | return { 24 | state: clearStateHistory(state) 25 | }; 26 | }; 27 | 28 | return { 29 | output: OutputFactory.makeTextOutput(stringifyStateHistory(state)) 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /test/emulator/emulator-error.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import {emulatorErrorType, makeError} from 'emulator/emulator-error'; 4 | 5 | describe('emulator/emulator-error', () => { 6 | it('should create error object with selected emulator error type', () => { 7 | const emulatorError = makeError(emulatorErrorType.COMMAND_NOT_FOUND); 8 | 9 | chai.expect(emulatorError).to.deep.equal({ 10 | source: 'emulator', 11 | type: emulatorErrorType.COMMAND_NOT_FOUND, 12 | message: '' 13 | }); 14 | }); 15 | 16 | it('should create error object with error message', () => { 17 | const emulatorError = makeError(emulatorErrorType.UNEXPECTED_COMMAND_FAILURE, 'my message'); 18 | 19 | chai.expect(emulatorError).to.deep.equal({ 20 | source: 'emulator', 21 | type: emulatorErrorType.UNEXPECTED_COMMAND_FAILURE, 22 | message: 'my message' 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/commands/rmdir.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes an empty directory 3 | * Usage: rmdir /emptyDir 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as DirOp from 'fs/operations-with-permissions/directory-operations'; 7 | import * as OutputFactory from 'emulator-output/output-factory'; 8 | import { resolvePath } from 'emulator-state/util'; 9 | 10 | export const optDef = {}; 11 | 12 | export default (state, commandOptions) => { 13 | const {argv} = parseOptions(commandOptions, optDef); 14 | 15 | if (argv.length === 0) { 16 | return {}; // do nothing if no arguments are given 17 | } 18 | 19 | const pathToDelete = resolvePath(state, argv[0]); 20 | const {fs, err} = DirOp.deleteDirectory(state.getFileSystem(), pathToDelete, false); 21 | 22 | if (err) { 23 | return { 24 | output: OutputFactory.makeErrorOutput(err) 25 | }; 26 | } 27 | 28 | return { 29 | state: state.setFileSystem(fs) 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /demo-cli/cli-evaluator.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | const getTerminalEvaluator = require('./cli-evaluator'); 3 | 4 | chai.expect(); 5 | 6 | let evaluator = getTerminalEvaluator(); 7 | 8 | describe('cli-evaluator', () => { 9 | it('should evaluate command without string output', () => { 10 | const output = evaluator('mkdir a'); 11 | 12 | chai.expect(output).to.equal(''); 13 | }); 14 | 15 | it('should evaluate command with string output', () => { 16 | const output = evaluator('echo hello world'); 17 | 18 | chai.expect(output).to.equal('hello world'); 19 | }); 20 | 21 | it('should evaluate multiple commands', () => { 22 | evaluator('mkdir testFolder'); 23 | evaluator('cd testFolder'); 24 | evaluator('mkdir foo'); 25 | evaluator('mkdir baz'); 26 | evaluator('mkdir bar'); 27 | evaluator('rmdir bar'); 28 | const output = evaluator('ls'); 29 | 30 | chai.expect(output).to.equal('baz/\nfoo/'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/commands/echo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints arguments to text output 3 | * Usage: echo 'hello world' 4 | */ 5 | import * as OutputFactory from 'emulator-output/output-factory'; 6 | import { getEnvironmentVariable } from 'emulator-state/environment-variables'; 7 | 8 | const VARIABLE_GROUP_REGEX = /\$(\w+)/g; 9 | const DOUBLE_SPACE_REGEX = /\s\s+/g; 10 | 11 | const substituteEnvVariables = (environmentVariables, inputStr) => { 12 | return inputStr.replace(VARIABLE_GROUP_REGEX, (match, varName) => 13 | getEnvironmentVariable(environmentVariables, varName) || '' 14 | ); 15 | }; 16 | 17 | export const optDef = {}; 18 | 19 | export default (state, commandOptions) => { 20 | const input = commandOptions.join(' '); 21 | const outputStr = substituteEnvVariables( 22 | state.getEnvVariables(), input 23 | ); 24 | const cleanStr = outputStr.trim().replace(DOUBLE_SPACE_REGEX, ' '); 25 | 26 | return { 27 | output: OutputFactory.makeTextOutput(cleanStr) 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/fs/util/glob-util.js: -------------------------------------------------------------------------------- 1 | import minimatch from 'minimatch'; 2 | import capture from 'minimatch-capture'; 3 | import { List } from 'immutable'; 4 | 5 | const GLOB_OPTIONS = {dot: true}; 6 | 7 | export const glob = (str, globPattern) => { 8 | return minimatch(str, globPattern, GLOB_OPTIONS); 9 | }; 10 | 11 | export const globSeq = (seq, globPattern) => { 12 | return seq.filter((path) => minimatch(path, globPattern, GLOB_OPTIONS)); 13 | }; 14 | 15 | export const globPaths = (fs, globPattern) => { 16 | return globSeq(fs.keySeq(), globPattern); 17 | }; 18 | 19 | export const captureGlobPaths = (fs, globPattern, filterCondition = (path) => true) => { 20 | return fs.keySeq().reduce((captures, path) => { 21 | if (filterCondition(path)) { 22 | const pathCaptures = capture(path, globPattern, GLOB_OPTIONS); 23 | 24 | if (pathCaptures) { 25 | return captures.concat(pathCaptures); 26 | } 27 | } 28 | 29 | return captures; 30 | }, List()); 31 | }; 32 | -------------------------------------------------------------------------------- /test/commands/whoami.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 7 | import whoami from 'commands/whoami'; 8 | 9 | describe('whoami', () => { 10 | it('should print root as the fallback usernname', () => { 11 | const state = EmulatorState.createEmpty(); 12 | 13 | const {output} = whoami(state, []); 14 | 15 | chai.expect(output.content).to.equal('root'); 16 | }); 17 | 18 | it('should print the user environment variable', () => { 19 | const state = EmulatorState.create({ 20 | environmentVariables: createEnvironmentVariables({ 21 | 'user': 'userNameValue' 22 | }, '/') 23 | }); 24 | 25 | const {output} = whoami(state, []); 26 | 27 | chai.expect(output.content).to.equal('userNameValue'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/parser/opt-parser.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import parseOptions from 'parser/option-parser'; 4 | 5 | // NB: Only rudimentary unit tests as `option-parser` is a wrapper for the 6 | // `get-options` library 7 | 8 | describe('option-parser', () => { 9 | it('should parse options', () => { 10 | const parsedOpts = parseOptions(['-b', 'fooVal', 'barVal', '--alias'], { 11 | '-a, --alias': '', 12 | '-b': ' ' 13 | }); 14 | 15 | chai.expect(parsedOpts).to.deep.equal({ 16 | options: { 17 | b: ['fooVal', 'barVal'], 18 | alias: true 19 | }, 20 | argv: [] 21 | }); 22 | }); 23 | 24 | it('should parse options argv', () => { 25 | const parsedOpts = parseOptions(['the argv', '--alias'], { 26 | '-a, --alias': '' 27 | }); 28 | 29 | chai.expect(parsedOpts).to.deep.equal({ 30 | options: { 31 | alias: true 32 | }, 33 | argv: ['the argv'] 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/commands/history.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import EmulatorState from 'emulator-state/EmulatorState'; 4 | import { create as createHistory } from 'emulator-state/history'; 5 | import history from 'commands/history'; 6 | 7 | describe('history', () => { 8 | const expectedHistory = ['pwd', 'cd /foo', 'echo abc']; 9 | const stateWithExpectedHistory = EmulatorState.create({ 10 | history: createHistory(expectedHistory) 11 | }); 12 | const stateWithNoHistory = EmulatorState.createEmpty(); 13 | 14 | it('should print history', () => { 15 | const {output} = history(stateWithExpectedHistory, []); 16 | 17 | chai.expect(output.content).to.equal(expectedHistory.join('\n')); 18 | }); 19 | 20 | describe('arg: -c', () => { 21 | it('should delete history', () => { 22 | const {state} = history(stateWithExpectedHistory, ['-c']); 23 | 24 | chai.expect(state).toEqualState(stateWithNoHistory); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/library.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import * as Terminal from '../lib/terminal.js'; 4 | 5 | describe('Given the Terminal library', () => { 6 | it('should define all API functions', () => { 7 | chai.assert.isDefined(Terminal.Emulator); 8 | chai.assert.isDefined(new Terminal.Emulator()); 9 | 10 | // State API 11 | chai.assert.isDefined(Terminal.CommandMapping); 12 | chai.assert.isDefined(Terminal.EnvironmentVariables); 13 | chai.assert.isDefined(Terminal.Outputs); 14 | chai.assert.isDefined(Terminal.FileSystem); 15 | chai.assert.isDefined(Terminal.History); 16 | chai.assert.isDefined(Terminal.EmulatorState); 17 | 18 | // Output API 19 | chai.assert.isDefined(Terminal.OutputFactory); 20 | chai.assert.isDefined(Terminal.OutputType); 21 | 22 | // FS API 23 | chai.assert.isDefined(Terminal.DirOp); 24 | chai.assert.isDefined(Terminal.FileOp); 25 | 26 | // Parser API 27 | chai.assert.isDefined(Terminal.OptionParser); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/os/mocks/mock-fs.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | 3 | // Primary folder 4 | export const PRIMARY_FOLDER_PATH = '/primary'; 5 | export const PRIMARY_SUBFOLDER_PATH = '/primary/subfolder'; 6 | export const PRIMARY_FOLDER_FILES = ['foobar']; 7 | export const PRIMARY_FILE_PATH = '/primary/foobar'; 8 | export const PRIMARY_FOLDER = { 9 | '/primary': {}, 10 | '/primary/foobar': {content: ''}, 11 | '/primary/subfolder': {} 12 | }; 13 | 14 | // Secondary folder 15 | export const SECONDARY_FOLDER_PATH = '/secondary'; 16 | export const SECONDARY_FOLDER_FILES = ['foo1', 'foo2', 'foo3']; 17 | export const SECONDARY_FOLDER = { 18 | '/secondary': {}, 19 | '/secondary/foo1': {content: ''}, 20 | '/secondary/foo2': {content: ''}, 21 | '/secondary/foo3': {content: ''} 22 | }; 23 | 24 | // Mock FS 25 | export const MOCK_FS = fromJS({ 26 | '/': {}, 27 | ...PRIMARY_FOLDER, 28 | ...SECONDARY_FOLDER 29 | }); 30 | 31 | export const MOCK_FS_EXC_SECONDARY_FOLDER = fromJS({ 32 | '/': {}, 33 | ...PRIMARY_FOLDER 34 | }); 35 | -------------------------------------------------------------------------------- /test/os/mocks/mock-fs-permissions.js: -------------------------------------------------------------------------------- 1 | import { create as createFileSystem } from 'emulator-state/file-system'; 2 | 3 | const FS = createFileSystem({ 4 | '/cannot-modify': { 5 | canModify: false 6 | }, 7 | '/cannot-modify/can-modify-file': { 8 | canModify: true, 9 | content: '' 10 | }, 11 | '/cannot-modify/cannot-modify-file': { 12 | canModify: true, 13 | content: '' 14 | }, 15 | '/cannot-modify/can-modify': { 16 | canModify: true 17 | }, 18 | '/cannot-modify/can-modify/can-modify-file': { 19 | canModify: true, 20 | content: '' 21 | }, 22 | '/cannot-modify/can-modify/cannot-modify-file': { 23 | canModify: true, 24 | content: '' 25 | }, 26 | '/can-modify': { 27 | canModify: true 28 | }, 29 | '/can-modify/can-modify-file': { 30 | canModify: true, 31 | content: '' 32 | }, 33 | '/can-modify/cannot-modify-file': { 34 | canModify: false, 35 | content: '' 36 | }, 37 | '/can-modify-secondary': { 38 | canModify: true 39 | } 40 | }); 41 | 42 | export default FS; 43 | -------------------------------------------------------------------------------- /src/commands/mkdir.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates an empty directory 3 | * Usage: mkdir /newDir 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as DirOp from 'fs/operations-with-permissions/directory-operations'; 7 | import * as OutputFactory from 'emulator-output/output-factory'; 8 | import * as FileUtil from 'fs/util/file-util'; 9 | import { resolvePath } from 'emulator-state/util'; 10 | 11 | const EMPTY_DIR = FileUtil.makeDirectory(); 12 | 13 | export const optDef = {}; 14 | 15 | export default (state, commandOptions) => { 16 | const {argv} = parseOptions(commandOptions, optDef); 17 | 18 | if (argv.length === 0) { 19 | return {}; // do nothing if no arguments are given 20 | } 21 | 22 | const newFolderPath = resolvePath(state, argv[0]); 23 | const {fs, err} = DirOp.addDirectory(state.getFileSystem(), newFolderPath, EMPTY_DIR, false); 24 | 25 | if (err) { 26 | return { 27 | output: OutputFactory.makeErrorOutput(err) 28 | }; 29 | } 30 | 31 | return { 32 | state: state.setFileSystem(fs) 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/commands/head.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints the first n lines of a file 3 | * Usage: head -n 5 file.txt 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as OutputFactory from 'emulator-output/output-factory'; 7 | import { trimFileContent } from 'commands/util/_head_tail_util.js'; 8 | import { resolvePath } from 'emulator-state/util'; 9 | 10 | export const optDef = { 11 | '-n, --lines': '' 12 | }; 13 | 14 | export default (state, commandOptions) => { 15 | const {argv, options} = parseOptions(commandOptions, optDef); 16 | 17 | if (argv.length === 0) { 18 | return {}; 19 | } 20 | 21 | const filePath = resolvePath(state, argv[0]); 22 | const headTrimmingFn = (lines, lineCount) => lines.slice(0, lineCount); 23 | const {content, err} = trimFileContent( 24 | state.getFileSystem(), filePath, options, headTrimmingFn 25 | ); 26 | 27 | if (err) { 28 | return { 29 | output: OutputFactory.makeErrorOutput(err) 30 | }; 31 | } 32 | 33 | return { 34 | output: OutputFactory.makeTextOutput(content) 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/commands/cat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Combines one or more files to display in the terminal output 3 | * Usage: cat file1.txt file2.txt 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as FileOp from 'fs/operations-with-permissions/file-operations'; 7 | import * as OutputFactory from 'emulator-output/output-factory'; 8 | import { resolvePath } from 'emulator-state/util'; 9 | 10 | const fileToTextOutput = (fs, filePath) => { 11 | const {err, file} = FileOp.readFile(fs, filePath); 12 | 13 | if (err) { 14 | return OutputFactory.makeErrorOutput(err); 15 | }; 16 | 17 | return OutputFactory.makeTextOutput(file.get('content')); 18 | }; 19 | 20 | export const optDef = {}; 21 | 22 | export default (state, commandOptions) => { 23 | const {argv} = parseOptions(commandOptions, optDef); 24 | 25 | if (argv.length === 0) { 26 | return {}; 27 | } 28 | 29 | const filePaths = argv.map(pathArg => resolvePath(state, pathArg)); 30 | 31 | return { 32 | outputs: filePaths.map(path => fileToTextOutput(state.getFileSystem(), path)) 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/commands/tail.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints the last n lines of a file 3 | * Usage: tail -n 5 file.txt 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as OutputFactory from 'emulator-output/output-factory'; 7 | import { trimFileContent } from 'commands/util/_head_tail_util.js'; 8 | import { resolvePath } from 'emulator-state/util'; 9 | 10 | export const optDef = { 11 | '-n, --lines': '' 12 | }; 13 | 14 | export default (state, commandOptions) => { 15 | const {argv, options} = parseOptions(commandOptions, optDef); 16 | 17 | if (argv.length === 0) { 18 | return {}; 19 | } 20 | 21 | const filePath = resolvePath(state, argv[0]); 22 | const tailTrimmingFn = (lines, lineCount) => lines.slice(-1 * lineCount); 23 | const {content, err} = trimFileContent( 24 | state.getFileSystem(), filePath, options, tailTrimmingFn 25 | ); 26 | 27 | if (err) { 28 | return { 29 | output: OutputFactory.makeErrorOutput(err) 30 | }; 31 | } 32 | 33 | return { 34 | output: OutputFactory.makeTextOutput(content) 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/emulator-state/outputs.js: -------------------------------------------------------------------------------- 1 | import { List } from 'immutable'; 2 | import { Record } from 'immutable'; 3 | 4 | /** 5 | * Stores outputs from the emulator (e.g. text to display after running a command) 6 | * @param {Array} [outputs=[]] Previous outputs 7 | * @return {List} List of outputs objects 8 | */ 9 | export const create = (outputs = []) => { 10 | return List(outputs); 11 | }; 12 | 13 | /** 14 | * Adds a new output record 15 | * @param {List} outputs outputs list 16 | * @param {OutputRecord} outputRecord record conforming to output schema 17 | */ 18 | export const addRecord = (outputs, outputRecord) => { 19 | if (!Record.isRecord(outputRecord)) { 20 | throw new Error('Only records of type OutputRecord can be added to outputs'); 21 | } 22 | 23 | if (!outputRecord.has('type')) { 24 | throw new Error('Output record must include a type'); 25 | } 26 | 27 | if (!outputRecord.has('content')) { 28 | throw new Error('Output record must include content'); 29 | } 30 | 31 | return outputs.push(outputRecord); 32 | }; 33 | -------------------------------------------------------------------------------- /src/emulator/plugins/HistoryKeyboardPlugin.js: -------------------------------------------------------------------------------- 1 | import BoundedHistoryIterator from 'emulator/plugins/BoundedHistoryIterator'; 2 | 3 | export default class HistoryKeyboardPlugin { 4 | constructor(state) { 5 | this._nullableHistoryIterator = null; 6 | this.historyStack = state.getHistory(); 7 | } 8 | 9 | // Plugin contract 10 | onExecuteStarted(state, str) { 11 | // no-op 12 | } 13 | 14 | // Plugin contract 15 | onExecuteCompleted(state) { 16 | this._nullableHistoryIterator = null; 17 | this.historyStack = state.getHistory(); 18 | } 19 | 20 | // Plugin API 21 | completeUp() { 22 | this.createHistoryIteratorIfNull(); 23 | 24 | return this._nullableHistoryIterator.up(); 25 | } 26 | 27 | completeDown() { 28 | this.createHistoryIteratorIfNull(); 29 | 30 | return this._nullableHistoryIterator.down(); 31 | } 32 | 33 | // Private methods 34 | createHistoryIteratorIfNull() { 35 | if (!this._nullableHistoryIterator) { 36 | this._nullableHistoryIterator = new BoundedHistoryIterator( 37 | this.historyStack 38 | ); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/fs/fs-error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * File system error types 3 | * @type {Object} 4 | */ 5 | export const fsErrorType = { 6 | FILE_EXISTS: 'File exists', 7 | DIRECTORY_EXISTS: 'Directory exists', 8 | DIRECTORY_NOT_EMPTY: 'Directory not empty', 9 | NO_SUCH_FILE_OR_DIRECTORY: 'No such file or directory', 10 | NO_SUCH_FILE: 'No such file', 11 | NO_SUCH_DIRECTORY: 'No such directory', 12 | FILE_OR_DIRECTORY_EXISTS: 'File or directory exists', 13 | IS_A_DIRECTORY: 'Is a directory', 14 | NOT_A_DIRECTORY: 'Not a directory', 15 | PERMISSION_DENIED: 'Permission denied', 16 | OTHER: 'Other' 17 | }; 18 | 19 | /** 20 | * Create a non-fatal file system error object 21 | * 22 | * For fatal errors do not use this. Throw an error instead. 23 | * @param {string} fsErrorType file system error type 24 | * @param {string} [message=''] optional metadata for developers about the error 25 | * @return {object} internal error object 26 | */ 27 | export const makeError = (fsErrorType, message = '') => { 28 | return { 29 | source: 'fs', 30 | type: fsErrorType, 31 | message 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/parser/command-parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes excess whitespace (> 1 space) from edges of string and inside string. 3 | * @param {string} str string 4 | * @return {string} string without > 1 space of whitespace 5 | */ 6 | const removeExcessWhiteSpace = str => str.trim().replace(/\s\s+/g, ' '); 7 | 8 | /** 9 | * Places the command name and each following argument into a list 10 | * @param {string} command sh command 11 | * @return {array} command name and arguments (if any) 12 | */ 13 | const toCommandParts = command => removeExcessWhiteSpace(command).split(/\s/); 14 | 15 | /** 16 | * Creates a list of commands split into the command name and arguments 17 | * @param {string} commands command input 18 | * @return {array} list of parsed command 19 | */ 20 | export const parseCommands = (commands) => { 21 | return commands 22 | .split(/&&|;/) // split command delimiters: `&&` and `;` 23 | .map((command) => toCommandParts(command)) 24 | .map(([commandName, ...commandOptions]) => ({ 25 | commandName, 26 | commandOptions 27 | })); 28 | }; 29 | 30 | export default parseCommands; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Rohan Chandra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/commands/touch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates an empty file. 3 | * Usage: touch new_file.txt 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as FileOp from 'fs/operations-with-permissions/file-operations'; 7 | import * as OutputFactory from 'emulator-output/output-factory'; 8 | import * as FileUtil from 'fs/util/file-util'; 9 | import { resolvePath } from 'emulator-state/util'; 10 | 11 | const EMPTY_FILE = FileUtil.makeFile(); 12 | 13 | export const optDef = {}; 14 | 15 | export default (state, commandOptions) => { 16 | const {argv} = parseOptions(commandOptions, optDef); 17 | 18 | if (argv.length === 0) { 19 | return {}; // do nothing if no arguments are given 20 | } 21 | 22 | const filePath = resolvePath(state, argv[0]); 23 | 24 | if (state.getFileSystem().has(filePath)) { 25 | return {}; // do nothing if already has a file at the provided path 26 | } 27 | 28 | const {fs, err} = FileOp.writeFile(state.getFileSystem(), filePath, EMPTY_FILE); 29 | 30 | if (err) { 31 | return { 32 | output: OutputFactory.makeErrorOutput(err) 33 | }; 34 | } 35 | 36 | return { 37 | state: state.setFileSystem(fs) 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /test/emulator-state/file-system.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { Map } from 'immutable'; 3 | import chaiImmutable from 'chai-immutable'; 4 | chai.use(chaiImmutable); 5 | 6 | import * as FileSystem from 'emulator-state/file-system'; 7 | 8 | describe('file-system', () => { 9 | describe('create', () => { 10 | it('should create an immutable map', () => { 11 | const fs = FileSystem.create({}); 12 | 13 | chai.expect(fs).to.be.instanceOf(Map); 14 | }); 15 | 16 | it('should create an immutable map from a JS object', () => { 17 | const fs = FileSystem.create({ 18 | '/dir': {} 19 | }); 20 | 21 | chai.expect(fs.get('/dir').toJS()).to.deep.equal({}); 22 | }); 23 | 24 | it('should add implied directory in nested file system', () => { 25 | const fs = FileSystem.create({ 26 | '/a/b/c': { // implies /a, /a/b and a/b/c are all directories in the file system 27 | 28 | } 29 | }); 30 | 31 | chai.expect(fs.get('/a').toJS()).to.deep.equal({}); 32 | 33 | chai.expect(fs.get('/a/b').toJS()).to.deep.equal({}); 34 | 35 | chai.expect(fs.get('/a/b/c').toJS()).to.deep.equal({}); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/commands/printenv.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 7 | import printenv from 'commands/printenv'; 8 | 9 | describe('printenv', () => { 10 | const state = EmulatorState.create({ 11 | environmentVariables: createEnvironmentVariables({ 12 | 'STR': 'baz', 13 | 'NUM': 1337 14 | }, '/dir') 15 | }); 16 | 17 | it('should print all environment variables when not given any args', () => { 18 | const {output} = printenv(state, []); 19 | 20 | const expectedCommands = ['cwd=/dir', 'STR=baz', 'NUM=1337']; 21 | 22 | chai.expect(output.content).to.deep.equal(expectedCommands.join('\n')); 23 | }); 24 | 25 | it('should print single environment variable given arg', () => { 26 | const {output} = printenv(state, ['STR']); 27 | 28 | chai.expect(output.content).to.equal('baz'); 29 | }); 30 | 31 | it('should not return any output or state if no env variable with given key', () => { 32 | chai.expect(printenv(state, ['NO_SUCH_KEY'])).to.deep.equal({}); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/fs/util/file-util.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | 3 | /** 4 | * Checks if a JavaScript object is a file object 5 | * @param {object} json potential file 6 | * @return {boolean} whether the object conforms to the file schema 7 | */ 8 | export const isFile = (map) => { 9 | return map.has('content'); 10 | }; 11 | 12 | /** 13 | * Checks if a JavaScript object is a directory object 14 | * @param {object} json potential directory 15 | * @return {boolean} whether the object conforms to the directory schema 16 | */ 17 | export const isDirectory = (map) => { 18 | return !map.has('content'); 19 | }; 20 | 21 | /** 22 | * Makes an file conforming to the file schema 23 | * @param {object} content content of the file 24 | * @return {object} new file 25 | */ 26 | export const makeFile = (content = '', metadata = {}) => { 27 | return fromJS({ 28 | content, 29 | ...metadata 30 | }); 31 | }; 32 | 33 | /** 34 | * Makes an directory conforming to the directory schema 35 | * @param {object} children child directories or files 36 | * @return {object} new directory 37 | */ 38 | export const makeDirectory = (metadata = {}) => { 39 | return fromJS({ 40 | ...metadata 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* global __dirname, require, module*/ 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const { argv: args } = require('yargs'); 6 | 7 | const isProd = args.mode === 'production'; 8 | 9 | let plugins = [ 10 | new webpack.NamedModulesPlugin() 11 | ]; 12 | 13 | const libraryName = 'Terminal'; 14 | 15 | const config = { 16 | entry: { 17 | [libraryName]: [path.join(__dirname, 'src/index.js')] 18 | }, 19 | devtool: 'source-map', 20 | output: { 21 | path: path.join(__dirname, 'lib'), 22 | filename: isProd ? 'terminal.min.js' : 'terminal.js', 23 | library: libraryName, 24 | libraryTarget: 'umd', 25 | umdNamedDefine: true, 26 | // Required to create single build for Node and web targets 27 | // FIXME: https://github.com/webpack/webpack/issues/6522 28 | globalObject: 'this' 29 | }, 30 | module: { 31 | rules: [{ 32 | test: /\.js$/, 33 | exclude: /node_modules/, 34 | use: { 35 | loader: 'babel-loader' 36 | } 37 | }] 38 | }, 39 | resolve: { 40 | modules: [path.resolve('./node_modules'), path.resolve('./src')], 41 | extensions: ['.json', '.js'] 42 | }, 43 | plugins: plugins 44 | }; 45 | 46 | module.exports = config; 47 | -------------------------------------------------------------------------------- /test/commands/echo.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 7 | import echo from 'commands/echo'; 8 | 9 | describe('echo', () => { 10 | const state = EmulatorState.create({ 11 | environmentVariables: createEnvironmentVariables({ 12 | 'STR': 'baz' 13 | }, '/dir') 14 | }); 15 | 16 | it('should echo string', () => { 17 | const {output} = echo(state, ['hello', 'world']); 18 | 19 | chai.expect(output.content).to.equal('hello world'); 20 | }); 21 | 22 | it('should replace variable in string with value', () => { 23 | const {output} = echo(state, ['this', 'is', '$STR']); 24 | 25 | chai.expect(output.content).to.equal('this is baz'); 26 | }); 27 | 28 | it('should replace variable with value', () => { 29 | const {output} = echo(state, ['$STR']); 30 | 31 | chai.expect(output.content).to.equal('baz'); 32 | }); 33 | 34 | it('should replace missing variable with no value', () => { 35 | const {output} = echo(state, ['val', '$NO_SUCH_VAR']); 36 | 37 | chai.expect(output.content).to.equal('val'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/commands/printenv.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints environment variable values 3 | * Usage: printenv cwd 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as OutputFactory from 'emulator-output/output-factory'; 7 | import { getEnvironmentVariable } from 'emulator-state/environment-variables'; 8 | 9 | // Converts all key-value pairs of the environment variables to a printable format 10 | const stringifyEnvVariables = (envVariables) => { 11 | const outputs = envVariables.reduce((outputs, varVal, varKey) => [ 12 | ...outputs, `${varKey}=${varVal}` 13 | ], []); 14 | 15 | return outputs.join('\n'); 16 | }; 17 | 18 | export const optDef = {}; 19 | 20 | export default (state, commandOptions) => { 21 | const {argv} = parseOptions(commandOptions, optDef); 22 | const envVariables = state.getEnvVariables(); 23 | 24 | if (argv.length === 0) { 25 | return { 26 | output: OutputFactory.makeTextOutput(stringifyEnvVariables(envVariables)) 27 | }; 28 | } 29 | 30 | // An argument has been passed to printenv; printenv will only print the first 31 | // argument provided 32 | const varValue = getEnvironmentVariable(envVariables, argv[0]); 33 | 34 | if (varValue) { 35 | return { 36 | output: OutputFactory.makeTextOutput(varValue) 37 | }; 38 | } 39 | 40 | return {}; 41 | }; 42 | -------------------------------------------------------------------------------- /src/commands/cd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Changes the current working directory to another directory 3 | * Usage: cd /newDirectory 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as DirectoryOp from 'fs/operations-with-permissions/directory-operations'; 7 | import * as EnvVariableUtil from 'emulator-state/environment-variables'; 8 | import * as OutputFactory from 'emulator-output/output-factory'; 9 | import { makeError, fsErrorType } from 'fs/fs-error'; 10 | import { resolvePath } from 'emulator-state/util'; 11 | 12 | const updateStateCwd = (state, newCwdPath) => { 13 | return EnvVariableUtil.setEnvironmentVariable( 14 | state.getEnvVariables(), 'cwd', newCwdPath 15 | ); 16 | }; 17 | 18 | export const optDef = {}; 19 | 20 | export default (state, commandOptions) => { 21 | const {argv} = parseOptions(commandOptions, optDef); 22 | const newCwdPath = argv[0] ? resolvePath(state, argv[0]) : '/'; 23 | 24 | if (!DirectoryOp.hasDirectory(state.getFileSystem(), newCwdPath)) { 25 | const newCwdPathDoesNotExistErr = makeError(fsErrorType.NO_SUCH_DIRECTORY); 26 | 27 | return { 28 | output: OutputFactory.makeErrorOutput(newCwdPathDoesNotExistErr) 29 | }; 30 | } 31 | 32 | return { 33 | state: state.setEnvVariables( 34 | updateStateCwd(state, newCwdPath) 35 | ) 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /demo-cli/cli-evaluator.js: -------------------------------------------------------------------------------- 1 | const { Emulator, EmulatorState, OutputType } = require('../lib/terminal.js'); 2 | 3 | /** 4 | * Processes multiple outputs for display. 5 | * 6 | * Currently only text-based output is supported. 7 | * @param {number} outputCount number of outputs from the command run 8 | * @param {list} outputs all emulator outputs 9 | * @return {none} 10 | */ 11 | const commandOutputToString = (outputCount, outputs) => { 12 | return outputs 13 | .slice(-1 * outputCount) 14 | .filter(output => output.type === OutputType.TEXT_OUTPUT_TYPE || output.type === OutputType.TEXT_ERROR_OUTPUT_TYPE) 15 | .map(output => output.content) 16 | .join('\n'); 17 | }; 18 | 19 | /** 20 | * Creates an evaluator for a Node REPL 21 | * @return {function} Node REPL evaluator 22 | */ 23 | const getTerminalEvaluator = () => { 24 | const emulator = new Emulator(); 25 | let state = EmulatorState.createEmpty(); 26 | let lastOutputsSize = 0; 27 | 28 | return (commandStr) => { 29 | state = emulator.execute(state, commandStr); 30 | 31 | const outputs = state.getOutputs(); 32 | const outputStr = commandOutputToString(outputs.size - lastOutputsSize, outputs); 33 | 34 | lastOutputsSize = outputs.size; 35 | 36 | return outputStr; 37 | }; 38 | }; 39 | 40 | module.exports = getTerminalEvaluator; 41 | -------------------------------------------------------------------------------- /src/fs/util/permission-util.js: -------------------------------------------------------------------------------- 1 | import * as PathUtil from 'fs/util/path-util'; 2 | 3 | const DEFAULT_PERMISSION = true; 4 | 5 | /** 6 | * Checks if a single path can be modified by checking the 'canModify' key held 7 | * in the path. 8 | * 9 | * This does NOT check parents of the path. 10 | * @param {Map} fs file system 11 | * @param {string} path path to check for modification permission 12 | * @return {Boolean} true, if a single path can be modified 13 | */ 14 | const isModificationAllowed = (fs, path) => { 15 | const directory = fs.get(path, null); 16 | 17 | if (directory) { 18 | const canModify = directory.get('canModify', DEFAULT_PERMISSION); 19 | 20 | if (!canModify) { 21 | return false; 22 | } 23 | } 24 | 25 | return true; 26 | }; 27 | 28 | /** 29 | * Checks if a path and its parents can be modified. 30 | * @param {Map} fs file systems 31 | * @param {String} path path to a directory or file 32 | * @return {Boolean} true, if the path and its parents can be modified 33 | */ 34 | export const canModifyPath = (fs, path) => { 35 | const breadCrumbPaths = PathUtil.getPathBreadCrumbs(path); 36 | 37 | for (const breadCrumbPath of breadCrumbPaths) { 38 | if (!isModificationAllowed(fs, breadCrumbPath)) { 39 | return false; 40 | } 41 | } 42 | 43 | return true; 44 | }; 45 | -------------------------------------------------------------------------------- /test/emulator-state/history.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import { Stack } from 'immutable'; 4 | 5 | chai.use(chaiImmutable); 6 | 7 | import * as History from 'emulator-state/history'; 8 | 9 | describe('history', () => { 10 | describe('create', () => { 11 | it('should create an immutable stack', () => { 12 | const history = History.create([]); 13 | 14 | chai.expect(history).to.be.instanceOf(Stack); 15 | }); 16 | 17 | it('should create command map from JS array', () => { 18 | const history = History.create([1, 2, 3]); 19 | 20 | chai.expect([...history.values()]).to.deep.equal([1, 2, 3]); 21 | }); 22 | }); 23 | 24 | describe('recordCommand', () => { 25 | it('should add command to top of stack', () => { 26 | const history = History.create(['a --help', 'b']); 27 | const newHistory = History.recordCommand(history, 'new'); 28 | 29 | chai.expect( 30 | newHistory.peek() 31 | ).to.equal('new'); 32 | }); 33 | 34 | it('should keep old commands in stack', () => { 35 | const history = History.create(['a --help', 'b']); 36 | const newHistory = History.recordCommand(history, 'new'); 37 | 38 | chai.expect( 39 | newHistory.toJS() 40 | ).to.deep.equal(['new', 'a --help', 'b']); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/emulator-output/output-factory.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { HEADER_OUTPUT_TYPE, TEXT_OUTPUT_TYPE, TEXT_ERROR_OUTPUT_TYPE } from 'emulator-output/output-type'; 3 | 4 | /** 5 | * Output from a command or emulator used for display to the user 6 | * @type {OutputRecord} 7 | */ 8 | export const OutputRecord = Record({ 9 | type: undefined, 10 | content: undefined 11 | }); 12 | 13 | /** 14 | * A terminal header containing metadata 15 | * @param {string} cwd the current working directory path 16 | * @return {OutputRecord} output record 17 | */ 18 | export const makeHeaderOutput = (cwd, command) => { 19 | return new OutputRecord({ 20 | type: HEADER_OUTPUT_TYPE, 21 | content: { cwd, command } 22 | }); 23 | }; 24 | 25 | /** 26 | * Unstyled text output 27 | * @param {string} content plain string output from a command or the emulator 28 | * @return {OutputRecord} output record 29 | */ 30 | export const makeTextOutput = (content) => { 31 | return new OutputRecord({ 32 | type: TEXT_OUTPUT_TYPE, 33 | content 34 | }); 35 | }; 36 | 37 | /** 38 | * Error text output 39 | * @param {object} err internal error object 40 | * @return {OutputRecord} output record 41 | */ 42 | export const makeErrorOutput = (err) => { 43 | return new OutputRecord({ 44 | type: TEXT_ERROR_OUTPUT_TYPE, 45 | content: `${err.source}: ${err.type}` 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /test/commands/mkdir.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import mkdir from 'commands/mkdir'; 4 | import { makeFileSystemTestState } from './test-helper'; 5 | 6 | describe('mkdir', () => { 7 | it('should do nothing if no arguments given', () => { 8 | const returnVal = mkdir(makeFileSystemTestState(), []); 9 | 10 | chai.expect(returnVal).to.deep.equal({}); 11 | }); 12 | 13 | it('should create directory in root with given name', () => { 14 | const expectedState = makeFileSystemTestState({ 15 | '/newFolderName': {} 16 | }); 17 | 18 | const {state} = mkdir(makeFileSystemTestState(), ['newFolderName']); 19 | 20 | chai.expect(state).toEqualState(expectedState); 21 | }); 22 | 23 | it('should create nested directory with given name', () => { 24 | const startState = makeFileSystemTestState({ 25 | '/a/b': {} 26 | }); 27 | 28 | const expectedState = makeFileSystemTestState({ 29 | '/a/b/c': {} 30 | }); 31 | 32 | const {state} = mkdir(startState, ['/a/b/c']); 33 | 34 | chai.expect(state).toEqualState(expectedState); 35 | }); 36 | 37 | describe('err: no parent directory', () => { 38 | it('should return error output if no parent directory', () => { 39 | const startState = makeFileSystemTestState(); 40 | 41 | const {output} = mkdir(startState, ['/new/folder/here']); 42 | 43 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/fs/operations-with-permissions/file-operations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds modification permissions to file operations by wrapping 3 | * file operations 4 | */ 5 | import * as PermissionUtil from 'fs/util/permission-util'; 6 | import * as FileOperations from 'fs/operations/file-operations'; 7 | import { makeError, fsErrorType } from 'fs/fs-error'; 8 | 9 | const makeFileOperationPermissionError = (message = 'Cannot modify file') => { 10 | return { 11 | err: makeError(fsErrorType.PERMISSION_DENIED, message) 12 | }; 13 | }; 14 | 15 | export const hasFile = (...args) => { 16 | return FileOperations.hasFile(...args); 17 | }; 18 | 19 | export const readFile = (...args) => { 20 | return FileOperations.readFile(...args); 21 | }; 22 | 23 | export const writeFile = (fs, filePath, ...args) => { 24 | if (!PermissionUtil.canModifyPath(fs, filePath)) { 25 | return makeFileOperationPermissionError(); 26 | } 27 | 28 | return FileOperations.writeFile(fs, filePath, ...args); 29 | }; 30 | 31 | export const copyFile = (fs, sourcePath, destPath) => { 32 | if (!PermissionUtil.canModifyPath(fs, sourcePath)) { 33 | return makeFileOperationPermissionError('Cannot modify source file'); 34 | } 35 | 36 | if (!PermissionUtil.canModifyPath(fs, destPath)) { 37 | return makeFileOperationPermissionError('Cannot modify destination file'); 38 | } 39 | 40 | return FileOperations.copyFile(fs, sourcePath, destPath); 41 | }; 42 | 43 | export const deleteFile = (fs, filePath) => { 44 | if (!PermissionUtil.canModifyPath(fs, filePath)) { 45 | return makeFileOperationPermissionError(); 46 | } 47 | 48 | return FileOperations.deleteFile(fs, filePath); 49 | }; 50 | -------------------------------------------------------------------------------- /test/commands/touch.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import EmulatorState from 'emulator-state/EmulatorState'; 4 | import {create as createFileSystem} from 'emulator-state/file-system'; 5 | import touch from 'commands/touch'; 6 | 7 | describe('touch', () => { 8 | const emptyState = EmulatorState.createEmpty(); 9 | const stateWithEmptyFile = EmulatorState.create({ 10 | fs: createFileSystem({ 11 | '/': {}, 12 | '/fileName': { 13 | content: '' 14 | } 15 | }) 16 | }); 17 | 18 | it('should do nothing if no arguments given', () => { 19 | const returnVal = touch(emptyState, []); 20 | 21 | chai.expect(returnVal).to.deep.equal({}); 22 | }); 23 | 24 | it('should create empty file with given names', () => { 25 | const {state} = touch(emptyState, ['fileName']); 26 | 27 | chai.expect(state).toEqualState(stateWithEmptyFile); 28 | }); 29 | 30 | it('should create empty file with absolute path', () => { 31 | const {state} = touch(emptyState, ['/fileName']); 32 | 33 | chai.expect(state).toEqualState(stateWithEmptyFile); 34 | }); 35 | 36 | describe('err: no directory', () => { 37 | it('should return error output if no directory for file', () => { 38 | const {output} = touch(emptyState, ['/no-such-dir/fileName']); 39 | 40 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 41 | }); 42 | }); 43 | 44 | describe('err: file already exists', () => { 45 | it('should NOT modify the fs and return NO error output', () => { 46 | const returnVal = touch(stateWithEmptyFile, ['fileName']); 47 | 48 | chai.expect(returnVal).to.deep.equal({}); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/commands/head.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import {create as createFileSystem} from 'emulator-state/file-system'; 7 | import head from 'commands/head'; 8 | 9 | const state = EmulatorState.create({ 10 | fs: createFileSystem({ 11 | '/1-line-file': { 12 | content: '1' 13 | }, 14 | '/10-line-file': { 15 | content: '1\n2\n3\n4\n5\n6\n7\n8\n9\n10' 16 | }, 17 | '/15-line-file': { 18 | content: '1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15' 19 | } 20 | }) 21 | }); 22 | 23 | describe('head', () => { 24 | it('should do nothing if no arguments given', () => { 25 | const returnVal = head(state, []); 26 | 27 | chai.expect(returnVal).to.deep.equal({}); 28 | }); 29 | 30 | it('print ten lines by default', () => { 31 | const {output} = head(state, ['15-line-file']); 32 | 33 | chai.expect(output.content).to.equal('1\n2\n3\n4\n5\n6\n7\n8\n9\n10'); 34 | }); 35 | 36 | it('print lines in count argument', () => { 37 | const {output} = head(state, ['15-line-file', '-n', '2']); 38 | 39 | chai.expect(output.content).to.equal('1\n2'); 40 | }); 41 | 42 | it('print maximum number of lines', () => { 43 | const {output} = head(state, ['1-line-file', '-n', '1000']); 44 | 45 | chai.expect(output.content).to.equal('1'); 46 | }); 47 | 48 | describe('err: no path', () => { 49 | it('should return error output if no path', () => { 50 | const {output} = head(state, ['/noSuchFile']); 51 | 52 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/commands/tail.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createFileSystem } from 'emulator-state/file-system'; 7 | import tail from 'commands/tail'; 8 | 9 | const state = EmulatorState.create({ 10 | fs: createFileSystem({ 11 | '/1-line-file': { 12 | content: '1' 13 | }, 14 | '/10-line-file': { 15 | content: '1\n2\n3\n4\n5\n6\n7\n8\n9\n10' 16 | }, 17 | '/15-line-file': { 18 | content: '1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15' 19 | } 20 | }) 21 | }); 22 | 23 | describe('tail', () => { 24 | it('should do nothing if no arguments given', () => { 25 | const returnVal = tail(state, []); 26 | 27 | chai.expect(returnVal).to.deep.equal({}); 28 | }); 29 | 30 | it('print last ten lines by default', () => { 31 | const {output} = tail(state, ['15-line-file']); 32 | 33 | chai.expect(output.content).to.equal('6\n7\n8\n9\n10\n11\n12\n13\n14\n15'); 34 | }); 35 | 36 | it('print last lines in count argument', () => { 37 | const {output} = tail(state, ['15-line-file', '-n', '2']); 38 | 39 | chai.expect(output.content).to.equal('14\n15'); 40 | }); 41 | 42 | it('print maximum number of lines', () => { 43 | const {output} = tail(state, ['1-line-file', '-n', '1000']); 44 | 45 | chai.expect(output.content).to.equal('1'); 46 | }); 47 | 48 | describe('err: no path', () => { 49 | it('should return error output if no path', () => { 50 | const {output} = tail(state, ['/noSuchFile']); 51 | 52 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/commands/rm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes a directory or a file 3 | * Usage: rm /existingDir 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as FileOp from 'fs/operations-with-permissions/file-operations'; 7 | import * as DirOp from 'fs/operations-with-permissions/directory-operations'; 8 | import * as OutputFactory from 'emulator-output/output-factory'; 9 | import { resolvePath } from 'emulator-state/util'; 10 | import { makeError, fsErrorType } from 'fs/fs-error'; 11 | 12 | export const optDef = { 13 | '--no-preserve-root, --noPreserveRoot': '', 14 | '-r, --recursive': '' 15 | }; 16 | 17 | const makeNoPathErrorOutput = () => { 18 | const noSuchFileOrDirError = makeError(fsErrorType.NO_SUCH_FILE_OR_DIRECTORY); 19 | 20 | return { 21 | output: OutputFactory.makeErrorOutput(noSuchFileOrDirError) 22 | }; 23 | }; 24 | 25 | export default (state, commandOptions) => { 26 | const {argv, options} = parseOptions(commandOptions, optDef); 27 | 28 | if (argv.length === 0) { 29 | return {}; // do nothing if no arguments are given 30 | } 31 | 32 | const deletionPath = resolvePath(state, argv[0]); 33 | const fs = state.getFileSystem(); 34 | 35 | if (deletionPath === '/' && options.noPreserveRoot !== true) { 36 | return {}; // do nothing as cannot safely delete the root 37 | } 38 | 39 | if (!fs.has(deletionPath)) { 40 | return makeNoPathErrorOutput(); 41 | } 42 | 43 | const {fs: deletedPathFS, err} = options.recursive === true ? 44 | DirOp.deleteDirectory(fs, deletionPath, true) : 45 | FileOp.deleteFile(fs, deletionPath); 46 | 47 | if (err) { 48 | return { 49 | output: OutputFactory.makeErrorOutput(err) 50 | }; 51 | } 52 | 53 | return { 54 | state: state.setFileSystem(deletedPathFS) 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /test/commands/rmdir.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import rmdir from 'commands/rmdir'; 4 | import { makeFileSystemTestState } from './test-helper'; 5 | 6 | describe('rmdir', () => { 7 | it('should do nothing if no arguments given', () => { 8 | const returnVal = rmdir(makeFileSystemTestState(), []); 9 | 10 | chai.expect(returnVal).to.deep.equal({}); 11 | }); 12 | 13 | it('should create remove a directory with a given name', () => { 14 | const startState = makeFileSystemTestState({ 15 | '/a/b': {} 16 | }); 17 | 18 | const expectedState = makeFileSystemTestState({ 19 | '/a': {} 20 | }); 21 | 22 | const {state} = rmdir(startState, ['/a/b']); 23 | 24 | chai.expect(state).toEqualState(expectedState); 25 | }); 26 | 27 | describe('err: no directory not empty', () => { 28 | it('should return error output if directory contains folders', () => { 29 | const startState = makeFileSystemTestState({ 30 | '/a/b': {} 31 | }); 32 | 33 | const {output} = rmdir(startState, ['/a']); 34 | 35 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 36 | }); 37 | 38 | it('should return error output if directory contains files', () => { 39 | const startState = makeFileSystemTestState({ 40 | '/a/b': {content: 'file b content'} 41 | }); 42 | 43 | const {output} = rmdir(startState, ['/a']); 44 | 45 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 46 | }); 47 | }); 48 | 49 | describe('err: no parent directory', () => { 50 | it('should return error output if no parent directory', () => { 51 | const startState = makeFileSystemTestState(); 52 | 53 | const {output} = rmdir(startState, ['/noSuchFolder']); 54 | 55 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/commands/cd.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import EmulatorState from 'emulator-state/EmulatorState'; 4 | import { create as createFileSystem } from 'emulator-state/file-system'; 5 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 6 | import cd from 'commands/cd'; 7 | 8 | describe('cd', () => { 9 | const fs = createFileSystem({ 10 | '/': {}, 11 | '/a/subfolder': {}, 12 | '/startingCwd': {} 13 | }); 14 | 15 | const state = EmulatorState.create({ 16 | fs, 17 | environmentVariables: createEnvironmentVariables({}, '/startingCwd') 18 | }); 19 | 20 | const makeExpectedState = (workingDirectory) => { 21 | return EmulatorState.create({ 22 | fs, 23 | environmentVariables: createEnvironmentVariables({}, workingDirectory) 24 | }); 25 | }; 26 | 27 | it('should change working directory to root if no arguments passed to cd', () => { 28 | const {state: actualState} = cd(state, []); 29 | const expectedState = makeExpectedState('/'); 30 | 31 | chai.expect(actualState).toEqualState(expectedState); 32 | }); 33 | 34 | it('should change working directory to argument directory', () => { 35 | const {state: actualState} = cd(state, ['/a']); 36 | const expectedState = makeExpectedState('/a'); 37 | 38 | chai.expect(actualState).toEqualState(expectedState); 39 | }); 40 | 41 | it('should change working directory to nested argument directory', () => { 42 | const {state: actualState} = cd(state, ['/a/subfolder']); 43 | const expectedState = makeExpectedState('/a/subfolder'); 44 | 45 | chai.expect(actualState).toEqualState(expectedState); 46 | }); 47 | 48 | it('should return error output if changing to non-existent directory', () => { 49 | const {output} = cd(state, ['/no-such-dir']); 50 | 51 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/emulator-state/outputs.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { List, Record } from 'immutable'; 3 | import chaiImmutable from 'chai-immutable'; 4 | chai.use(chaiImmutable); 5 | 6 | import * as Outputs from 'emulator-state/outputs'; 7 | import { OutputRecord } from 'emulator-output/output-factory'; 8 | 9 | describe('outputs', () => { 10 | describe('create', () => { 11 | it('should create an immutable list', () => { 12 | const outputs = Outputs.create([]); 13 | 14 | chai.expect(outputs).to.be.instanceOf(List); 15 | }); 16 | }); 17 | 18 | describe('addRecord', () => { 19 | const emptyOutputs = Outputs.create([]); 20 | 21 | it('should add an output record', () => { 22 | const newRecord = new OutputRecord({ 23 | type: 'the type', 24 | content: ['the content'] 25 | }); 26 | 27 | const outputs = Outputs.addRecord(emptyOutputs, newRecord); 28 | 29 | chai.expect(outputs).to.equal(new List([newRecord])); 30 | }); 31 | 32 | it('should throw error if adding plain JS object', () => { 33 | chai.expect(() => 34 | Outputs.addRecord(emptyOutputs, { 35 | type: 'the type', 36 | content: ['the content'] 37 | }) 38 | ).to.throw(); 39 | }); 40 | 41 | it('should throw error if adding record without type', () => { 42 | const CustomRecord = new Record('a'); 43 | const missingTypeRecord = new CustomRecord({ 44 | content: 'content' 45 | }); 46 | 47 | chai.expect(() => 48 | Outputs.addRecord(emptyOutputs, missingTypeRecord) 49 | ).to.throw(); 50 | }); 51 | 52 | it('should throw error if adding record without content', () => { 53 | const CustomRecord = new Record('a'); 54 | const missingContentRecord = new CustomRecord({ 55 | type: 'type' 56 | }); 57 | 58 | chai.expect(() => 59 | Outputs.addRecord(emptyOutputs, missingContentRecord) 60 | ).to.throw(); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascript-terminal", 3 | "version": "1.1.1", 4 | "description": "Emulate a terminal environment in JavaScript", 5 | "main": "lib/terminal.js", 6 | "unpkg": "lib/terminal.min.js", 7 | "scripts": { 8 | "build": "webpack --mode development && webpack --mode production", 9 | "test": "cross-env NODE_PATH=./src mocha --require @babel/register --colors './test/**/*.spec.js' './demo-**/**/*.spec.js'", 10 | "test:min": "yarn run test --reporter min", 11 | "test:coverage": "nyc yarn run test", 12 | "cli": "node demo-cli", 13 | "artifact:test-coverage": "nyc --reporter=html yarn run test" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/rohanchandra/javascript-terminal.git" 18 | }, 19 | "keywords": [ 20 | "terminal", 21 | "emulation" 22 | ], 23 | "author": "Rohan Chandra", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/rohanchandra/javascript-terminal/issues" 27 | }, 28 | "homepage": "https://github.com/rohanchandra/javascript-terminal", 29 | "nyc": { 30 | "exclude": [ 31 | "test", 32 | "lib" 33 | ] 34 | }, 35 | "devDependencies": { 36 | "@babel/cli": "^7.8.4", 37 | "@babel/core": "^7.9.0", 38 | "@babel/plugin-proposal-object-rest-spread": "^7.9.0", 39 | "@babel/preset-env": "^7.9.0", 40 | "@babel/register": "^7.9.0", 41 | "babel-loader": "^8.1.0", 42 | "babel-plugin-add-module-exports": "^1.0.2", 43 | "chai": "^4.2.0", 44 | "chai-immutable": "^2.1.0", 45 | "chai-spies": "^1.0.0", 46 | "cross-env": "^7.0.2", 47 | "eslint": "^6.8.0", 48 | "eslint-loader": "^4.0.0", 49 | "mocha": "^7.1.1", 50 | "nyc": "^15.0.1", 51 | "webpack": "^4.42.1", 52 | "webpack-cli": "^3.3.11" 53 | }, 54 | "dependencies": { 55 | "get-options": "^1.2.0", 56 | "immutable": "^4.0.0-rc.12", 57 | "minimatch": "^3.0.4", 58 | "minimatch-capture": "^1.1.0" 59 | }, 60 | "files": [ 61 | "lib/terminal.js", 62 | "lib/terminal.min.js" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/emulator-state/environment-variables.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | /** 4 | * Environment variable mapping containing arbitary data accessed by any 5 | * command or the emulator as a key-value pair 6 | * @param {Object} [defaultVariables={}] default environment variables 7 | * @return {Map} environment variables 8 | */ 9 | export const create = (defaultVariables = {}, cwd = '/') => { 10 | if (!cwd && !defaultVariables.hasOwnProperty('cwd')) { 11 | throw new Error( 12 | "Failed to create environment variables. Missing 'cwd' (current working directory)." 13 | ); 14 | } 15 | 16 | return Map({ 17 | 'cwd': cwd, // cwd can be undefined as it can be set in defaultVariables 18 | ...defaultVariables 19 | }); 20 | }; 21 | 22 | /** 23 | * Gets the value of an environment variable 24 | * @param {Map} environmentVariables environment variables 25 | * @param {string} key name of the environment variable 26 | * @return {T} the value stored in the environment variable 27 | */ 28 | export const getEnvironmentVariable = (environmentVariables, key) => { 29 | return environmentVariables.get(key); 30 | }; 31 | 32 | /** 33 | * Sets the value of an environment variable 34 | * @param {Map} environmentVariables environment variables 35 | * @param {string} key name of the environment variable 36 | * @param {T} val value to store in the environment variable 37 | * @return {Map} environment variables 38 | */ 39 | export const setEnvironmentVariable = (environmentVariables, key, val) => { 40 | return environmentVariables.set(key, val); 41 | }; 42 | 43 | /** 44 | * Removes an environment variable 45 | * @param {Map} environmentVariables environment variables 46 | * @param {string} key name of the environment variable 47 | * @return {Map} environment variables 48 | */ 49 | export const unsetEnvironmentVariable = (environmentVariables, key) => { 50 | return environmentVariables.delete(key); 51 | }; 52 | -------------------------------------------------------------------------------- /src/emulator/command-runner.js: -------------------------------------------------------------------------------- 1 | import { makeError, emulatorErrorType } from 'emulator/emulator-error'; 2 | import { makeErrorOutput } from 'emulator-output/output-factory'; 3 | import * as CommandMappingUtil from 'emulator-state/command-mapping'; 4 | 5 | /** 6 | * Makes an internal emulator error for emulator output. Error output may be 7 | * visible to the user. 8 | * @param {string} errorType type of emulator error 9 | * @return {object} error output object 10 | */ 11 | export const makeRunnerErrorOutput = (errorType) => { 12 | return makeErrorOutput(makeError(errorType)); 13 | }; 14 | 15 | /** 16 | * Runs a command and returns an object containing either: 17 | * - outputs from running the command, or 18 | * - new emulator state after running the command, or 19 | * - new emulator state and output after running the command 20 | * 21 | * The form of the object from this function is as follows: 22 | * { 23 | * outputs: [optional array of output records] 24 | * output: [optional single output record] 25 | * state: [optional Map] 26 | * } 27 | * @param {Map} commandMapping command mapping from emulator state 28 | * @param {string} commandName name of command to run 29 | * @param {array} commandArgs commands to provide to the command function 30 | * @param {string} errorStr a default string to be displayed if no command is found 31 | * @return {object} outputs and/or new state of the emulator 32 | */ 33 | export const run = (commandMapping, commandName, commandArgs, errorStr=emulatorErrorType.COMMAND_NOT_FOUND) => { 34 | 35 | const notFoundCallback = () => ({ 36 | output: makeRunnerErrorOutput(errorStr) 37 | }) 38 | 39 | if (!CommandMappingUtil.isCommandSet(commandMapping, commandName)) { 40 | return notFoundCallback(...commandArgs); 41 | } 42 | 43 | const command = CommandMappingUtil.getCommandFn(commandMapping, commandName); 44 | 45 | try { 46 | return command(...commandArgs); // run extracted command from the mapping 47 | } catch (fatalCommandError) { 48 | return { 49 | output: makeRunnerErrorOutput(emulatorErrorType.UNEXPECTED_COMMAND_FAILURE) 50 | }; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /test/commands/cat.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createFileSystem } from 'emulator-state/file-system'; 7 | import cat from 'commands/cat'; 8 | 9 | const state = EmulatorState.create({ 10 | fs: createFileSystem({ 11 | '/': {}, 12 | '/a': { 13 | content: 'file-one-content\nline-two' 14 | }, 15 | '/b': { 16 | content: 'file-two-content' 17 | }, 18 | '/directory': {} 19 | }) 20 | }); 21 | 22 | describe('cat', () => { 23 | it('print out single file', () => { 24 | const {outputs} = cat(state, ['a']); 25 | 26 | chai.expect(outputs[0].content).to.equal('file-one-content\nline-two'); 27 | }); 28 | 29 | it('should join multiple files ', () => { 30 | const {outputs} = cat(state, ['a', 'b']); 31 | 32 | chai.expect(outputs[0].content).to.equal('file-one-content\nline-two'); 33 | chai.expect(outputs[1].content).to.equal('file-two-content'); 34 | }); 35 | 36 | it('should have no output if no file is given', () => { 37 | const {outputs} = cat(state, []); 38 | 39 | chai.expect(outputs).to.equal(undefined); 40 | }); 41 | 42 | describe('err: no directory', () => { 43 | it('should return error output', () => { 44 | const {outputs} = cat(state, ['/no/such/dir/file.txt']); 45 | 46 | chai.expect(outputs[0].type).to.equal('TEXT_ERROR_OUTPUT'); 47 | }); 48 | }); 49 | 50 | describe('err: no file', () => { 51 | it('should read files which exist and skip files with error', () => { 52 | const {outputs} = cat(state, ['a', 'no-such-file', 'b']); 53 | 54 | chai.expect(outputs[0].content).to.equal('file-one-content\nline-two'); 55 | chai.expect(outputs[1].type).to.equal('TEXT_ERROR_OUTPUT'); 56 | chai.expect(outputs[2].content).to.equal('file-two-content'); 57 | }); 58 | 59 | it('should return error output if no file', () => { 60 | const {outputs} = cat(state, ['/no_such_file.txt']); 61 | 62 | chai.expect(outputs[0].type).to.equal('TEXT_ERROR_OUTPUT'); 63 | }); 64 | 65 | it('should return error output if directory instead of file is passed to cat', () => { 66 | const {outputs} = cat(state, ['/directory']); 67 | 68 | chai.expect(outputs[0].type).to.equal('TEXT_ERROR_OUTPUT'); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/emulator/command-runner.spec.js: -------------------------------------------------------------------------------- 1 | import { create as createCommandMapping } from 'emulator-state/command-mapping'; 2 | import chai from 'chai'; 3 | 4 | import { makeRunnerErrorOutput, run } from 'emulator/command-runner'; 5 | import { makeHeaderOutput, makeTextOutput } from 'emulator-output/output-factory'; 6 | import { emulatorErrorType } from 'emulator/emulator-error'; 7 | 8 | describe('command-runner', () => { 9 | describe('run', () => { 10 | it('should exist', () => { 11 | chai.assert.isFunction(run); 12 | }); 13 | 14 | it('should run command from command mapping with no args', () => { 15 | const commandMapping = createCommandMapping({ 16 | returnTrue: { 17 | function: () => {return true;}, 18 | optDef: {} 19 | } 20 | }); 21 | 22 | chai.expect( 23 | run(commandMapping, 'returnTrue', []) 24 | ).to.equal(true); 25 | }); 26 | 27 | it('should run command from command mapping with args', () => { 28 | const commandMapping = createCommandMapping({ 29 | sum: { 30 | function: (a, b) => {return a + b;}, 31 | optDef: {} 32 | } 33 | }); 34 | 35 | chai.expect( 36 | run(commandMapping, 'sum', [40, 2]) 37 | ).to.equal(42); 38 | }); 39 | 40 | it('should raise unexpected command failure internal error if command throws error', () => { 41 | const commandMapping = createCommandMapping({ 42 | throwsError: { 43 | function: () => {throw new Error('Unhandled error');}, 44 | optDef: {} 45 | } 46 | }); 47 | 48 | const {output} = run(commandMapping, 'throwsError', []); 49 | 50 | chai.expect(output.content).to.include(emulatorErrorType.UNEXPECTED_COMMAND_FAILURE); 51 | }); 52 | 53 | it('should raise no command error if command not in mapping', () => { 54 | const commandMapping = createCommandMapping({}); 55 | 56 | const {output} = run(commandMapping, 'noSuchKey', []); 57 | 58 | chai.expect(output.content).to.include(emulatorErrorType.COMMAND_NOT_FOUND); 59 | }); 60 | 61 | it('should print a custom error if command not in mapping and message provided', () => { 62 | const commandMapping = createCommandMapping({}); 63 | 64 | chai.expect(run(commandMapping, 'noSuchKey', [], "an error message")).to.deep.equal({ 65 | output: makeRunnerErrorOutput("an error message") 66 | } 67 | ); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/fs/operations-with-permissions/directory-operations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds modification permissions to directory operations by wrapping 3 | * directory operations 4 | */ 5 | import * as DirectoryOperations from 'fs/operations/directory-operations'; 6 | import * as PermissionUtil from 'fs/util/permission-util'; 7 | import { makeError, fsErrorType } from 'fs/fs-error'; 8 | 9 | const makeDirectoryOperationPermissionError = (message = 'Cannot modify directory') => { 10 | return { 11 | err: makeError(fsErrorType.PERMISSION_DENIED, message) 12 | }; 13 | }; 14 | 15 | export const hasDirectory = (...args) => { 16 | return DirectoryOperations.hasDirectory(...args); 17 | }; 18 | 19 | export const listDirectory = (...args) => { 20 | return DirectoryOperations.listDirectory(...args); 21 | }; 22 | 23 | export const listDirectoryFiles = (...args) => { 24 | return DirectoryOperations.listDirectoryFiles(...args); 25 | }; 26 | 27 | export const listDirectoryFolders = (...args) => { 28 | return DirectoryOperations.listDirectoryFolders(...args); 29 | }; 30 | 31 | export const addDirectory = (fs, path, ...args) => { 32 | if (!PermissionUtil.canModifyPath(fs, path)) { 33 | return makeDirectoryOperationPermissionError(); 34 | } 35 | 36 | return DirectoryOperations.addDirectory(fs, path, ...args); 37 | }; 38 | 39 | export const copyDirectory = (fs, srcPath, destPath, ...args) => { 40 | if (!PermissionUtil.canModifyPath(fs, srcPath)) { 41 | return makeDirectoryOperationPermissionError('Cannot modify source directory'); 42 | } 43 | 44 | if (!PermissionUtil.canModifyPath(fs, destPath)) { 45 | return makeDirectoryOperationPermissionError('Cannot modify dest directory'); 46 | } 47 | 48 | return DirectoryOperations.copyDirectory(fs, srcPath, destPath, ...args); 49 | }; 50 | 51 | export const deleteDirectory = (fs, path, ...args) => { 52 | if (!PermissionUtil.canModifyPath(fs, path)) { 53 | return makeDirectoryOperationPermissionError(); 54 | } 55 | 56 | return DirectoryOperations.deleteDirectory(fs, path, ...args); 57 | }; 58 | 59 | export const renameDirectory = (fs, currentPath, newPath) => { 60 | if (!PermissionUtil.canModifyPath(fs, currentPath)) { 61 | return makeDirectoryOperationPermissionError('Cannot modify current path'); 62 | } 63 | 64 | if (!PermissionUtil.canModifyPath(fs, newPath)) { 65 | return makeDirectoryOperationPermissionError('Cannot modify renamed path'); 66 | } 67 | 68 | return DirectoryOperations.renameDirectory(fs, currentPath, newPath); 69 | }; 70 | -------------------------------------------------------------------------------- /test/os/util/permission-util.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import FS from '../mocks/mock-fs-permissions'; 6 | import * as PermissionUtil from 'fs/util/permission-util'; 7 | 8 | describe('file-operations', () => { 9 | describe('directories', () => { 10 | it('should return false if root directory is not readable', () => { 11 | chai.expect( 12 | PermissionUtil.canModifyPath(FS, '/cannot-modify') 13 | ).to.equal(false); 14 | }); 15 | 16 | it('should return false if parent directory is not readable', () => { 17 | chai.expect( 18 | PermissionUtil.canModifyPath(FS, '/cannot-modify/can-modify') 19 | ).to.equal(false); 20 | }); 21 | 22 | it('should return true if directory is readable', () => { 23 | chai.expect( 24 | PermissionUtil.canModifyPath(FS, '/can-modify') 25 | ).to.equal(true); 26 | }); 27 | 28 | it('should return true if directory does not exist', () => { 29 | chai.expect( 30 | PermissionUtil.canModifyPath(FS, '/no-directory') 31 | ).to.equal(true); 32 | }); 33 | }); 34 | 35 | describe('files', () => { 36 | it('should return false can modify file but cannot modify dir', () => { 37 | chai.expect( 38 | PermissionUtil.canModifyPath(FS, '/cannot-modify/can-modify-file') 39 | ).to.equal(false); 40 | }); 41 | 42 | it('should return false cannot modify file and cannot modify dir', () => { 43 | chai.expect( 44 | PermissionUtil.canModifyPath(FS, '/cannot-modify/cannot-modify-file') 45 | ).to.equal(false); 46 | }); 47 | 48 | it('should return false if cannot write parent directory', () => { 49 | chai.expect( 50 | PermissionUtil.canModifyPath(FS, '/cannot-modify/can-modify/can-modify-file') 51 | ).to.equal(false); 52 | }); 53 | 54 | it('should return false if can modify dir but cannot modify file', () => { 55 | chai.expect( 56 | PermissionUtil.canModifyPath(FS, '/can-modify/cannot-modify-file') 57 | ).to.equal(false); 58 | }); 59 | 60 | it('should return true if can modify file and dir', () => { 61 | chai.expect( 62 | PermissionUtil.canModifyPath(FS, '/can-modify/can-modify-file') 63 | ).to.equal(true); 64 | }); 65 | 66 | it('should return true if missing file', () => { 67 | chai.expect( 68 | PermissionUtil.canModifyPath(FS, '/can-modify', 'no-such-file') 69 | ).to.equal(true); 70 | }); 71 | 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/emulator-output/output-factory.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import { OutputRecord, makeHeaderOutput, makeTextOutput, makeErrorOutput } from 'emulator-output/output-factory'; 4 | import * as Types from 'emulator-output/output-type'; 5 | 6 | describe('output-factory', () => { 7 | describe('OutputRecord', () => { 8 | it('should create a record with type and content', () => { 9 | const newRecord = new OutputRecord({ 10 | type: 'the type', 11 | content: ['the content'] 12 | }); 13 | 14 | chai.expect(newRecord.type).to.equal('the type'); 15 | chai.expect(newRecord.content).to.be.deep.equal(['the content']); 16 | }); 17 | 18 | it('should ignore keys not in the schema', () => { 19 | const newRecord = new OutputRecord({ 20 | notInSchema: 'do not add me' 21 | }); 22 | 23 | chai.expect(newRecord.notInSchema).to.equal(undefined); 24 | }); 25 | }); 26 | 27 | describe('makeHeaderOutput', () => { 28 | it('should create a record with the cwd', () => { 29 | const outputRecord = makeHeaderOutput('the cwd', 'the command'); 30 | 31 | chai.expect(outputRecord.content).to.deep.equal({cwd: 'the cwd', command: 'the command'}); 32 | }); 33 | 34 | it('should create a record with header type', () => { 35 | const outputRecord = makeHeaderOutput(''); 36 | 37 | chai.expect(outputRecord.type).to.equal(Types.HEADER_OUTPUT_TYPE); 38 | }); 39 | }); 40 | 41 | describe('makeTextOutput', () => { 42 | it('should create a record with content', () => { 43 | const textRecord = makeTextOutput('the content'); 44 | 45 | chai.expect(textRecord.content).to.be.deep.equal('the content'); 46 | }); 47 | 48 | it('should create a record with text type', () => { 49 | const textRecord = makeTextOutput(''); 50 | 51 | chai.expect(textRecord.type).to.equal(Types.TEXT_OUTPUT_TYPE); 52 | }); 53 | }); 54 | 55 | describe('makeTextOutput', () => { 56 | it('should combine source of error and type of error in output', () => { 57 | const errorRecord = makeErrorOutput({ 58 | source: 'the source', 59 | type: 'the type' 60 | }); 61 | 62 | chai.expect(errorRecord.content).to.be.deep.equal('the source: the type'); 63 | }); 64 | 65 | it('should create a record with error type', () => { 66 | const errorRecord = makeErrorOutput({ 67 | source: 'the source', 68 | type: 'the type' 69 | }); 70 | 71 | chai.expect(errorRecord.type).to.equal(Types.TEXT_ERROR_OUTPUT_TYPE); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /demo-web/js/main.js: -------------------------------------------------------------------------------- 1 | /* global Terminal */ 2 | 3 | // Utilities 4 | const addKeyDownListener = (eventKey, target, onKeyDown) => { 5 | target.addEventListener('keydown', e => { 6 | if (e.key === eventKey) { 7 | onKeyDown(); 8 | 9 | e.preventDefault(); 10 | } 11 | }); 12 | }; 13 | 14 | const scrollToPageEnd = () => { 15 | window.scrollTo(0, document.body.scrollHeight); 16 | }; 17 | 18 | // User interface 19 | const viewRefs = { 20 | input: document.getElementById('input'), 21 | output: document.getElementById('output-wrapper') 22 | }; 23 | 24 | const createOutputDiv = (className, textContent) => { 25 | const div = document.createElement('div'); 26 | 27 | div.className = className; 28 | div.appendChild(document.createTextNode(textContent)); 29 | 30 | return div; 31 | }; 32 | 33 | const outputToHTMLNode = { 34 | [Terminal.OutputType.TEXT_OUTPUT_TYPE]: content => 35 | createOutputDiv('text-output', content), 36 | [Terminal.OutputType.TEXT_ERROR_OUTPUT_TYPE]: content => 37 | createOutputDiv('error-output', content), 38 | [Terminal.OutputType.HEADER_OUTPUT_TYPE]: content => 39 | createOutputDiv('header-output', `$ ${content.command}`) 40 | }; 41 | 42 | const displayOutputs = (outputs) => { 43 | viewRefs.output.innerHTML = ''; 44 | 45 | const outputNodes = outputs.map(output => 46 | outputToHTMLNode[output.type](output.content) 47 | ); 48 | 49 | for (const outputNode of outputNodes) { 50 | viewRefs.output.append(outputNode); 51 | } 52 | }; 53 | 54 | const getInput = () => viewRefs.input.value; 55 | 56 | const setInput = (input) => { 57 | viewRefs.input.value = input; 58 | }; 59 | 60 | const clearInput = () => { 61 | setInput(''); 62 | }; 63 | 64 | // Execution 65 | const emulator = new Terminal.Emulator(); 66 | 67 | let emulatorState = Terminal.EmulatorState.createEmpty(); 68 | const historyKeyboardPlugin = new Terminal.HistoryKeyboardPlugin(emulatorState); 69 | const plugins = [historyKeyboardPlugin]; 70 | 71 | addKeyDownListener('Enter', viewRefs.input, () => { 72 | const commandStr = getInput(); 73 | 74 | emulatorState = emulator.execute(emulatorState, commandStr, plugins); 75 | displayOutputs(emulatorState.getOutputs()); 76 | scrollToPageEnd(); 77 | clearInput(); 78 | }); 79 | 80 | addKeyDownListener('ArrowUp', viewRefs.input, () => { 81 | setInput(historyKeyboardPlugin.completeUp()); 82 | }); 83 | 84 | addKeyDownListener('ArrowDown', viewRefs.input, () => { 85 | setInput(historyKeyboardPlugin.completeDown()); 86 | }); 87 | 88 | addKeyDownListener('Tab', viewRefs.input, () => { 89 | const autoCompletionStr = emulator.autocomplete(emulatorState, getInput()); 90 | 91 | setInput(autoCompletionStr); 92 | }); 93 | -------------------------------------------------------------------------------- /src/commands/ls.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Lists the contents of a directory 3 | * Usage: ls /folderName 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as DirectoryOp from 'fs/operations-with-permissions/directory-operations'; 7 | import * as EnvVariableUtil from 'emulator-state/environment-variables'; 8 | import * as PathUtil from 'fs/util/path-util'; 9 | import * as OutputFactory from 'emulator-output/output-factory'; 10 | import { Seq } from 'immutable'; 11 | 12 | const IMPLIED_DIRECTORY_ENTRIES = Seq(['.', '..']); // . = listed folder, .. = parent folder 13 | 14 | /** 15 | * Finds the directory path to list entries in. 16 | * 17 | * If ls has an argument passed in (example: ls /home/user/directory-to-list), 18 | * use the first argument as the directory to list. 19 | * 20 | * If ls is used without any path arguments (example: ls), the cwd (current 21 | * working directory) should be listed by ls. 22 | * @param {Map} envVariables environment variables 23 | * @param {array} argv argument vector 24 | * @return {string} directory path to list 25 | */ 26 | const resolveDirectoryToList = (envVariables, argv) => { 27 | const cwd = EnvVariableUtil.getEnvironmentVariable(envVariables, 'cwd'); 28 | 29 | if (argv.length > 0) { 30 | return PathUtil.toAbsolutePath(argv[0], cwd); 31 | } 32 | 33 | return cwd; 34 | }; 35 | 36 | /** 37 | * Alphabetically sorts the ls listing for display to the user 38 | * @param {array} listing list of files/directories to present to the user 39 | * @return {object} return object of ls 40 | */ 41 | const makeSortedReturn = (listing) => { 42 | const sortedListing = listing.sort(); 43 | 44 | return { 45 | output: OutputFactory.makeTextOutput(sortedListing.join('\n')) 46 | }; 47 | }; 48 | 49 | const removeHiddenFilesFilter = (record) => { 50 | return !record.startsWith('.'); 51 | }; 52 | 53 | export const optDef = { 54 | '-a, --all': '', // Include hidden directory entries starting with . 55 | '-A, --almost-all': '' // Do not include . and .. as implied directory entries 56 | }; 57 | 58 | export default (state, commandOptions) => { 59 | const {options, argv} = parseOptions(commandOptions, optDef); 60 | const dirPath = resolveDirectoryToList(state.getEnvVariables(), argv); 61 | const {err, list: dirList} = DirectoryOp.listDirectory(state.getFileSystem(), dirPath); 62 | 63 | if (err) { 64 | return { 65 | output: OutputFactory.makeErrorOutput(err) 66 | }; 67 | } 68 | 69 | if (options.all) { 70 | return makeSortedReturn(IMPLIED_DIRECTORY_ENTRIES.concat(dirList)); 71 | } else if (options.almostAll) { 72 | return makeSortedReturn(dirList); 73 | } 74 | 75 | return makeSortedReturn(dirList.filter(removeHiddenFilesFilter)); 76 | }; 77 | -------------------------------------------------------------------------------- /src/emulator/auto-complete.js: -------------------------------------------------------------------------------- 1 | import * as PathUtil from 'fs/util/path-util'; 2 | import * as GlobUtil from 'fs/util/glob-util'; 3 | import { isCommandSet, getCommandNames, getCommandOptDef } from 'emulator-state/command-mapping'; 4 | 5 | /** 6 | * Suggest command names 7 | * @param {Map} cmdMapping command mapping 8 | * @param {string} partialStr partial user input of a command 9 | * @return {array} list of possible text suggestions 10 | */ 11 | export const suggestCommands = (cmdMapping, partialStr) => { 12 | const commandNameSeq = getCommandNames(cmdMapping); 13 | 14 | return [...GlobUtil.globSeq(commandNameSeq, `${partialStr}*`)]; 15 | }; 16 | 17 | /** 18 | * Suggest command options 19 | * @param {Map} cmdMapping command mapping 20 | * @param {string} commandName name of the command user is running 21 | * @param {string} partialStr partial user input of a command (excluding the command name) 22 | * @return {array} list of possible text suggestions 23 | */ 24 | export const suggestCommandOptions = (cmdMapping, commandName, partialStr) => { 25 | if (!isCommandSet(cmdMapping, commandName)) { 26 | return []; 27 | } 28 | 29 | const optDefSeq = getCommandOptDef(cmdMapping, commandName) 30 | .keySeq() 31 | .flatMap(opts => 32 | opts.split(',').map(opt => opt.trim()) 33 | ); 34 | 35 | return [...GlobUtil.globSeq(optDefSeq, `${partialStr}*`)]; 36 | }; 37 | 38 | /** 39 | * Suggest file and folder names from partially completed user input 40 | * @param {Map} fileSystem file system 41 | * @param {string} cwd current working directory 42 | * @param {string} partialStr partial string to base suggestions on (excluding the command name) 43 | * @return {array} list of possible text suggestions 44 | */ 45 | export const suggestFileSystemNames = (fileSystem, cwd, partialStr) => { 46 | const path = PathUtil.toAbsolutePath(partialStr, cwd); 47 | 48 | // complete name of a folder or file 49 | const completeNamePattern = `${path}*`; 50 | // complete child folder name 51 | const completeSubfolderPattern = path === '/' ? '/*' : `${path}*/*`; 52 | // only complete child folders when the path ends with / (which marks a directory path) 53 | const globPattern = partialStr.endsWith('/') ? completeSubfolderPattern : completeNamePattern; 54 | 55 | const childPaths = GlobUtil.globPaths(fileSystem, globPattern); 56 | 57 | if (PathUtil.isAbsPath(partialStr)) { 58 | return [...childPaths]; // absolute paths 59 | } 60 | 61 | return [...childPaths.map(path => { 62 | const pathPartsWithoutTail = PathUtil.toPathParts(partialStr).slice(0, -1); 63 | const newTail = PathUtil.getLastPathPart(path); 64 | 65 | return PathUtil.toPath(pathPartsWithoutTail.concat(newTail)); 66 | })]; // relative paths 67 | }; 68 | -------------------------------------------------------------------------------- /src/fs/operations/base-operations.js: -------------------------------------------------------------------------------- 1 | import * as GlobUtil from 'fs/util/glob-util'; 2 | import * as DirOp from 'fs/operations/directory-operations'; 3 | import * as FileOp from 'fs/operations/file-operations'; 4 | import * as PathUtil from 'fs/util/path-util'; 5 | import { makeError, fsErrorType } from 'fs/fs-error'; 6 | 7 | /** 8 | * Adds a file or directory to a path 9 | * @param {Map} fs file system 10 | * @param {string} pathToAdd path to add the file or directory to 11 | * @param {string} fsElementToAdd file or directory map 12 | * @param {Boolean} [addParentPaths=false] true, if path parent directories should 13 | * be made (if they don't exist) 14 | * @return {object} file system or error 15 | */ 16 | export const add = (fs, pathToAdd, fsElementToAdd, addParentPaths = false) => { 17 | if (fs.has(pathToAdd)) { 18 | return { 19 | err: makeError(fsErrorType.FILE_OR_DIRECTORY_EXISTS) 20 | }; 21 | } 22 | 23 | const parentPaths = PathUtil.getPathBreadCrumbs(pathToAdd).slice(0, -1); 24 | 25 | for (const parentPath of parentPaths) { 26 | if (FileOp.hasFile(fs, parentPath)) { 27 | return { 28 | err: makeError(fsErrorType.NOT_A_DIRECTORY, 29 | `Cannot add path to a file: ${parentPath}`) 30 | }; 31 | } 32 | 33 | if (!fs.has(parentPath) && !addParentPaths) { 34 | return { 35 | err: makeError(fsErrorType.NO_SUCH_DIRECTORY, 36 | `Parent directory does not exist: ${parentPath}`) 37 | }; 38 | } 39 | } 40 | 41 | const addedDirectoryFs = fs.set(pathToAdd, fsElementToAdd); 42 | 43 | return { 44 | fs: addParentPaths ? DirOp.fillGaps(addedDirectoryFs) : addedDirectoryFs 45 | }; 46 | }; 47 | 48 | /** 49 | * Removes a file or directory from a path 50 | * @param {Map} fs file system 51 | * @param {string} pathToRemove removes the path 52 | * @param {Boolean} [isNonEmptyDirectoryRemovable=true] true if non-empty paths can be removed 53 | * @return {object} file system or error 54 | */ 55 | export const remove = (fs, pathToRemove, isNonEmptyDirectoryRemovable = true) => { 56 | if (!fs.has(pathToRemove)) { 57 | return { 58 | err: makeError(fsErrorType.NO_SUCH_FILE_OR_DIRECTORY) 59 | }; 60 | } 61 | 62 | const childPathPattern = pathToRemove === '/' ? '/**' : `${pathToRemove}/**`; 63 | const childPaths = GlobUtil.globPaths(fs, childPathPattern); 64 | 65 | if (!isNonEmptyDirectoryRemovable && !childPaths.isEmpty()) { 66 | return { 67 | err: makeError(fsErrorType.DIRECTORY_NOT_EMPTY) 68 | }; 69 | } 70 | 71 | return { 72 | fs: fs.removeAll(childPaths.concat(pathToRemove)) 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /test/commands/ls.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import EmulatorState from 'emulator-state/EmulatorState'; 6 | import { create as createFileSystem } from 'emulator-state/file-system'; 7 | import ls from 'commands/ls'; 8 | 9 | const state = EmulatorState.create({ 10 | fs: createFileSystem({ 11 | '/': {}, 12 | '/a.txt': { 13 | content: '' 14 | }, 15 | '/b.txt': { 16 | content: '' 17 | }, 18 | '/.hidden.txt': { 19 | content: '' 20 | }, 21 | '/a/subfolder': {}, 22 | '/a/subfolder/c.txt': { 23 | content: '' 24 | }, 25 | '/a/subfolder/d.txt': { 26 | content: '' 27 | }, 28 | '/emptyFolder': {} 29 | }) 30 | }); 31 | 32 | describe('ls', () => { 33 | it('should list folders and files in root: /', () => { 34 | const {output} = ls(state, ['/']); 35 | const expectedListing = ['a.txt', 'a/', 'b.txt', 'emptyFolder/'].join('\n'); 36 | 37 | chai.expect(output.content).to.equal(expectedListing); 38 | }); 39 | 40 | it('should list folders and files in cwd if no argument', () => { 41 | const {output} = ls(state, []); 42 | const expectedListing = ['a.txt', 'a/', 'b.txt', 'emptyFolder/'].join('\n'); 43 | 44 | chai.expect(output.content).to.equal(expectedListing); 45 | }); 46 | 47 | it('should list files in subfolder folder', () => { 48 | const {output} = ls(state, ['/a/subfolder']); 49 | const expectedListing = ['c.txt', 'd.txt'].join('\n'); 50 | 51 | chai.expect(output.content).to.equal(expectedListing); 52 | }); 53 | 54 | it('should list no files in empty folder', () => { 55 | const {output} = ls(state, ['/emptyFolder']); 56 | 57 | chai.expect(output.content).to.equal(''); 58 | }); 59 | 60 | describe('err: no directory', () => { 61 | it('should return error output', () => { 62 | const {output} = ls(state, ['/no/such/dir']); 63 | 64 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 65 | }); 66 | }); 67 | 68 | describe('arg: -a', () => { 69 | it('should list hidden files/folders, implied directories (. and ..), visible files/folders', () => { 70 | const {output} = ls(state, ['/', '-a']); 71 | const expectedListing = [ 72 | '.', '..', '.hidden.txt', 73 | 'a.txt', 'a/', 'b.txt', 'emptyFolder/' 74 | ].join('\n'); 75 | 76 | chai.expect(output.content).to.equal(expectedListing); 77 | }); 78 | }); 79 | 80 | describe('arg: -A', () => { 81 | it('should list hidden and visible files/folders', () => { 82 | const {output} = ls(state, ['/', '-A']); 83 | const expectedListing = [ 84 | '.hidden.txt', 'a.txt', 'a/', 'b.txt', 'emptyFolder/' 85 | ].join('\n'); 86 | 87 | chai.expect(output.content).to.equal(expectedListing); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /test/emulator-state/EmulatorState.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import { Map } from 'immutable'; 4 | 5 | chai.use(chaiImmutable); 6 | 7 | import EmulatorState from 'emulator-state/EmulatorState'; 8 | import { create as createCommandMapping } from 'emulator-state/command-mapping'; 9 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 10 | import { create as createFileSystem } from 'emulator-state/file-system'; 11 | import { create as createHistory } from 'emulator-state/history'; 12 | import { create as createOutputs } from 'emulator-state/outputs'; 13 | 14 | describe('EmulatorState', () => { 15 | describe('constructor', () => { 16 | it('should throw error if not given an immutable data structure', () => { 17 | chai.expect(() => 18 | new EmulatorState({}) 19 | ).to.throw(); 20 | }); 21 | 22 | it('should throw error if given an immutable Map', () => { 23 | // Still not valid emulator state as does not contain the required keys 24 | // The constructor should only be used internally (does not form part of 25 | // the API) so no validation of this is OK 26 | chai.expect(() => 27 | new EmulatorState(new Map()) 28 | ).to.not.throw(); 29 | }); 30 | }); 31 | 32 | describe('create', () => { 33 | it('should create state with default components', () => { 34 | const state = EmulatorState.create({}); 35 | 36 | chai.expect(state.getFileSystem()).to.equal(createFileSystem()); 37 | chai.expect(state.getEnvVariables()).to.equal(createEnvironmentVariables()); 38 | chai.expect(state.getHistory()).to.equal(createHistory()); 39 | chai.expect(state.getOutputs()).to.equal(createOutputs()); 40 | chai.expect(state.getCommandMapping()).to.equal(createCommandMapping()); 41 | }); 42 | 43 | it('should create state using user defined components', () => { 44 | const expectedFS = createFileSystem({ 45 | '/files': {} 46 | }); 47 | const expectedEnvironmentVariables = createEnvironmentVariables({ 48 | 'a': 'b' 49 | }, '/'); 50 | const expectedHistory = createHistory(['a', 'b', 'c']); 51 | const expectedOutputs = createOutputs(); 52 | const expectedCommandMapping = createCommandMapping({ 53 | 'a': { 54 | function: () => {}, 55 | optDef: {'a': 'd'} 56 | } 57 | }); 58 | 59 | const state = EmulatorState.create({ 60 | fs: expectedFS, 61 | environmentVariables: expectedEnvironmentVariables, 62 | history: expectedHistory, 63 | outputs: expectedOutputs, 64 | commandMapping: expectedCommandMapping 65 | }); 66 | 67 | chai.expect(state.getFileSystem()).to.equal(expectedFS); 68 | chai.expect(state.getEnvVariables()).to.equal(expectedEnvironmentVariables); 69 | chai.expect(state.getHistory()).to.equal(expectedHistory); 70 | chai.expect(state.getOutputs()).to.equal(expectedOutputs); 71 | chai.expect(state.getCommandMapping()).to.equal(expectedCommandMapping); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/emulator/plugins/BoundedHistoryIterator.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import BoundedHistoryIterator from 'emulator/plugins/BoundedHistoryIterator'; 4 | import { create as createHistory } from 'emulator-state/history'; 5 | 6 | describe('BoundedHistoryIterator', () => { 7 | it('should go up until last value', () => { 8 | const iterator = new BoundedHistoryIterator(createHistory([1, 2, 3])); 9 | 10 | chai.expect(iterator.up()).to.equal(1); 11 | chai.expect(iterator.up()).to.equal(2); 12 | chai.expect(iterator.up()).to.equal(3); 13 | chai.expect(iterator.up()).to.equal(3); 14 | chai.expect(iterator.up()).to.equal(3); 15 | }); 16 | 17 | it('should go down/up with empty history', () => { 18 | const iterator = new BoundedHistoryIterator(createHistory([])); 19 | 20 | chai.expect(iterator.down()).to.equal(''); 21 | chai.expect(iterator.up()).to.equal(''); 22 | }); 23 | 24 | it('should go down with empty history', () => { 25 | const iterator = new BoundedHistoryIterator(createHistory([])); 26 | 27 | chai.expect(iterator.down()).to.equal(''); 28 | chai.expect(iterator.down()).to.equal(''); 29 | chai.expect(iterator.down()).to.equal(''); 30 | }); 31 | 32 | it('should go up with empty history', () => { 33 | const iterator = new BoundedHistoryIterator(createHistory([])); 34 | 35 | chai.expect(iterator.up()).to.equal(''); 36 | chai.expect(iterator.up()).to.equal(''); 37 | chai.expect(iterator.up()).to.equal(''); 38 | }); 39 | 40 | it('should go down until empty string', () => { 41 | const iterator = new BoundedHistoryIterator(createHistory([1, 2, 3]), 3); 42 | 43 | chai.expect(iterator.down()).to.equal(2); 44 | chai.expect(iterator.down()).to.equal(1); 45 | chai.expect(iterator.down()).to.equal(''); 46 | }); 47 | 48 | it('should go down and up', () => { 49 | const iterator = new BoundedHistoryIterator(createHistory([1, 2, 3, 4, 5])); 50 | 51 | // up sequence 52 | chai.expect(iterator.up()).to.equal(1); 53 | chai.expect(iterator.up()).to.equal(2); 54 | chai.expect(iterator.up()).to.equal(3); 55 | chai.expect(iterator.up()).to.equal(4); 56 | chai.expect(iterator.up()).to.equal(5); 57 | 58 | // down sequence 59 | chai.expect(iterator.down()).to.equal(4); 60 | chai.expect(iterator.down()).to.equal(3); 61 | chai.expect(iterator.down()).to.equal(2); 62 | chai.expect(iterator.down()).to.equal(1); 63 | 64 | // extra down sequence 65 | chai.expect(iterator.down()).to.equal(''); 66 | chai.expect(iterator.down()).to.equal(''); 67 | 68 | // up sequence 69 | chai.expect(iterator.up()).to.equal(1); 70 | chai.expect(iterator.up()).to.equal(2); 71 | chai.expect(iterator.up()).to.equal(3); 72 | chai.expect(iterator.up()).to.equal(4); 73 | chai.expect(iterator.up()).to.equal(5); 74 | 75 | // extra up sequence 76 | chai.expect(iterator.up()).to.equal(5); 77 | chai.expect(iterator.up()).to.equal(5); 78 | 79 | // up/down sequence 80 | chai.expect(iterator.down()).to.equal(4); 81 | chai.expect(iterator.down()).to.equal(3); 82 | chai.expect(iterator.up()).to.equal(4); 83 | chai.expect(iterator.up()).to.equal(5); 84 | chai.expect(iterator.down()).to.equal(4); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/commands/rm.spec.js: -------------------------------------------------------------------------------- 1 | import chai from '../_plugins/state-equality-plugin'; 2 | 3 | import rm from 'commands/rm'; 4 | import { makeFileSystemTestState } from './test-helper'; 5 | 6 | describe('rm', () => { 7 | it('should do nothing if no arguments given', () => { 8 | const returnVal = rm(makeFileSystemTestState(), []); 9 | 10 | chai.expect(returnVal).to.deep.equal({}); 11 | }); 12 | 13 | describe('removing files', () => { 14 | it('should create remove a file with a given path', () => { 15 | const startState = makeFileSystemTestState({ 16 | '/a/file': {content: 'file content'} 17 | }); 18 | 19 | const expectedState = makeFileSystemTestState({ 20 | '/a': {} 21 | }); 22 | 23 | const {state} = rm(startState, ['/a/file']); 24 | 25 | chai.expect(state).toEqualState(expectedState); 26 | }); 27 | }); 28 | 29 | describe('removing directories', () => { 30 | it('should return nothing removing root without no-preserve-root flag', () => { 31 | const startState = makeFileSystemTestState({ 32 | '/a/b': {} 33 | }); 34 | 35 | const returnVal = rm(startState, ['-r', '/']); 36 | 37 | chai.expect(returnVal).to.deep.equal({}); 38 | }); 39 | 40 | it('should create remove root with flag', () => { 41 | const startState = makeFileSystemTestState({ 42 | '/a/b': {} 43 | }); 44 | 45 | const expectedState = makeFileSystemTestState({}); 46 | 47 | const {state} = rm(startState, ['-r', '--no-preserve-root', '/']); 48 | 49 | chai.expect(state).toEqualState(expectedState); 50 | }); 51 | 52 | it('should create remove nested directory', () => { 53 | const startState = makeFileSystemTestState({ 54 | '/a/b': {} 55 | }); 56 | 57 | const expectedState = makeFileSystemTestState({ 58 | '/a': {} 59 | }); 60 | 61 | const {state} = rm(startState, ['-r', '/a/b']); 62 | 63 | chai.expect(state).toEqualState(expectedState); 64 | }); 65 | 66 | it('should create remove children directories', () => { 67 | const startState = makeFileSystemTestState({ 68 | '/doNotDelete/preserveFile': {content: 'should not be removed'}, 69 | '/delete': {}, 70 | '/delete/foo': {}, 71 | '/delete/baz': {}, 72 | '/delete/baz/bar': {} 73 | }); 74 | 75 | const expectedState = makeFileSystemTestState({ 76 | '/doNotDelete/preserveFile': {content: 'should not be removed'} 77 | }); 78 | 79 | const {state} = rm(startState, ['-r', 'delete']); 80 | 81 | chai.expect(state).toEqualState(expectedState); 82 | }); 83 | }); 84 | 85 | describe('err: removing directory without -r flag', () => { 86 | it('should return error if removing directory without -r flag', () => { 87 | const startState = makeFileSystemTestState({ 88 | '/a/b': {} 89 | }); 90 | 91 | const {output} = rm(startState, ['/a/b']); 92 | 93 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 94 | }); 95 | }); 96 | 97 | describe('err: no path', () => { 98 | it('should return error output if no path', () => { 99 | const startState = makeFileSystemTestState(); 100 | 101 | const {output} = rm(startState, ['/noSuchFolder']); 102 | 103 | chai.expect(output.type).to.equal('TEXT_ERROR_OUTPUT'); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/emulator-state/EmulatorState.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import { create as createCommandMapping } from 'emulator-state/command-mapping'; 3 | import { create as createEnvironmentVariables } from 'emulator-state/environment-variables'; 4 | import { create as createFileSystem } from 'emulator-state/file-system'; 5 | import { create as createHistory } from 'emulator-state/history'; 6 | import { create as createOutputs } from 'emulator-state/outputs'; 7 | 8 | const FS_KEY = 'fs'; 9 | const ENVIRONMENT_VARIABLES_KEY = 'environmentVariables'; 10 | const HISTORY_KEY = 'history'; 11 | const OUTPUTS_KEY = 'outputs'; 12 | const COMMAND_MAPPING_KEY = 'commandMapping'; 13 | 14 | export default class EmulatorState { 15 | constructor(immutable) { 16 | if (!immutable || !(immutable instanceof Map)) { 17 | throw new Error('Do not use the constructor directly. Use the static create method.'); 18 | } 19 | 20 | this._immutable = immutable; 21 | } 22 | 23 | /** 24 | * Creates emulator state with defaults 25 | * @return {EmulatorState} default emulator state 26 | */ 27 | static createEmpty() { 28 | return EmulatorState.create({}); 29 | } 30 | 31 | /** 32 | * Creates emulator state using the user's state components, or a default 33 | * fallback if none is provided 34 | * @param {object} optionally contains each component as a key and the component as a value 35 | * @return {EmulatorState} emulator state 36 | */ 37 | static create({ 38 | fs = createFileSystem(), 39 | environmentVariables = createEnvironmentVariables(), 40 | history = createHistory(), 41 | outputs = createOutputs(), 42 | commandMapping = createCommandMapping() 43 | }) { 44 | const stateMap = new Map({ 45 | [FS_KEY]: fs, 46 | [ENVIRONMENT_VARIABLES_KEY]: environmentVariables, 47 | [HISTORY_KEY]: history, 48 | [OUTPUTS_KEY]: outputs, 49 | [COMMAND_MAPPING_KEY]: commandMapping 50 | }); 51 | 52 | return new EmulatorState(stateMap); 53 | } 54 | 55 | getFileSystem() { 56 | return this.getImmutable().get(FS_KEY); 57 | } 58 | 59 | setFileSystem(newFileSystem) { 60 | return new EmulatorState( 61 | this.getImmutable().set(FS_KEY, newFileSystem) 62 | ); 63 | } 64 | 65 | getEnvVariables() { 66 | return this.getImmutable().get(ENVIRONMENT_VARIABLES_KEY); 67 | } 68 | 69 | setEnvVariables(newEnvVariables) { 70 | return new EmulatorState( 71 | this.getImmutable().set(ENVIRONMENT_VARIABLES_KEY, newEnvVariables) 72 | ); 73 | } 74 | 75 | getHistory() { 76 | return this.getImmutable().get(HISTORY_KEY); 77 | } 78 | 79 | setHistory(newHistory) { 80 | return new EmulatorState( 81 | this.getImmutable().set(HISTORY_KEY, newHistory) 82 | ); 83 | } 84 | 85 | getOutputs() { 86 | return this.getImmutable().get(OUTPUTS_KEY); 87 | } 88 | 89 | setOutputs(newOutputs) { 90 | return new EmulatorState( 91 | this.getImmutable().set(OUTPUTS_KEY, newOutputs) 92 | ); 93 | } 94 | 95 | getCommandMapping() { 96 | return this.getImmutable().get(COMMAND_MAPPING_KEY); 97 | } 98 | 99 | setCommandMapping(newCommandMapping) { 100 | return new EmulatorState( 101 | this.getImmutable().set(COMMAND_MAPPING_KEY, newCommandMapping) 102 | ); 103 | } 104 | 105 | getImmutable() { 106 | return this._immutable; 107 | } 108 | 109 | toJS() { 110 | return this._immutable.toJS(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/emulator/plugins/history-keyboard.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import HistoryKeyboardPlugin from 'emulator/plugins/HistoryKeyboardPlugin'; 4 | import EmulatorState from 'emulator-state/EmulatorState'; 5 | import Emulator from 'emulator'; 6 | 7 | describe('HistoryKeyboardPlugin', () => { 8 | let historyKeyboardPlugin; 9 | let emulator; 10 | let emulatorState; 11 | 12 | beforeEach(() => { 13 | emulatorState = EmulatorState.createEmpty(); 14 | historyKeyboardPlugin = new HistoryKeyboardPlugin(emulatorState); 15 | emulator = new Emulator(); 16 | }); 17 | 18 | const executeCommand = (commandStr) => { 19 | emulatorState = emulator.execute( 20 | emulatorState, commandStr, [historyKeyboardPlugin] 21 | ); 22 | }; 23 | 24 | it('should reset history iterator when command run', () => { 25 | executeCommand('1'); 26 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('1'); 27 | 28 | executeCommand('2'); 29 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('2'); 30 | 31 | executeCommand('3'); 32 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('3'); 33 | }); 34 | 35 | it('should go up and down to empty string if no commands run', () => { 36 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal(''); 37 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal(''); 38 | }); 39 | 40 | it('should go up and down with single command run', () => { 41 | executeCommand('only command run'); 42 | 43 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('only command run'); 44 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal(''); 45 | }); 46 | 47 | it('should go up/down sequence with two commands run', () => { 48 | executeCommand('1'); 49 | executeCommand('2'); 50 | 51 | // up, up, down, down 52 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('2'); 53 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('1'); 54 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal('2'); 55 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal(''); 56 | }); 57 | 58 | it('should go up/down interleaved sequence with two commands run', () => { 59 | executeCommand('1'); 60 | executeCommand('2'); 61 | 62 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('2'); 63 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal(''); 64 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('2'); 65 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal(''); 66 | }); 67 | 68 | it('should go up and down interleaved sequence with many commands run', () => { 69 | executeCommand('1'); 70 | executeCommand('2'); 71 | executeCommand('3'); 72 | executeCommand('4'); 73 | executeCommand('5'); 74 | executeCommand('6'); 75 | 76 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('6'); 77 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('5'); 78 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('4'); 79 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('3'); 80 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('2'); 81 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('1'); 82 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('1'); 83 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('1'); 84 | chai.expect(historyKeyboardPlugin.completeUp()).to.equal('1'); 85 | chai.expect(historyKeyboardPlugin.completeDown()).to.equal('2'); 86 | }); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /test/emulator-state/environment-variables.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import { Map } from 'immutable'; 4 | 5 | chai.use(chaiImmutable); 6 | 7 | import * as EnvironmentVariables from 'emulator-state/environment-variables'; 8 | 9 | describe('environment-variables', () => { 10 | const ENV_VARIABLES = EnvironmentVariables.create({ 11 | 'cwd': '/', 12 | 'foo': 'bar' 13 | }); 14 | 15 | describe('create', () => { 16 | it('should create an immutable map', () => { 17 | const envVariables = EnvironmentVariables.create({}, '/'); 18 | 19 | chai.expect(envVariables).to.be.instanceOf(Map); 20 | }); 21 | 22 | it('should create environment variables from JS object', () => { 23 | const envVariables = EnvironmentVariables.create({ 24 | 'a': 'b', 25 | 'c': 'd', 26 | 'cwd': '/' 27 | }); 28 | 29 | chai.expect(envVariables).to.equal(Map({ 30 | 'a': 'b', 31 | 'c': 'd', 32 | 'cwd': '/' 33 | })); 34 | }); 35 | 36 | it('should create environment variables if cwd is separately provided', () => { 37 | const envVariables = EnvironmentVariables.create({ 38 | 'a': 'b', 39 | 'c': 'd' 40 | }, '/path/to/cwd'); 41 | 42 | chai.expect(envVariables).to.equal(Map({ 43 | 'a': 'b', 44 | 'c': 'd', 45 | 'cwd': '/path/to/cwd' 46 | })); 47 | }); 48 | 49 | it('should throw error if no cwd (current working directory) is set', () => { 50 | chai.expect(() => 51 | EnvironmentVariables.create({}, null) 52 | ).to.throw(); 53 | }); 54 | }); 55 | 56 | describe('getEnvironmentVariable', () => { 57 | it('should get value of environment variable', () => { 58 | chai.expect( 59 | EnvironmentVariables.getEnvironmentVariable(ENV_VARIABLES, 'foo') 60 | ).to.equal('bar'); 61 | }); 62 | 63 | it('should return undefined if variable does not exist', () => { 64 | chai.expect( 65 | EnvironmentVariables.getEnvironmentVariable(ENV_VARIABLES, 'noVar') 66 | ).to.equal(undefined); 67 | }); 68 | }); 69 | 70 | describe('setEnvironmentVariable', () => { 71 | it('should set new variable', () => { 72 | const newEnvVariables = EnvironmentVariables.setEnvironmentVariable( 73 | ENV_VARIABLES, 'new key', 'new value' 74 | ); 75 | 76 | chai.expect( 77 | newEnvVariables.get('new key') 78 | ).to.equal('new value'); 79 | }); 80 | 81 | it('should overwrite existing variable', () => { 82 | const newEnvVariables = EnvironmentVariables.setEnvironmentVariable( 83 | ENV_VARIABLES, 'foo', 'new value'); 84 | 85 | chai.expect( 86 | newEnvVariables.get('foo') 87 | ).to.equal('new value'); 88 | }); 89 | }); 90 | 91 | describe('unsetEnvironmentVariable', () => { 92 | it('should remove existing variable', () => { 93 | const newEnvVariables = EnvironmentVariables.unsetEnvironmentVariable( 94 | ENV_VARIABLES, 'foo'); 95 | 96 | chai.expect( 97 | newEnvVariables.get('foo') 98 | ).to.equal(undefined); 99 | }); 100 | 101 | it('should have no effect if variable does not exist', () => { 102 | const newEnvVariables = EnvironmentVariables.unsetEnvironmentVariable( 103 | ENV_VARIABLES, 'noSuchKey' 104 | ); 105 | 106 | chai.expect( 107 | newEnvVariables.get('noSuchKey') 108 | ).to.equal(undefined); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/fs/operations/file-operations.js: -------------------------------------------------------------------------------- 1 | import * as PathUtil from 'fs/util/path-util'; 2 | import * as BaseOp from 'fs/operations/base-operations'; 3 | import { isFile } from 'fs/util/file-util'; 4 | import { hasDirectory } from 'fs/operations/directory-operations'; 5 | import { makeError, fsErrorType } from 'fs/fs-error'; 6 | 7 | /** 8 | * Checks whether a file exists 9 | * @param {Map} fs file system 10 | * @param {string} dirPath directory of the file to check for existence 11 | * @param {string} fileName file name to check for existence 12 | * @return {Boolean} true, if the file exists 13 | */ 14 | export const hasFile = (fs, filePath) => { 15 | if (fs.has(filePath)) { 16 | const possibleFile = fs.get(filePath); 17 | 18 | return isFile(possibleFile); 19 | } 20 | return false; 21 | }; 22 | 23 | /** 24 | * Get a file from the file system 25 | * @param {Map} fs file system 26 | * @param {string} filePath path to file to read 27 | * @return {object} file system or an error 28 | */ 29 | export const readFile = (fs, filePath) => { 30 | if (hasDirectory(fs, filePath)) { 31 | return { 32 | err: makeError(fsErrorType.IS_A_DIRECTORY) 33 | }; 34 | } 35 | 36 | if (!hasFile(fs, filePath)) { 37 | return { 38 | err: makeError(fsErrorType.NO_SUCH_FILE) 39 | }; 40 | } 41 | 42 | return { 43 | file: fs.get(filePath) 44 | }; 45 | }; 46 | 47 | /** 48 | * Write a new file to the file system 49 | * @param {Map} fs file system 50 | * @param {string} filePath path to new file 51 | * @param {Map} file the new file 52 | * @return {object} file system or an error 53 | */ 54 | export const writeFile = (fs, filePath, file) => { 55 | return BaseOp.add(fs, filePath, file); 56 | }; 57 | 58 | /** 59 | * Copies a file from a source directory to a destination directory 60 | * @param {Map} fs file system 61 | * @param {string} sourcePath path to source file (to copy from) 62 | * @param {string} destPath path to destination file (to copy to) 63 | * @return {object} file system or an error 64 | */ 65 | export const copyFile = (fs, sourcePath, destPath) => { 66 | if (!hasFile(fs, sourcePath)) { 67 | return { 68 | err: makeError(fsErrorType.NO_SUCH_FILE, 'Source file does not exist') 69 | }; 70 | } 71 | 72 | const pathParent = PathUtil.getPathParent(destPath); 73 | 74 | if (!hasDirectory(fs, pathParent)) { 75 | return { 76 | err: makeError(fsErrorType.NO_SUCH_DIRECTORY, 'Destination directory does not exist') 77 | }; 78 | } 79 | 80 | if (hasDirectory(fs, destPath)) { 81 | // Copying file to directory without specifying the filename explicitly 82 | const sourceFileName = PathUtil.getLastPathPart(sourcePath); 83 | 84 | destPath = destPath === '/' ? `/${sourceFileName}` : `${destPath}/${sourceFileName}`; 85 | } 86 | 87 | return { 88 | fs: fs.set(destPath, fs.get(sourcePath)) 89 | }; 90 | }; 91 | 92 | /** 93 | * Removes a file from the file system 94 | * @param {Map} fs file system 95 | * @param {string} filePath path to the file to delete 96 | * @return {object} file system or an error 97 | */ 98 | export const deleteFile = (fs, filePath) => { 99 | if (hasDirectory(fs, filePath)) { 100 | return { 101 | err: makeError(fsErrorType.IS_A_DIRECTORY) 102 | }; 103 | } 104 | 105 | if (!hasFile(fs, filePath)) { 106 | return { 107 | err: makeError(fsErrorType.NO_SUCH_FILE) 108 | }; 109 | } 110 | 111 | return BaseOp.remove(fs, filePath); 112 | }; 113 | -------------------------------------------------------------------------------- /src/emulator-state/command-mapping.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import defaultCommandMapping from 'commands'; 3 | 4 | /** 5 | * Links a command name to a function 6 | * @param {Object} [commandMapping={}] default command map 7 | * @return {Map} command mapping 8 | */ 9 | export const create = (commandMapping = defaultCommandMapping) => { 10 | for (const commandName of Object.keys(commandMapping)) { 11 | const command = commandMapping[commandName]; 12 | 13 | if (!command.hasOwnProperty('function')) { 14 | throw new Error(`Failed to create command mapping: missing command function for ${commandName}`); 15 | } 16 | 17 | if (!command.hasOwnProperty('optDef')) { 18 | throw new Error(`Failed to create command mapping: missing option definition (optDef) for ${commandName}`); 19 | } 20 | } 21 | 22 | return fromJS(commandMapping); 23 | }; 24 | 25 | /** 26 | * Checks if a comand has been defined with a function in the command mapping 27 | * @param {Map} commandMapping command mapping 28 | * @param {string} commandName command name to check if available 29 | * @return {Boolean} true, if the command is available 30 | */ 31 | export const isCommandSet = (commandMapping, commandName) => { 32 | return commandMapping.has(commandName); 33 | }; 34 | 35 | /** 36 | * Set a command function with a key of the command name into the command mapping 37 | * @param {Map} commandMapping command mapping 38 | * @param {string} commandName name of the function 39 | * @param {function} commandFn command function 40 | * @param {object} optDef option definition (optional) 41 | * @return {Map} command mapping 42 | */ 43 | export const setCommand = (commandMapping, commandName, commandFn, optDef) => { 44 | if (commandFn === undefined) { 45 | throw new Error(`Cannot set ${commandName} command without function`); 46 | } 47 | 48 | if (optDef === undefined) { 49 | throw new Error(`Cannot set ${commandName} command without optDef (pass in {} if the command takes no options)`); 50 | } 51 | 52 | return commandMapping.set(commandName, fromJS({ 53 | 'function': commandFn, 54 | 'optDef': optDef 55 | })); 56 | }; 57 | 58 | /** 59 | * Removes a command name and its function from a command mapping 60 | * @param {Map} commandMapping command mapping 61 | * @param {string} commandName name of command to remove 62 | * @return {Map} command mapping 63 | */ 64 | export const unsetCommand = (commandMapping, commandName) => { 65 | return commandMapping.delete(commandName); 66 | }; 67 | 68 | /** 69 | * Gets the function of a command based on its command name (the key) from the 70 | * command mapping 71 | * @param {Map} commandMapping command mapping 72 | * @param {string} commandName name of command 73 | * @return {function} command function 74 | */ 75 | export const getCommandFn = (commandMapping, commandName) => { 76 | if (commandMapping.has(commandName)) { 77 | return commandMapping.get(commandName).get('function'); 78 | } 79 | 80 | return undefined; 81 | }; 82 | 83 | /** 84 | * Gets the option definition of a command based on its command name 85 | * @param {Map} commandMapping command mapping 86 | * @param {string} commandName name of command 87 | * @return {Map} option definition 88 | */ 89 | export const getCommandOptDef = (commandMapping, commandName) => { 90 | if (commandMapping.has(commandName)) { 91 | return commandMapping.get(commandName).get('optDef'); 92 | } 93 | 94 | return undefined; 95 | }; 96 | 97 | /** 98 | * Gets command names 99 | * @param {Map} commandMapping command mapping 100 | * @return {Seq} sequence of command names 101 | */ 102 | export const getCommandNames = (commandMapping) => { 103 | return commandMapping.keySeq(); 104 | }; 105 | -------------------------------------------------------------------------------- /test/os/util/file-util.spec.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import chai from 'chai'; 3 | import chaiImmutable from 'chai-immutable'; 4 | chai.use(chaiImmutable); 5 | 6 | import * as FileUtil from 'fs/util/file-util'; 7 | 8 | describe('file-util', () => { 9 | describe('isFile', () => { 10 | it('should exist', () => { 11 | chai.assert.isFunction(FileUtil.isFile); 12 | }); 13 | 14 | it('should handle directory object', () => { 15 | const dir = fromJS({}); 16 | 17 | chai.expect(FileUtil.isFile(dir)).to.equal(false); 18 | }); 19 | 20 | it('should handle directory with metadata', () => { 21 | const dir = fromJS({ 22 | metadataKey: 'abc' 23 | }); 24 | 25 | chai.expect(FileUtil.isFile(dir)).to.equal(false); 26 | }); 27 | 28 | it('should handle file object with non-empty content', () => { 29 | const nonEmptyFileObject = fromJS({ 30 | content: 'file content' 31 | }); 32 | 33 | chai.expect(FileUtil.isFile(nonEmptyFileObject)).to.equal(true); 34 | }); 35 | 36 | it('should handle file object with empty content', () => { 37 | const emptyFileObject = fromJS({ 38 | content: '' 39 | }); 40 | 41 | chai.expect(FileUtil.isFile(emptyFileObject)).to.equal(true); 42 | }); 43 | }); 44 | 45 | describe('isDirectory', () => { 46 | it('should exist', () => { 47 | chai.assert.isFunction(FileUtil.isDirectory); 48 | }); 49 | 50 | it('should handle directory object', () => { 51 | const dir = fromJS({}); 52 | 53 | chai.expect(FileUtil.isDirectory(dir)).to.equal(true); 54 | }); 55 | 56 | it('should handle directory with metadata', () => { 57 | const dir = fromJS({ 58 | metadataKey: 'abc' 59 | }); 60 | 61 | chai.expect(FileUtil.isDirectory(dir)).to.equal(true); 62 | }); 63 | 64 | it('should handle file object with empty content', () => { 65 | const emptyFileObject = fromJS({ 66 | content: '' 67 | }); 68 | 69 | chai.expect(FileUtil.isDirectory(emptyFileObject)).to.equal(false); 70 | }); 71 | }); 72 | 73 | describe('makeFile', () => { 74 | it('should exist', () => { 75 | chai.assert.isFunction(FileUtil.makeFile); 76 | }); 77 | 78 | it('should make empty file', () => { 79 | const file = FileUtil.makeFile(); 80 | const expectedFile = fromJS({ 81 | content: '' 82 | }); 83 | 84 | chai.expect(file).to.equal(expectedFile); 85 | }); 86 | 87 | it('should make non-empty file', () => { 88 | const file = FileUtil.makeFile('hello world'); 89 | const expectedFile = fromJS({ 90 | content: 'hello world' 91 | }); 92 | 93 | chai.expect(file).to.equal(expectedFile); 94 | }); 95 | 96 | it('should make file without metadata', () => { 97 | const file = FileUtil.makeFile('hello world', {}); 98 | const expectedFile = fromJS({ 99 | content: 'hello world' 100 | }); 101 | 102 | chai.expect(file).to.equal(expectedFile); 103 | }); 104 | 105 | it('should make file with metadata', () => { 106 | const file = FileUtil.makeFile('hello world', { 107 | metadataKey: 'meta value', 108 | permission: 666 109 | }); 110 | const expectedFile = fromJS({ 111 | content: 'hello world', 112 | metadataKey: 'meta value', 113 | permission: 666 114 | }); 115 | 116 | chai.expect(file).to.equal(expectedFile); 117 | }); 118 | }); 119 | 120 | describe('makeDirectory', () => { 121 | it('should exist', () => { 122 | chai.assert.isFunction(FileUtil.makeDirectory); 123 | }); 124 | 125 | it('should make empty directory', () => { 126 | const directory = FileUtil.makeDirectory(); 127 | const expectedDirectory = fromJS({}); 128 | 129 | chai.expect(directory).to.equal(expectedDirectory); 130 | }); 131 | 132 | it('should make directory with metadata', () => { 133 | const directory = FileUtil.makeDirectory({ 134 | metadataKey: 'meta value', 135 | permission: 666 136 | }); 137 | 138 | const expectedDirectory = fromJS({ 139 | metadataKey: 'meta value', 140 | permission: 666 141 | }); 142 | 143 | chai.expect(directory).to.equal(expectedDirectory); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/os/util/glob-util.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { Seq, fromJS } from 'immutable'; 3 | 4 | import * as GlobUtil from 'fs/util/glob-util'; 5 | 6 | describe('glob-util', () => { 7 | describe('glob', () => { 8 | it('should exist', () => { 9 | chai.assert.isFunction(GlobUtil.glob); 10 | }); 11 | 12 | // * matches any character except / 13 | it('should match files/folders in directory with *', () => { 14 | chai.expect(GlobUtil.glob('/a/b/c', '/a/b/*')).to.equal(true); 15 | chai.expect(GlobUtil.glob('/a/b/c/d', '/a/b/*')).to.equal(false); 16 | chai.expect(GlobUtil.glob('/a/b', '/a/b/*')).to.equal(false); 17 | }); 18 | 19 | it('should match incomplete file/folder names', () => { 20 | chai.expect(GlobUtil.glob('/a/bbb/c', '/a/b*/*')).to.equal(true); 21 | chai.expect(GlobUtil.glob('/a/bbb/c', '/a/b*/c')).to.equal(true); 22 | }); 23 | 24 | // ** matches any character including / 25 | it('should match multiple subdirectory levels with **', () => { 26 | chai.expect(GlobUtil.glob('/a/b/c', '/a/b/**')).to.equal(true); 27 | chai.expect(GlobUtil.glob('/a/b/c/d', '/a/b/**')).to.equal(true); 28 | chai.expect(GlobUtil.glob('/a/b', '/a/b/**')).to.equal(false); 29 | }); 30 | }); 31 | 32 | describe('globSeq', () => { 33 | it('should exist', () => { 34 | chai.assert.isFunction(GlobUtil.globSeq); 35 | }); 36 | 37 | it('should empty sequence', () => { 38 | const [...paths] = GlobUtil.globSeq( 39 | Seq([]), 40 | '/*' 41 | ); 42 | 43 | chai.expect(paths).to.deep.equal([]); 44 | }); 45 | 46 | it('should match sequence', () => { 47 | const [...paths] = GlobUtil.globSeq( 48 | Seq(['/', '/a', '/b']), 49 | '/*' 50 | ); 51 | 52 | chai.expect(paths).to.deep.equal(['/a', '/b']); 53 | }); 54 | }); 55 | 56 | describe('globPaths', () => { 57 | const mockFS = fromJS({ 58 | '/': {}, 59 | '/a': {}, 60 | '/a/foo': {}, 61 | '/a/foo/bar': {} 62 | }); 63 | 64 | it('should exist', () => { 65 | chai.assert.isFunction(GlobUtil.globPaths); 66 | }); 67 | 68 | it('should match immediate children with * from root directory', () => { 69 | const [...paths] = GlobUtil.globPaths(mockFS, '/*'); 70 | 71 | chai.expect(paths).to.deep.equal(['/a']); 72 | }); 73 | 74 | it('should match immediate children with * from subfolder', () => { 75 | const [...paths] = GlobUtil.globPaths(mockFS, '/a/*'); 76 | 77 | chai.expect(paths).to.deep.equal(['/a/foo']); 78 | }); 79 | 80 | it('should match multiple levels with ** from subfolder', () => { 81 | const [...paths] = GlobUtil.globPaths(mockFS, '/a/**'); 82 | 83 | chai.expect(paths).to.deep.equal(['/a/foo', '/a/foo/bar']); 84 | }); 85 | 86 | it('should match hidden file', () => { 87 | const [...paths] = GlobUtil.globPaths(fromJS({ 88 | '/.hidden': {} 89 | }), '/*'); 90 | 91 | chai.expect(paths).to.deep.equal(['/.hidden']); 92 | }); 93 | }); 94 | 95 | describe('captureGlobPaths', () => { 96 | const mockFS = fromJS({ 97 | '/': {}, 98 | '/a': {}, 99 | '/a/foo': {}, 100 | '/a/foo/bar': {} 101 | }); 102 | 103 | it('should exist', () => { 104 | chai.assert.isFunction(GlobUtil.captureGlobPaths); 105 | }); 106 | 107 | it('should match immediate children names with * from root directory', () => { 108 | const [...paths] = GlobUtil.captureGlobPaths(mockFS, '/*'); 109 | 110 | chai.expect(paths).to.deep.equal(['a']); 111 | }); 112 | 113 | it('should match immediate children names with * from subfolder', () => { 114 | const [...paths] = GlobUtil.captureGlobPaths(mockFS, '/a/*'); 115 | 116 | chai.expect(paths).to.deep.equal(['foo']); 117 | }); 118 | 119 | it('should match multiple level names with ** from subfolder', () => { 120 | const [...paths] = GlobUtil.captureGlobPaths(mockFS, '/a/**'); 121 | 122 | chai.expect(paths).to.deep.equal(['foo', 'foo/bar']); 123 | }); 124 | 125 | it('should match hidden file', () => { 126 | const [...paths] = GlobUtil.captureGlobPaths(fromJS({ 127 | '/.hidden': {} 128 | }), '/*'); 129 | 130 | chai.expect(paths).to.deep.equal(['.hidden']); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /test/os/operations/base-operations.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import * as BaseOp from 'fs/operations/base-operations'; 6 | import { fsErrorType } from 'fs/fs-error'; 7 | import { create as createFileSystem } from 'emulator-state/file-system'; 8 | 9 | describe('base-operations', () => { 10 | describe('add', () => { 11 | it('should add file system object to root', () => { 12 | const emptyFS = createFileSystem({}); 13 | const {fs} = BaseOp.add(emptyFS, '/', 'added path'); 14 | 15 | chai.expect(fs).to.equal( 16 | createFileSystem({'/': 'added path'}) 17 | ); 18 | }); 19 | 20 | it('should add file system object to nested path', () => { 21 | const emptyFS = createFileSystem({'/': {}}); 22 | const {fs} = BaseOp.add(emptyFS, '/a', 'added path'); 23 | 24 | chai.expect(fs).to.equal( 25 | createFileSystem({'/a': 'added path'}) 26 | ); 27 | }); 28 | 29 | it('should return error if root path already exists', () => { 30 | const rootFS = createFileSystem({'/': {}}); 31 | 32 | const {err} = BaseOp.add(rootFS, '/', 'added path'); 33 | 34 | chai.expect(err.type).to.equal(fsErrorType.FILE_OR_DIRECTORY_EXISTS); 35 | }); 36 | 37 | it('should return error adding path (or directory) to a file', () => { 38 | const rootFS = createFileSystem({'/folderName/fileName': {content: 'file content'}}); 39 | 40 | const { 41 | err: withAddParentPathsErr 42 | } = BaseOp.add(rootFS, '/folderName/fileName/newFolder', 'added path'); 43 | 44 | const { 45 | err: withoutAddParentPathsErr 46 | } = BaseOp.add(rootFS, '/folderName/fileName/newFolder', 'added path', true); 47 | 48 | chai.expect(withAddParentPathsErr.type).to.equal(fsErrorType.NOT_A_DIRECTORY); 49 | chai.expect(withoutAddParentPathsErr.type).to.equal(fsErrorType.NOT_A_DIRECTORY); 50 | }); 51 | 52 | it('should return error if non-root path already exists', () => { 53 | const fs = createFileSystem({'/a/b/c': {}}); 54 | 55 | const {err} = BaseOp.add(fs, '/a/b', 'added path'); 56 | 57 | chai.expect(err.type).to.equal(fsErrorType.FILE_OR_DIRECTORY_EXISTS); 58 | }); 59 | 60 | it('should return error if parent path does not exist', () => { 61 | const fs = createFileSystem({'/a': {}}); 62 | 63 | const {err} = BaseOp.add(fs, '/a/noParent/newDir', 'added path'); 64 | 65 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_DIRECTORY); 66 | }); 67 | 68 | it('should add path if parent path does not exist but option set to create parent paths', () => { 69 | const fs = createFileSystem({'/a': {}}); 70 | 71 | const {fs: newFS} = BaseOp.add(fs, '/a/noParent/newDir', 'added path', true); 72 | 73 | chai.expect(newFS).to.equal( 74 | createFileSystem({ 75 | '/a': {}, 76 | '/a/noParent': {}, 77 | '/a/noParent/newDir': 'added path' 78 | }) 79 | ); 80 | }); 81 | }); 82 | 83 | describe('remove', () => { 84 | const removalFS = createFileSystem({ 85 | '/': {}, 86 | '/subdir': {}, 87 | '/subdir/file': {content: 'file content'} 88 | }); 89 | 90 | it('should remove root', () => { 91 | const {fs} = BaseOp.remove(removalFS, '/'); 92 | 93 | chai.expect(fs).to.equal(createFileSystem({})); 94 | }); 95 | 96 | it('should remove file', () => { 97 | const {fs} = BaseOp.remove(removalFS, '/subdir/file'); 98 | 99 | chai.expect(fs).to.equal(createFileSystem({ 100 | '/': {}, 101 | '/subdir': {} 102 | })); 103 | }); 104 | 105 | it('should remove subdirectory', () => { 106 | const {fs} = BaseOp.remove(removalFS, '/subdir'); 107 | 108 | chai.expect(fs).to.equal(createFileSystem({ 109 | '/': {} 110 | })); 111 | }); 112 | 113 | it('should return error if path does not exist', () => { 114 | const {err} = BaseOp.remove(removalFS, '/noSuchDirectory'); 115 | 116 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE_OR_DIRECTORY); 117 | }); 118 | 119 | it('should return error cannot remove non-empty directories', () => { 120 | const {err} = BaseOp.remove(removalFS, '/subdir', false); 121 | 122 | chai.expect(err.type).to.equal(fsErrorType.DIRECTORY_NOT_EMPTY); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/commands/cp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copies a file/directory to another file/directory 3 | * Usage: cp file new-file 4 | */ 5 | import parseOptions from 'parser/option-parser'; 6 | import * as FileOp from 'fs/operations-with-permissions/file-operations'; 7 | import * as DirectoryOp from 'fs/operations-with-permissions/directory-operations'; 8 | import * as PathUtil from 'fs/util/path-util'; 9 | import * as OutputFactory from 'emulator-output/output-factory'; 10 | import * as FileUtil from 'fs/util/file-util'; 11 | import { makeError, fsErrorType } from 'fs/fs-error'; 12 | import { resolvePath } from 'emulator-state/util'; 13 | 14 | /** 15 | * Copy from a source file into a directory or another file. 16 | * 17 | * A trailing slash / can be used in the destination to explicitly state the 18 | * destination is a directory and not a file. 19 | * @param {Map} state emulator state 20 | * @param {string} srcPath source file path 21 | * @param {string} destPath destination file or destination directory path 22 | * @param {Boolean} isTrailingPathDest true if the destPath ended in a / 23 | * @return {object} cp command return object 24 | */ 25 | const copySourceFile = (state, srcPath, destPath, isTrailingPathDest) => { 26 | const fs = state.getFileSystem(); 27 | 28 | if (isTrailingPathDest && !DirectoryOp.hasDirectory(fs, destPath)) { 29 | const dirAtTrailingPathNonExistentErr = makeError(fsErrorType.NO_SUCH_DIRECTORY); 30 | 31 | return { 32 | output: OutputFactory.makeErrorOutput(dirAtTrailingPathNonExistentErr) 33 | }; 34 | } 35 | 36 | const {fs: copiedFS, err} = FileOp.copyFile(fs, srcPath, destPath); 37 | 38 | if (err) { 39 | return { 40 | output: OutputFactory.makeErrorOutput(err) 41 | }; 42 | } 43 | 44 | return { 45 | state: state.setFileSystem(copiedFS) 46 | }; 47 | }; 48 | 49 | /** 50 | * Copies a directory into another directory 51 | * 52 | * When the destination path exists, cp copies the source FOLDER into the 53 | * destination. 54 | * 55 | * When the destination DOES NOT exist, cp copies the source FILES into the 56 | * destination. 57 | * @param {Map} state emulator state 58 | * @param {string} srcPath source directory path (copy from) 59 | * @param {string} destPath destination directory path (copy to) 60 | * @return {object} cp command return object 61 | */ 62 | const copySourceDirectory = (state, srcPath, destPath) => { 63 | if (DirectoryOp.hasDirectory(state.getFileSystem(), destPath)) { 64 | const lastPathComponent = PathUtil.getLastPathPart(srcPath); 65 | 66 | // Remap dest to copy source FOLDER, as destination path exists 67 | if (lastPathComponent !== '/') { 68 | destPath = `${destPath}/${lastPathComponent}`; 69 | } 70 | } 71 | 72 | // Make directory to copy into, if it doesn't already exist 73 | if (!DirectoryOp.hasDirectory(state.getFileSystem(), destPath)) { 74 | const emptyDir = FileUtil.makeDirectory(); 75 | const {fs, err} = DirectoryOp.addDirectory(state.getFileSystem(), destPath, emptyDir, false); 76 | 77 | state = state.setFileSystem(fs); 78 | 79 | if (err) { 80 | return { 81 | output: OutputFactory.makeErrorOutput(err) 82 | }; 83 | } 84 | } 85 | 86 | const {fs, err} = DirectoryOp.copyDirectory(state.getFileSystem(), srcPath, destPath); 87 | 88 | if (err) { 89 | return { 90 | output: OutputFactory.makeErrorOutput(err) 91 | }; 92 | } 93 | 94 | return { 95 | state: state.setFileSystem(fs) 96 | }; 97 | }; 98 | 99 | export const optDef = { 100 | '-r, --recursive': '' // required to copy directories 101 | }; 102 | 103 | export default (state, commandOptions) => { 104 | const {argv, options} = parseOptions(commandOptions, optDef); 105 | 106 | if (argv.length < 2) { 107 | return {}; 108 | } 109 | 110 | const srcPath = resolvePath(state, argv[0]); 111 | const destPath = resolvePath(state, argv[1]); 112 | const isTrailingDestPath = PathUtil.isTrailingPath(argv[1]); 113 | 114 | if (srcPath === destPath) { 115 | return { 116 | output: OutputFactory.makeTextOutput('Source and destination are the same (not copied).') 117 | }; 118 | } 119 | 120 | if (options.recursive) { 121 | return copySourceDirectory(state, srcPath, destPath); 122 | } 123 | 124 | return copySourceFile(state, srcPath, destPath, isTrailingDestPath); 125 | }; 126 | -------------------------------------------------------------------------------- /test/os/operations-with-permissions/file-operations.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import spies from 'chai-spies'; 4 | 5 | chai.use(chaiImmutable); 6 | chai.use(spies); 7 | 8 | const sandbox = chai.spy.sandbox(); 9 | 10 | import FS from '../mocks/mock-fs-permissions'; 11 | import * as FileOpsPermissioned from 'fs/operations-with-permissions/file-operations'; 12 | import * as FileOps from 'fs/operations/file-operations'; 13 | import * as FileUtil from 'fs/util/file-util'; 14 | import { fsErrorType } from 'fs/fs-error'; 15 | 16 | describe('file-operations with modification permissions', () => { 17 | before(() => { 18 | sandbox.on(FileOps, [ 19 | 'hasFile', 20 | 'readFile', 21 | 'writeFile', 22 | 'copyFile', 23 | 'deleteFile' 24 | ]); 25 | }); 26 | 27 | describe('hasFile', () => { 28 | it('should use non-permissioned operation with same arguments', () => { 29 | const args = [FS, '/can-modify/can-modify-file']; 30 | 31 | FileOpsPermissioned.hasFile(...args); 32 | chai.expect(FileOps.hasFile).to.have.been.called.with(...args); 33 | }); 34 | }); 35 | 36 | describe('readFile', () => { 37 | it('should use non-permissioned operation with same arguments', () => { 38 | const args = [FS, '/can-modify/can-modify-file']; 39 | 40 | FileOpsPermissioned.readFile(...args); 41 | chai.expect(FileOps.readFile).to.have.been.called.with(...args); 42 | }); 43 | }); 44 | 45 | describe('writeFile', () => { 46 | const NEW_FILE = FileUtil.makeFile(); 47 | 48 | it('should use non-permissioned operation with same arguments', () => { 49 | const args = [FS, '/can-modify/new-file', NEW_FILE]; 50 | 51 | FileOpsPermissioned.writeFile(...args); 52 | chai.expect(FileOps.writeFile).to.have.been.called.with(...args); 53 | }); 54 | 55 | it('should return permissions error if cannot modify directory', () => { 56 | const {err} = FileOpsPermissioned.writeFile( 57 | FS, '/cannot-modify/new-file', NEW_FILE 58 | ); 59 | 60 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 61 | }); 62 | 63 | it('should return permissions error if cannot modify file', () => { 64 | const {err} = FileOpsPermissioned.writeFile( 65 | FS, '/can-modify/cannot-modify-file', NEW_FILE 66 | ); 67 | 68 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 69 | }); 70 | }); 71 | 72 | describe('copyFile', () => { 73 | it('should use non-permissioned operation with same arguments', () => { 74 | const args = [FS, '/can-modify/can-modify-file', '/can-modify/dest-file']; 75 | 76 | FileOpsPermissioned.copyFile(...args); 77 | chai.expect(FileOps.copyFile).to.have.been.called.with(...args); 78 | }); 79 | 80 | it('should return permissions error if cannot modify source directory', () => { 81 | const {err} = FileOpsPermissioned.copyFile( 82 | FS, '/cannot-modify/new-file', '/can-modify/dest-file' 83 | ); 84 | 85 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 86 | }); 87 | 88 | it('should return permissions error if cannot modify source file', () => { 89 | const {err} = FileOpsPermissioned.copyFile( 90 | FS, '/can-modify/cannot-modify-file', '/can-modify/dest-file' 91 | ); 92 | 93 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 94 | }); 95 | 96 | it('should return permissions error if cannot modify dest directory', () => { 97 | const {err} = FileOpsPermissioned.copyFile( 98 | FS, '/can-modify/new-file', '/cannot-modify/dest-file' 99 | ); 100 | 101 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 102 | }); 103 | 104 | it('should return permissions error if cannot modify dest file', () => { 105 | const {err} = FileOpsPermissioned.copyFile( 106 | FS, '/can-modify/new-file', '/can-modify/cannot-modify-file' 107 | ); 108 | 109 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 110 | }); 111 | }); 112 | 113 | describe('deleteFile', () => { 114 | it('should use non-permissioned operation with same arguments', () => { 115 | const args = [FS, '/can-modify/can-modify-file']; 116 | 117 | FileOpsPermissioned.deleteFile(...args); 118 | chai.expect(FileOps.deleteFile).to.have.been.called.with(...args); 119 | }); 120 | 121 | it('should return permissions error if cannot modify directory', () => { 122 | const {err} = FileOpsPermissioned.deleteFile( 123 | FS, '/cannot-modify/can-modify-file' 124 | ); 125 | 126 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 127 | }); 128 | 129 | it('should return permissions error if cannot modify file', () => { 130 | const {err} = FileOpsPermissioned.deleteFile( 131 | FS, '/can-modify/cannot-modify-file' 132 | ); 133 | 134 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/parser/command-parser.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import parseCommands from 'parser/command-parser'; 4 | 5 | describe('command-parser', () => { 6 | it('should parse command with no args', () => { 7 | const parsedCommands = parseCommands('ls'); 8 | 9 | chai.expect(parsedCommands).to.deep.equal([ 10 | { 11 | commandName: 'ls', 12 | commandOptions: [] 13 | } 14 | ]); 15 | }); 16 | 17 | it('should parse command with single anonymous arg', () => { 18 | const parsedCommands = parseCommands('ls a'); 19 | 20 | chai.expect(parsedCommands).to.deep.equal([ 21 | { 22 | commandName: 'ls', 23 | commandOptions: ['a'] 24 | } 25 | ]); 26 | }); 27 | 28 | it('should parse command with text after command name', () => { 29 | const parsedCommands = parseCommands('echo hello world!'); 30 | 31 | chai.expect(parsedCommands).to.deep.equal([ 32 | { 33 | commandName: 'echo', 34 | commandOptions: ['hello', 'world!'] 35 | } 36 | ]); 37 | }); 38 | 39 | it('should parse command with multiple anonymous args', () => { 40 | const parsedCommands = parseCommands('ls a b c'); 41 | 42 | chai.expect(parsedCommands).to.deep.equal([ 43 | { 44 | commandName: 'ls', 45 | commandOptions: ['a', 'b', 'c'] 46 | } 47 | ]); 48 | }); 49 | 50 | it('should parse command with single flag', () => { 51 | const parsedCommands = parseCommands('foo --help'); 52 | 53 | chai.expect(parsedCommands).to.deep.equal([ 54 | { 55 | commandName: 'foo', 56 | commandOptions: ['--help'] 57 | } 58 | ]); 59 | }); 60 | 61 | it('should parse command with excess spaces', () => { 62 | const parsedCommands = parseCommands(' a --b --c'); 63 | 64 | chai.expect(parsedCommands).to.deep.equal([ 65 | { 66 | commandName: 'a', 67 | commandOptions: ['--b', '--c'] 68 | } 69 | ]); 70 | }); 71 | 72 | it('should parse command with tabs instead of spaces', () => { 73 | const parsedCommands = parseCommands('\u00a0a\u00a0--b\u00a0--c'); 74 | 75 | chai.expect(parsedCommands).to.deep.equal([ 76 | { 77 | commandName: 'a', 78 | commandOptions: ['--b', '--c'] 79 | } 80 | ]); 81 | }); 82 | 83 | it('should parse command with excess tabs', () => { 84 | const parsedCommands = parseCommands('\u00a0\u00a0\u00a0\u00a0\u00a0a\u00a0\u00a0--b\u00a0\u00a0--c'); 85 | 86 | chai.expect(parsedCommands).to.deep.equal([ 87 | { 88 | commandName: 'a', 89 | commandOptions: ['--b', '--c'] 90 | } 91 | ]); 92 | }); 93 | 94 | it('should parse command with mixed flag and args', () => { 95 | const parsedCommands = parseCommands('foo --help a/b/c hello.txt -a -h'); 96 | 97 | chai.expect(parsedCommands).to.deep.equal([ 98 | { 99 | commandName: 'foo', 100 | commandOptions: ['--help', 'a/b/c', 'hello.txt', '-a', '-h'] 101 | } 102 | ]); 103 | }); 104 | 105 | it('should parse multiple commands with && and no args', () => { 106 | const parsedCommands = parseCommands('foo && bar'); 107 | 108 | chai.expect(parsedCommands).to.deep.equal([ 109 | { 110 | commandName: 'foo', 111 | commandOptions: [] 112 | }, { 113 | commandName: 'bar', 114 | commandOptions: [] 115 | } 116 | ]); 117 | }); 118 | 119 | it('should parse multiple commands with && and args', () => { 120 | const parsedCommands = parseCommands('foo -a --help && bar --help -b'); 121 | 122 | chai.expect(parsedCommands).to.deep.equal([ 123 | { 124 | commandName: 'foo', 125 | commandOptions: ['-a', '--help'] 126 | }, { 127 | commandName: 'bar', 128 | commandOptions: ['--help', '-b'] 129 | } 130 | ]); 131 | }); 132 | 133 | it('should parse multiple commands with ; and no args', () => { 134 | const parsedCommands = parseCommands('foo; bar'); 135 | 136 | chai.expect(parsedCommands).to.deep.equal([ 137 | { 138 | commandName: 'foo', 139 | commandOptions: [] 140 | }, { 141 | commandName: 'bar', 142 | commandOptions: [] 143 | } 144 | ]); 145 | }); 146 | 147 | it('should parse multiple commands with ; and args', () => { 148 | const parsedCommands = parseCommands('foo -a --help; bar --help -b'); 149 | 150 | chai.expect(parsedCommands).to.deep.equal([ 151 | { 152 | commandName: 'foo', 153 | commandOptions: ['-a', '--help'] 154 | }, { 155 | commandName: 'bar', 156 | commandOptions: ['--help', '-b'] 157 | } 158 | ]); 159 | }); 160 | 161 | it('should parse multiple commands with excess space', () => { 162 | const parsedCommands = parseCommands('foo -a --help ; bar --help -b'); 163 | 164 | chai.expect(parsedCommands).to.deep.equal([ 165 | { 166 | commandName: 'foo', 167 | commandOptions: ['-a', '--help'] 168 | }, { 169 | commandName: 'bar', 170 | commandOptions: ['--help', '-b'] 171 | } 172 | ]); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/fs/util/path-util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests if a path is a trailing path. 3 | * 4 | * A trailing path ends with a trailing slash (/) and excludes the root 5 | * directory (/). 6 | * @param {string} path path with or without a trailing slash 7 | * @return {Boolean} true, if the path is a trailing path 8 | */ 9 | export const isTrailingPath = (path) => { 10 | return path.endsWith('/') && path !== '/'; 11 | }; 12 | 13 | /** 14 | * Removes a trailing slash (/) from a path 15 | * @param {string} path path with or without a trailing / 16 | * @return {string} path without trailing / 17 | */ 18 | export const removeTrailingSeparator = (path) => { 19 | if (path.endsWith('/') && path !== '/') { 20 | return path.slice(0, -1); 21 | } 22 | return path; 23 | }; 24 | 25 | /** 26 | * Tests if a path is absolute 27 | * @param {string} path 28 | * @return {boolean} 29 | */ 30 | export const isAbsPath = (path) => { 31 | return path.startsWith('/'); 32 | }; 33 | 34 | /** 35 | * Converts a path to an ordered array of folders and files. 36 | * 37 | * Example: Parts of '/a/b/c/e.txt' has parts of ['/', 'a', 'b', 'c', 'e.txt'] 38 | * 39 | * A relative path splits parts at /. An absolute path splits at / and also 40 | * considers the root directory (/) as a part of the path. 41 | * @param {string} path [description] 42 | * @return {array} list of path parts 43 | */ 44 | export const toPathParts = (path) => { 45 | if (path === '/') { 46 | return ['/']; 47 | }; 48 | 49 | path = removeTrailingSeparator(path); 50 | const pathParts = path.split('/'); 51 | 52 | if (isAbsPath(path)) { 53 | const [, ...nonRootPathParts] = pathParts; 54 | 55 | return ['/', ...nonRootPathParts]; 56 | } 57 | 58 | return pathParts; 59 | }; 60 | 61 | /** 62 | * Converts path parts back to a path 63 | * @param {array} pathParts path parts 64 | * @return {string} path 65 | */ 66 | export const toPath = (pathParts) => { 67 | if (pathParts[0] === '/') { // absolute path 68 | const [, ...nonRootPathParts] = pathParts; 69 | 70 | return `/${nonRootPathParts.join('/')}`; 71 | } 72 | 73 | return pathParts.join('/'); 74 | }; 75 | 76 | /** 77 | * Find breadcrumb paths, i.e. all paths that need to be walked to get to 78 | * the specified path 79 | * Example: /a/b/c will have breadcrumb paths of '/', '/a', '/a/b', '/a/b/c' 80 | * @param {string} path path to a directory 81 | * @return {array} list of paths that lead up to a path 82 | */ 83 | export const getPathBreadCrumbs = (path) => { 84 | const pathParts = toPathParts(path); 85 | 86 | if (pathParts.length <= 1) { 87 | return ['/']; 88 | } 89 | 90 | const [, secondPathPart, ...pathPartsWithoutRoot] = pathParts; 91 | 92 | return pathPartsWithoutRoot.reduce((breadCrumbs, pathPart) => { 93 | const previousBreadCrumb = breadCrumbs[breadCrumbs.length - 1]; 94 | 95 | return [...breadCrumbs, `${previousBreadCrumb}/${pathPart}`]; 96 | }, ['/', `/${secondPathPart}`]); 97 | }; 98 | 99 | /** 100 | * Removes the file name from the end of a file path, returning the path to the 101 | * directory of the file 102 | * @param {string} filePath path which ends with a file name 103 | * @return {string} directory path 104 | */ 105 | export const getPathParent = (filePath) => { 106 | if (filePath === '/') { 107 | return '/'; 108 | } 109 | 110 | const pathParts = toPathParts(filePath); // converts path string to array 111 | const pathPartsWithoutFileName = pathParts.slice(0, -1); // removes last element of array 112 | 113 | return toPath(pathPartsWithoutFileName); 114 | }; 115 | 116 | /** 117 | * Extracts the file name from the end of the file path 118 | * @param {string} filePath path which ends with a file name 119 | * @return {string} file name from the path 120 | */ 121 | export const getLastPathPart = (filePath) => { 122 | const pathParts = toPathParts(filePath); // converts path string to array 123 | 124 | return pathParts[pathParts.length - 1]; 125 | }; 126 | 127 | /** 128 | * Extracts the file name and directory path from a file path 129 | * @param {string} filePath path which ends with a file name 130 | * @return {object} object with directory and file name 131 | */ 132 | export const splitFilePath = (filePath) => { 133 | return { 134 | 'dirPath': getPathParent(filePath), 135 | 'fileName': getLastPathPart(filePath) 136 | }; 137 | }; 138 | 139 | /** 140 | * Converts a relative path to an absolute path 141 | * @param {string} relativePath 142 | * @param {string} cwd current working directory 143 | * @return {string} absolute path 144 | */ 145 | const GO_UP = '..'; 146 | const CURRENT_DIR = '.'; 147 | const isStackAtRootDirectory = stack => stack.length === 1 && stack[0] === '/'; 148 | 149 | export const toAbsolutePath = (relativePath, cwd) => { 150 | relativePath = removeTrailingSeparator(relativePath); 151 | const pathStack = isAbsPath(relativePath) ? [] : toPathParts(cwd); 152 | 153 | for (const pathPart of toPathParts(relativePath)) { 154 | if (pathPart === GO_UP) { 155 | if (!isStackAtRootDirectory(pathStack)) { 156 | pathStack.pop(); 157 | } 158 | } else if (pathPart !== CURRENT_DIR) { 159 | pathStack.push(pathPart); 160 | } 161 | } 162 | 163 | return toPath(pathStack); 164 | }; 165 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | 9 | "extends": [ 10 | "plugin:react/recommended" 11 | ], 12 | 13 | "globals": { 14 | "document": false, 15 | "escape": false, 16 | "navigator": false, 17 | "unescape": false, 18 | "window": false, 19 | "describe": true, 20 | "before": true, 21 | "it": true, 22 | "expect": true, 23 | "sinon": true, 24 | "beforeEach": true 25 | }, 26 | 27 | "parser": "babel-eslint", 28 | 29 | "plugins": [ 30 | 31 | ], 32 | 33 | "rules": { 34 | "block-scoped-var": 2, 35 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 36 | "camelcase": [2, { "properties": "always" }], 37 | "comma-dangle": [2, "never"], 38 | "comma-spacing": [2, { "before": false, "after": true }], 39 | "comma-style": [2, "last"], 40 | "complexity": 0, 41 | "consistent-return": 2, 42 | "consistent-this": 0, 43 | "curly": [2, "multi-line"], 44 | "default-case": 0, 45 | "dot-location": [2, "property"], 46 | "dot-notation": 0, 47 | "eol-last": 2, 48 | "eqeqeq": [2, "allow-null"], 49 | "func-names": 0, 50 | "func-style": 0, 51 | "generator-star-spacing": [2, "both"], 52 | "guard-for-in": 0, 53 | "handle-callback-err": [2, "^(err|error|anySpecificError)$" ], 54 | "indent": [2, 2, { "SwitchCase": 1 }], 55 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }], 56 | "keyword-spacing": [2, {"before": true, "after": true}], 57 | "linebreak-style": 0, 58 | "max-depth": 0, 59 | "max-len": [2, 120, 4], 60 | "max-nested-callbacks": 0, 61 | "max-params": 0, 62 | "max-statements": 0, 63 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }], 64 | "newline-after-var": [2, "always"], 65 | "new-parens": 2, 66 | "no-alert": 0, 67 | "no-array-constructor": 2, 68 | "no-bitwise": 0, 69 | "no-caller": 2, 70 | "no-catch-shadow": 0, 71 | "no-cond-assign": 2, 72 | "no-console": 0, 73 | "no-constant-condition": 0, 74 | "no-continue": 0, 75 | "no-control-regex": 2, 76 | "no-debugger": 2, 77 | "no-delete-var": 2, 78 | "no-div-regex": 0, 79 | "no-dupe-args": 2, 80 | "no-dupe-keys": 2, 81 | "no-duplicate-case": 2, 82 | "no-else-return": 2, 83 | "no-empty": 0, 84 | "no-empty-character-class": 2, 85 | "no-eq-null": 0, 86 | "no-eval": 2, 87 | "no-ex-assign": 2, 88 | "no-extend-native": 2, 89 | "no-extra-bind": 2, 90 | "no-extra-boolean-cast": 2, 91 | "no-extra-parens": 0, 92 | "no-extra-semi": 0, 93 | "no-extra-strict": 0, 94 | "no-fallthrough": 2, 95 | "no-floating-decimal": 2, 96 | "no-func-assign": 2, 97 | "no-implied-eval": 2, 98 | "no-inline-comments": 0, 99 | "no-inner-declarations": [2, "functions"], 100 | "no-invalid-regexp": 2, 101 | "no-irregular-whitespace": 2, 102 | "no-iterator": 2, 103 | "no-label-var": 2, 104 | "no-labels": 2, 105 | "no-lone-blocks": 0, 106 | "no-lonely-if": 0, 107 | "no-loop-func": 0, 108 | "no-mixed-requires": 0, 109 | "no-mixed-spaces-and-tabs": [2, false], 110 | "no-multi-spaces": 2, 111 | "no-multi-str": 2, 112 | "no-multiple-empty-lines": [2, { "max": 1 }], 113 | "no-native-reassign": 2, 114 | "no-negated-in-lhs": 2, 115 | "no-nested-ternary": 0, 116 | "no-new": 2, 117 | "no-new-func": 2, 118 | "no-new-object": 2, 119 | "no-new-require": 2, 120 | "no-new-wrappers": 2, 121 | "no-obj-calls": 2, 122 | "no-octal": 2, 123 | "no-octal-escape": 2, 124 | "no-path-concat": 0, 125 | "no-plusplus": 0, 126 | "no-process-env": 0, 127 | "no-process-exit": 0, 128 | "no-proto": 2, 129 | "no-redeclare": 2, 130 | "no-regex-spaces": 2, 131 | "no-reserved-keys": 0, 132 | "no-restricted-modules": 0, 133 | "no-return-assign": 2, 134 | "no-script-url": 0, 135 | "no-self-compare": 2, 136 | "no-sequences": 2, 137 | "no-shadow": 0, 138 | "no-shadow-restricted-names": 2, 139 | "no-spaced-func": 2, 140 | "no-sparse-arrays": 2, 141 | "no-sync": 0, 142 | "no-ternary": 0, 143 | "no-throw-literal": 2, 144 | "no-trailing-spaces": 2, 145 | "no-undef": 2, 146 | "no-undef-init": 2, 147 | "no-undefined": 0, 148 | "no-underscore-dangle": 0, 149 | "no-unneeded-ternary": 2, 150 | "no-unreachable": 2, 151 | "no-unused-expressions": 0, 152 | "no-unused-vars": [2, { "vars": "all", "args": "none" }], 153 | "no-use-before-define": 2, 154 | "no-var": 0, 155 | "no-void": 0, 156 | "no-warning-comments": 0, 157 | "no-with": 2, 158 | "one-var": 0, 159 | "operator-assignment": 0, 160 | "operator-linebreak": [2, "after"], 161 | "padded-blocks": 0, 162 | "quote-props": 0, 163 | "quotes": [2, "single", "avoid-escape"], 164 | "radix": 2, 165 | "semi": [2, "always"], 166 | "semi-spacing": 0, 167 | "sort-vars": 0, 168 | "space-before-blocks": [2, "always"], 169 | "space-before-function-paren": [2, {"anonymous": "always", "named": "never"}], 170 | "space-in-brackets": 0, 171 | "space-in-parens": [2, "never"], 172 | "space-infix-ops": 2, 173 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 174 | "spaced-comment": [2, "always"], 175 | "strict": 0, 176 | "use-isnan": 2, 177 | "valid-jsdoc": 0, 178 | "valid-typeof": 2, 179 | "vars-on-top": 2, 180 | "wrap-iife": [2, "any"], 181 | "wrap-regex": 0, 182 | "yoda": [2, "never"] 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/emulator/index.js: -------------------------------------------------------------------------------- 1 | import * as CommandRunner from 'emulator/command-runner'; 2 | import parseCommands from 'parser/command-parser'; 3 | import { makeHeaderOutput, makeTextOutput } from 'emulator-output/output-factory'; 4 | import { recordCommand } from 'emulator-state/history'; 5 | import { getEnvironmentVariable } from 'emulator-state/environment-variables'; 6 | import { suggestCommands, suggestCommandOptions, suggestFileSystemNames } from 'emulator/auto-complete'; 7 | import { List } from 'immutable'; 8 | 9 | export default class Emulator { 10 | /** 11 | * Completes user input if there is one, and only one, suggestion. 12 | * 13 | * If there are no suggestions or more than one suggestion, the original 14 | * user input will be returned. 15 | * @param {EmulatorState} state emulator state 16 | * @param {string} partialStr partial user input to the emulator 17 | * @return {string} completed user input when one suggest (or, otherwsie, the original input) 18 | */ 19 | autocomplete(state, partialStr) { 20 | const suggestions = this.suggest(state, partialStr); 21 | 22 | if (suggestions.length !== 1) { 23 | return partialStr; 24 | } 25 | 26 | const strParts = new List(partialStr.split(' ')); 27 | const autocompletedText = suggestions[0]; 28 | 29 | return strParts 30 | .update(-1, lastVal => autocompletedText) 31 | .join(' '); 32 | }; 33 | 34 | /** 35 | * Suggest what the user will type next 36 | * @param {EmulatorState} state emulator state 37 | * @param {string} partialStr partial user input of a command 38 | * @return {array} list of possible text suggestions 39 | */ 40 | suggest(state, partialStr) { 41 | partialStr = this._trimLeadingSpace(partialStr); 42 | 43 | const lastPartialChar = partialStr.slice(-1); 44 | const isTypingNewPart = lastPartialChar === ' '; 45 | 46 | const strParts = partialStr.trim().split(' '); 47 | const {start: cmdName, end: lastTextEntered} = this._getBoundaryWords(strParts); 48 | 49 | if (!isTypingNewPart && strParts.length === 1) { 50 | return suggestCommands(state.getCommandMapping(), cmdName); 51 | } 52 | 53 | const strToComplete = isTypingNewPart ? '' : lastTextEntered; 54 | const cwd = getEnvironmentVariable(state.getEnvVariables(), 'cwd'); 55 | 56 | return [ 57 | ...suggestCommandOptions(state.getCommandMapping(), cmdName, strToComplete), 58 | ...suggestFileSystemNames(state.getFileSystem(), cwd, strToComplete) 59 | ]; 60 | }; 61 | 62 | _trimLeadingSpace(str) { 63 | return str.replace(/^\s+/g, ''); 64 | }; 65 | 66 | _getBoundaryWords(strParts) { 67 | return { 68 | start: strParts[0], 69 | end: strParts[strParts.length - 1] 70 | }; 71 | }; 72 | 73 | /** 74 | * Runs emulator command 75 | * @param {EmulatorState} state emulator state before running command 76 | * @param {string} str command string to execute 77 | * @param {Array} [executionListeners=[]] list of plugins to notify while running the command 78 | * @param {string} errorStr string to display on unrecognized command 79 | * @return {EmulatorState} updated emulator state after running command 80 | */ 81 | execute(state, str, executionListeners = [], errorStr) { 82 | for (const executionListener of executionListeners) { 83 | executionListener.onExecuteStarted(state, str); 84 | } 85 | 86 | state = this._addHeaderOutput(state, str); 87 | 88 | if (str.trim() === '') { 89 | // empty command string 90 | state = this._addCommandOutputs(state, [makeTextOutput('')]); 91 | } else { 92 | state = this._addCommandToHistory(state, str); 93 | state = this._updateStateByExecution(state, str, errorStr); 94 | } 95 | 96 | for (const executionListener of executionListeners) { 97 | executionListener.onExecuteCompleted(state); 98 | } 99 | 100 | return state; 101 | }; 102 | 103 | _updateStateByExecution(state, commandStrToExecute, errorStr) { 104 | for (const {commandName, commandOptions} of parseCommands(commandStrToExecute)) { 105 | const commandMapping = state.getCommandMapping(); 106 | const commandArgs = [state, commandOptions]; 107 | 108 | const {state: nextState, output, outputs} = CommandRunner.run( 109 | commandMapping, commandName, commandArgs, errorStr 110 | ); 111 | 112 | if (nextState) { 113 | state = nextState; 114 | } 115 | 116 | if (output) { 117 | state = this._addCommandOutputs(state, [output]); 118 | } else if (outputs) { 119 | state = this._addCommandOutputs(state, outputs); 120 | } 121 | } 122 | 123 | return state; 124 | } 125 | 126 | _addCommandToHistory(state, command) { 127 | const history = state.getHistory(); 128 | 129 | return state.setHistory(recordCommand(history, command)); 130 | } 131 | 132 | _addHeaderOutput(state, commandStr) { 133 | const envVariables = state.getEnvVariables(); 134 | const cwd = getEnvironmentVariable(envVariables, 'cwd'); 135 | 136 | return this._addCommandOutputs(state, [makeHeaderOutput(cwd, commandStr)]); 137 | } 138 | 139 | /** 140 | * Appends outputs to the internal state of outputs 141 | * @param {List} outputs list of outputs 142 | */ 143 | _addCommandOutputs(state, outputs) { 144 | for (const output of outputs) { 145 | const outputs = state.getOutputs(); 146 | 147 | state = state.setOutputs(outputs.push(output)); 148 | } 149 | 150 | return state; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /test/os/operations-with-permissions/directory-operations.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import spies from 'chai-spies'; 4 | 5 | chai.use(chaiImmutable); 6 | chai.use(spies); 7 | 8 | const sandbox = chai.spy.sandbox(); 9 | 10 | import FS from '../mocks/mock-fs-permissions'; 11 | import * as DirectoryOpsPermissioned from 'fs/operations-with-permissions/directory-operations'; 12 | import * as DirectoryOps from 'fs/operations/directory-operations'; 13 | import * as FileUtil from 'fs/util/file-util'; 14 | import { fsErrorType } from 'fs/fs-error'; 15 | 16 | describe('directory-operations with modification permissions', () => { 17 | before(() => { 18 | sandbox.on(DirectoryOps, [ 19 | 'hasDirectory', 20 | 'addDirectory', 21 | 'listDirectoryFiles', 22 | 'listDirectoryFolders', 23 | 'listDirectory', 24 | 'deleteDirectory', 25 | 'copyDirectory', 26 | 'renameDirectory' 27 | ]); 28 | }); 29 | 30 | describe('hasDirectory', () => { 31 | it('should use non-permissioned operation with same arguments', () => { 32 | const args = [FS, '/can-modify']; 33 | 34 | DirectoryOpsPermissioned.hasDirectory(...args); 35 | chai.expect(DirectoryOps.hasDirectory).to.have.been.called.with(...args); 36 | }); 37 | }); 38 | 39 | describe('addDirectory', () => { 40 | it('should use non-permissioned operation with same arguments', () => { 41 | const args = [FS, '/can-modify/new', FileUtil.makeDirectory()]; 42 | 43 | DirectoryOpsPermissioned.addDirectory(...args); 44 | chai.expect(DirectoryOps.addDirectory).to.have.been.called.with(...args); 45 | }); 46 | 47 | it('should return permissions error if cannot modify directory', () => { 48 | const newDir = FileUtil.makeDirectory(); 49 | const {err} = DirectoryOpsPermissioned.addDirectory(FS, '/cannot-modify/new', newDir); 50 | 51 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 52 | }); 53 | }); 54 | 55 | describe('listDirectoryFiles', () => { 56 | it('should use non-permissioned operation with same arguments', () => { 57 | const args = [FS, '/can-modify']; 58 | 59 | DirectoryOpsPermissioned.listDirectoryFiles(...args); 60 | chai.expect(DirectoryOps.listDirectoryFiles).to.have.been.called.with(...args); 61 | }); 62 | }); 63 | 64 | describe('listDirectoryFolders', () => { 65 | it('should use non-permissioned operation with same arguments', () => { 66 | const args = [FS, '/can-modify']; 67 | 68 | DirectoryOpsPermissioned.listDirectoryFolders(...args); 69 | chai.expect(DirectoryOps.listDirectoryFolders).to.have.been.called.with(...args); 70 | }); 71 | }); 72 | 73 | describe('listDirectory', () => { 74 | it('should use non-permissioned operation with same arguments', () => { 75 | const args = [FS, '/can-modify']; 76 | 77 | DirectoryOpsPermissioned.listDirectory(...args); 78 | chai.expect(DirectoryOps.listDirectory).to.have.been.called.with(...args); 79 | }); 80 | }); 81 | 82 | describe('deleteDirectory', () => { 83 | it('should use non-permissioned operation with same arguments', () => { 84 | const args = [FS, '/can-modify', true]; 85 | 86 | DirectoryOpsPermissioned.deleteDirectory(...args); 87 | chai.expect(DirectoryOps.deleteDirectory).to.have.been.called.with(...args); 88 | }); 89 | 90 | it('should return permissions error if cannot modify directory', () => { 91 | const {err} = DirectoryOpsPermissioned.deleteDirectory( 92 | FS, '/cannot-modify' 93 | ); 94 | 95 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 96 | }); 97 | }); 98 | 99 | describe('copyDirectory', () => { 100 | it('should use non-permissioned operation with same arguments', () => { 101 | const args = [FS, '/can-modify', '/can-modify-secondary', true]; 102 | 103 | DirectoryOpsPermissioned.copyDirectory(...args); 104 | chai.expect(DirectoryOps.copyDirectory).to.have.been.called.with(...args); 105 | }); 106 | 107 | it('should return permissions error if cannot modify source directory', () => { 108 | const {err} = DirectoryOpsPermissioned.copyDirectory( 109 | FS, '/cannot-modify', '/can-modify' 110 | ); 111 | 112 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 113 | }); 114 | 115 | it('should return permissions error if cannot modify dest directory', () => { 116 | const {err} = DirectoryOpsPermissioned.copyDirectory( 117 | FS, '/can-modify', '/cannot-modify' 118 | ); 119 | 120 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 121 | }); 122 | }); 123 | 124 | describe('renameDirectory', () => { 125 | it('should use non-permissioned operation with same arguments', () => { 126 | const args = [FS, '/can-modify', '/can-modify-secondary']; 127 | 128 | DirectoryOpsPermissioned.renameDirectory(...args); 129 | chai.expect(DirectoryOps.renameDirectory).to.have.been.called.with(...args); 130 | }); 131 | 132 | it('should return permissions error if cannot modify original directory', () => { 133 | const {err} = DirectoryOpsPermissioned.renameDirectory( 134 | FS, '/cannot-modify', '/can-modify' 135 | ); 136 | 137 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 138 | }); 139 | 140 | it('should return permissions error if cannot modify renamed directory', () => { 141 | const {err} = DirectoryOpsPermissioned.renameDirectory( 142 | FS, '/can-modify', '/cannot-modify' 143 | ); 144 | 145 | chai.expect(err.type).to.equal(fsErrorType.PERMISSION_DENIED); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /demo-web/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Correct the font size and margin on `h1` elements within `section` and 29 | * `article` contexts in Chrome, Firefox, and Safari. 30 | */ 31 | 32 | h1 { 33 | font-size: 2em; 34 | margin: 0.67em 0; 35 | } 36 | 37 | /* Grouping content 38 | ========================================================================== */ 39 | 40 | /** 41 | * 1. Add the correct box sizing in Firefox. 42 | * 2. Show the overflow in Edge and IE. 43 | */ 44 | 45 | hr { 46 | box-sizing: content-box; /* 1 */ 47 | height: 0; /* 1 */ 48 | overflow: visible; /* 2 */ 49 | } 50 | 51 | /** 52 | * 1. Correct the inheritance and scaling of font size in all browsers. 53 | * 2. Correct the odd `em` font sizing in all browsers. 54 | */ 55 | 56 | pre { 57 | font-family: monospace, monospace; /* 1 */ 58 | font-size: 1em; /* 2 */ 59 | } 60 | 61 | /* Text-level semantics 62 | ========================================================================== */ 63 | 64 | /** 65 | * Remove the gray background on active links in IE 10. 66 | */ 67 | 68 | a { 69 | background-color: transparent; 70 | } 71 | 72 | /** 73 | * 1. Remove the bottom border in Chrome 57- 74 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 75 | */ 76 | 77 | abbr[title] { 78 | border-bottom: none; /* 1 */ 79 | text-decoration: underline; /* 2 */ 80 | text-decoration: underline dotted; /* 2 */ 81 | } 82 | 83 | /** 84 | * Add the correct font weight in Chrome, Edge, and Safari. 85 | */ 86 | 87 | b, 88 | strong { 89 | font-weight: bolder; 90 | } 91 | 92 | /** 93 | * 1. Correct the inheritance and scaling of font size in all browsers. 94 | * 2. Correct the odd `em` font sizing in all browsers. 95 | */ 96 | 97 | code, 98 | kbd, 99 | samp { 100 | font-family: monospace, monospace; /* 1 */ 101 | font-size: 1em; /* 2 */ 102 | } 103 | 104 | /** 105 | * Add the correct font size in all browsers. 106 | */ 107 | 108 | small { 109 | font-size: 80%; 110 | } 111 | 112 | /** 113 | * Prevent `sub` and `sup` elements from affecting the line height in 114 | * all browsers. 115 | */ 116 | 117 | sub, 118 | sup { 119 | font-size: 75%; 120 | line-height: 0; 121 | position: relative; 122 | vertical-align: baseline; 123 | } 124 | 125 | sub { 126 | bottom: -0.25em; 127 | } 128 | 129 | sup { 130 | top: -0.5em; 131 | } 132 | 133 | /* Embedded content 134 | ========================================================================== */ 135 | 136 | /** 137 | * Remove the border on images inside links in IE 10. 138 | */ 139 | 140 | img { 141 | border-style: none; 142 | } 143 | 144 | /* Forms 145 | ========================================================================== */ 146 | 147 | /** 148 | * 1. Change the font styles in all browsers. 149 | * 2. Remove the margin in Firefox and Safari. 150 | */ 151 | 152 | button, 153 | input, 154 | optgroup, 155 | select, 156 | textarea { 157 | font-family: inherit; /* 1 */ 158 | font-size: 100%; /* 1 */ 159 | line-height: 1.15; /* 1 */ 160 | margin: 0; /* 2 */ 161 | } 162 | 163 | /** 164 | * Show the overflow in IE. 165 | * 1. Show the overflow in Edge. 166 | */ 167 | 168 | button, 169 | input { /* 1 */ 170 | overflow: visible; 171 | } 172 | 173 | /** 174 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 175 | * 1. Remove the inheritance of text transform in Firefox. 176 | */ 177 | 178 | button, 179 | select { /* 1 */ 180 | text-transform: none; 181 | } 182 | 183 | /** 184 | * Correct the inability to style clickable types in iOS and Safari. 185 | */ 186 | 187 | button, 188 | [type="button"], 189 | [type="reset"], 190 | [type="submit"] { 191 | -webkit-appearance: button; 192 | } 193 | 194 | /** 195 | * Remove the inner border and padding in Firefox. 196 | */ 197 | 198 | button::-moz-focus-inner, 199 | [type="button"]::-moz-focus-inner, 200 | [type="reset"]::-moz-focus-inner, 201 | [type="submit"]::-moz-focus-inner { 202 | border-style: none; 203 | padding: 0; 204 | } 205 | 206 | /** 207 | * Restore the focus styles unset by the previous rule. 208 | */ 209 | 210 | button:-moz-focusring, 211 | [type="button"]:-moz-focusring, 212 | [type="reset"]:-moz-focusring, 213 | [type="submit"]:-moz-focusring { 214 | outline: 1px dotted ButtonText; 215 | } 216 | 217 | /** 218 | * Correct the padding in Firefox. 219 | */ 220 | 221 | fieldset { 222 | padding: 0.35em 0.75em 0.625em; 223 | } 224 | 225 | /** 226 | * 1. Correct the text wrapping in Edge and IE. 227 | * 2. Correct the color inheritance from `fieldset` elements in IE. 228 | * 3. Remove the padding so developers are not caught out when they zero out 229 | * `fieldset` elements in all browsers. 230 | */ 231 | 232 | legend { 233 | box-sizing: border-box; /* 1 */ 234 | color: inherit; /* 2 */ 235 | display: table; /* 1 */ 236 | max-width: 100%; /* 1 */ 237 | padding: 0; /* 3 */ 238 | white-space: normal; /* 1 */ 239 | } 240 | 241 | /** 242 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 243 | */ 244 | 245 | progress { 246 | vertical-align: baseline; 247 | } 248 | 249 | /** 250 | * Remove the default vertical scrollbar in IE 10+. 251 | */ 252 | 253 | textarea { 254 | overflow: auto; 255 | } 256 | 257 | /** 258 | * 1. Add the correct box sizing in IE 10. 259 | * 2. Remove the padding in IE 10. 260 | */ 261 | 262 | [type="checkbox"], 263 | [type="radio"] { 264 | box-sizing: border-box; /* 1 */ 265 | padding: 0; /* 2 */ 266 | } 267 | 268 | /** 269 | * Correct the cursor style of increment and decrement buttons in Chrome. 270 | */ 271 | 272 | [type="number"]::-webkit-inner-spin-button, 273 | [type="number"]::-webkit-outer-spin-button { 274 | height: auto; 275 | } 276 | 277 | /** 278 | * 1. Correct the odd appearance in Chrome and Safari. 279 | * 2. Correct the outline style in Safari. 280 | */ 281 | 282 | [type="search"] { 283 | -webkit-appearance: textfield; /* 1 */ 284 | outline-offset: -2px; /* 2 */ 285 | } 286 | 287 | /** 288 | * Remove the inner padding in Chrome and Safari on macOS. 289 | */ 290 | 291 | [type="search"]::-webkit-search-decoration { 292 | -webkit-appearance: none; 293 | } 294 | 295 | /** 296 | * 1. Correct the inability to style clickable types in iOS and Safari. 297 | * 2. Change font properties to `inherit` in Safari. 298 | */ 299 | 300 | ::-webkit-file-upload-button { 301 | -webkit-appearance: button; /* 1 */ 302 | font: inherit; /* 2 */ 303 | } 304 | 305 | /* Interactive 306 | ========================================================================== */ 307 | 308 | /* 309 | * Add the correct display in Edge, IE 10+, and Firefox. 310 | */ 311 | 312 | details { 313 | display: block; 314 | } 315 | 316 | /* 317 | * Add the correct display in all browsers. 318 | */ 319 | 320 | summary { 321 | display: list-item; 322 | } 323 | 324 | /* Misc 325 | ========================================================================== */ 326 | 327 | /** 328 | * Add the correct display in IE 10+. 329 | */ 330 | 331 | template { 332 | display: none; 333 | } 334 | 335 | /** 336 | * Add the correct display in IE 10. 337 | */ 338 | 339 | [hidden] { 340 | display: none; 341 | } 342 | -------------------------------------------------------------------------------- /test/os/operations/file-operations.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | chai.use(chaiImmutable); 4 | 5 | import * as FileOp from 'fs/operations/file-operations'; 6 | import * as FileUtil from 'fs/util/file-util'; 7 | import { fsErrorType } from 'fs/fs-error'; 8 | import { create as createFileSystem } from 'emulator-state/file-system'; 9 | 10 | describe('file-operations', () => { 11 | // empty file 12 | const EMPTY_FILE_PATH = '/files/empty.txt'; 13 | const EMPTY_FILE_CONTENT = ''; 14 | 15 | // non-empty file 16 | const NON_EMPTY_FILENAME = 'non_empty.txt'; 17 | const NON_EMPTY_FILE_PATH = `/files/${NON_EMPTY_FILENAME}`; 18 | const NON_EMPTY_FILE_CONTENT = 'hello world! '; 19 | 20 | // nested file 21 | const NESTED_FILE_PATH = '/files/deeply/nested/nested.txt'; 22 | const NESTED_FILE_CONTENT = 'this is a deeply nested file'; 23 | 24 | // empty folder 25 | const EMPTY_DIRECTORY = '/empty'; 26 | 27 | const fileFS = createFileSystem({ 28 | [EMPTY_FILE_PATH]: {content: EMPTY_FILE_CONTENT}, 29 | [NON_EMPTY_FILE_PATH]: {content: NON_EMPTY_FILE_CONTENT}, 30 | [NESTED_FILE_PATH]: {content: NESTED_FILE_CONTENT}, 31 | [EMPTY_DIRECTORY]: {} 32 | }); 33 | 34 | describe('hasFile', () => { 35 | it('should return true if file is present', () => { 36 | chai.expect( 37 | FileOp.hasFile(fileFS, NON_EMPTY_FILE_PATH) 38 | ).to.equal(true); 39 | }); 40 | 41 | it('should return false if file is not present in existent directory', () => { 42 | chai.expect(FileOp.hasFile(fileFS, '/noSuchFile')).to.equal(false); 43 | }); 44 | 45 | it('should return false if given non-existent directory', () => { 46 | chai.expect(FileOp.hasFile(fileFS, '/noSuchDirectory/noSuchFile.txt')).to.equal(false); 47 | }); 48 | }); 49 | 50 | describe('readFile', () => { 51 | it('should read non-empty file', () => { 52 | const {file} = FileOp.readFile(fileFS, NON_EMPTY_FILE_PATH); 53 | 54 | chai.expect(file.get('content')).to.equal(NON_EMPTY_FILE_CONTENT); 55 | }); 56 | 57 | it('should read empty file', () => { 58 | const {file} = FileOp.readFile(fileFS, EMPTY_FILE_PATH); 59 | 60 | chai.expect(file.get('content')).to.equal(EMPTY_FILE_CONTENT); 61 | }); 62 | 63 | it('should read file in nested directory', () => { 64 | const {file} = FileOp.readFile(fileFS, NESTED_FILE_PATH); 65 | 66 | chai.expect(file.get('content')).to.equal(NESTED_FILE_CONTENT); 67 | }); 68 | 69 | it('should throw error if trying to read a directory', () => { 70 | const {err} = FileOp.readFile(fileFS, EMPTY_DIRECTORY); 71 | 72 | chai.expect(err.type).to.equal(fsErrorType.IS_A_DIRECTORY); 73 | }); 74 | 75 | it('should throw error if directory does not exist', () => { 76 | const {err} = FileOp.readFile(fileFS, '/noSuchDirectory/fileName'); 77 | 78 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE); 79 | }); 80 | 81 | it('should throw error if empty directory argument', () => { 82 | const {err} = FileOp.readFile(fileFS, ''); 83 | 84 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE); 85 | }); 86 | 87 | it('should throw error if file does not exist', () => { 88 | const {err} = FileOp.readFile(fileFS, '/noSuchFile.txt'); 89 | 90 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE); 91 | }); 92 | }); 93 | 94 | describe('writeFile', () => { 95 | const getContentAfterFileWrite = (fs, filePath, ...otherWriteFSArgs) => { 96 | const {fs: fsAfterWrite} = FileOp.writeFile(fs, filePath, ...otherWriteFSArgs); 97 | const {file} = FileOp.readFile(fsAfterWrite, filePath); 98 | 99 | return file.get('content'); 100 | }; 101 | 102 | it('should write new empty file', () => { 103 | const emptyFile = FileUtil.makeFile(''); 104 | 105 | chai.expect( 106 | getContentAfterFileWrite(fileFS, '/files/newFile.txt', emptyFile) 107 | ).to.equal(''); 108 | }); 109 | 110 | it('should throw error if trying to write to a file path', () => { 111 | const {err} = FileOp.writeFile( 112 | fileFS, `${EMPTY_FILE_PATH}/newFile`, FileUtil.makeFile() 113 | ); 114 | 115 | chai.expect(err.type).to.equal(fsErrorType.NOT_A_DIRECTORY); 116 | }); 117 | 118 | it('should throw error if overwriting file', () => { 119 | const {err} = FileOp.writeFile(fileFS, NON_EMPTY_FILE_PATH, FileUtil.makeFile()); 120 | 121 | chai.expect(err.type).to.equal(fsErrorType.FILE_OR_DIRECTORY_EXISTS); 122 | }); 123 | 124 | it('should throw error if directory does not exist', () => { 125 | const {err} = FileOp.writeFile( 126 | fileFS, '/noSuchDirectory/fileName', FileUtil.makeFile() 127 | ); 128 | 129 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_DIRECTORY); 130 | }); 131 | }); 132 | 133 | describe('copyFile', () => { 134 | it('should copy file into empty directory with specified file name', () => { 135 | const newFilePath = `${EMPTY_DIRECTORY}/newFile`; 136 | const {fs} = FileOp.copyFile(fileFS, NON_EMPTY_FILE_PATH, newFilePath); 137 | const {file} = FileOp.readFile(fs, newFilePath); 138 | 139 | chai.expect(file.get('content')).to.equal(NON_EMPTY_FILE_CONTENT); 140 | }); 141 | 142 | it('should copy file into root directory with same file name as source', () => { 143 | const {fs} = FileOp.copyFile(fileFS, NON_EMPTY_FILE_PATH, '/'); 144 | const {file} = FileOp.readFile(fs, `/${NON_EMPTY_FILENAME}`); 145 | 146 | chai.expect(file.get('content')).to.equal(NON_EMPTY_FILE_CONTENT); 147 | }); 148 | 149 | it('should copy file into empty directory with same file name as source', () => { 150 | const {fs} = FileOp.copyFile(fileFS, NON_EMPTY_FILE_PATH, EMPTY_DIRECTORY); 151 | const {file} = FileOp.readFile(fs, `${EMPTY_DIRECTORY}/${NON_EMPTY_FILENAME}`); 152 | 153 | chai.expect(file.get('content')).to.equal(NON_EMPTY_FILE_CONTENT); 154 | }); 155 | 156 | it('should overwrite file', () => { 157 | const {fs} = FileOp.copyFile(fileFS, NON_EMPTY_FILE_PATH, EMPTY_FILE_PATH); 158 | const {file} = FileOp.readFile(fs, EMPTY_FILE_PATH); 159 | 160 | chai.expect(file.get('content')).to.equal(NON_EMPTY_FILE_CONTENT); 161 | }); 162 | 163 | it('should throw error if copying FROM non-existent path', () => { 164 | const {err} = FileOp.copyFile( 165 | fileFS, '/noSuchDirectory/fileName', NESTED_FILE_PATH 166 | ); 167 | 168 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE); 169 | }); 170 | 171 | it('should throw error if copying TO non-existent path with a filename', () => { 172 | const {err} = FileOp.copyFile( 173 | fileFS, NESTED_FILE_PATH, '/noSuchDirectory/fileName' 174 | ); 175 | 176 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_DIRECTORY); 177 | }); 178 | }); 179 | 180 | describe('deleteFile', () => { 181 | it('should delete file', () => { 182 | const {fs: newFS} = FileOp.deleteFile(fileFS, NESTED_FILE_PATH); 183 | 184 | chai.expect(FileOp.hasFile(newFS, NESTED_FILE_PATH)).to.equal(false); 185 | }); 186 | 187 | it('should throw error if trying to delete a directory', () => { 188 | const {err} = FileOp.deleteFile(fileFS, EMPTY_DIRECTORY); 189 | 190 | chai.expect(err.type).to.equal(fsErrorType.IS_A_DIRECTORY); 191 | }); 192 | 193 | it('should throw error if deleting from non-existent directory', () => { 194 | const {err} = FileOp.deleteFile(fileFS, '/noSuchDirectory/fileName'); 195 | 196 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE); 197 | }); 198 | 199 | it('should throw error if deleting from non-existent file', () => { 200 | const {err} = FileOp.deleteFile(fileFS, '/files/noSuchFile'); 201 | 202 | chai.expect(err.type).to.equal(fsErrorType.NO_SUCH_FILE); 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /test/emulator/auto-complete.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | import {create as createFileSystem} from 'emulator-state/file-system'; 4 | import {create as createCommandMapping} from 'emulator-state/command-mapping'; 5 | import {suggestCommands, suggestCommandOptions, suggestFileSystemNames} from 'emulator/auto-complete'; 6 | 7 | const EMPTY_CMD = {function: () => {}, optDef: {}}; 8 | 9 | describe('auto-complete', () => { 10 | describe('suggestCommands', () => { 11 | it('should suggest all commands if no input', () => { 12 | const testCmdMapping = createCommandMapping({ 13 | 'a': EMPTY_CMD, 14 | 'b': EMPTY_CMD, 15 | 'c': EMPTY_CMD 16 | }); 17 | const suggestions = suggestCommands(testCmdMapping, ''); 18 | 19 | chai.expect(suggestions).to.deep.equal(['a', 'b', 'c']); 20 | }); 21 | 22 | it('should suggest exact match', () => { 23 | const testCmdMapping = createCommandMapping({ 24 | 'cwd': EMPTY_CMD 25 | }); 26 | const suggestions = suggestCommands(testCmdMapping, 'cwd'); 27 | 28 | chai.expect(suggestions).to.deep.equal(['cwd']); 29 | }); 30 | 31 | it('should suggest using partially completed command', () => { 32 | const testCmdMapping = createCommandMapping({ 33 | 'cwd': EMPTY_CMD 34 | }); 35 | const suggestions = suggestCommands(testCmdMapping, 'cw'); 36 | 37 | chai.expect(suggestions).to.deep.equal(['cwd']); 38 | }); 39 | 40 | it('should suggest multiple matches', () => { 41 | const testCmdMapping = createCommandMapping({ 42 | 'quick': EMPTY_CMD, 43 | 'quicklook': EMPTY_CMD, 44 | 'quickoats': EMPTY_CMD, 45 | 'distractor': EMPTY_CMD 46 | }); 47 | const suggestions = suggestCommands(testCmdMapping, 'quick'); 48 | 49 | chai.expect(suggestions).to.deep.equal(['quick', 'quicklook', 'quickoats']); 50 | }); 51 | }); 52 | 53 | describe('suggestCommandOptions', () => { 54 | it('should suggest all options', () => { 55 | const testCmdMapping = createCommandMapping({ 56 | 'history': { 57 | function: () => {}, 58 | optDef: { 59 | '-c, --clear': '' 60 | } 61 | } 62 | }); 63 | 64 | const suggestions = suggestCommandOptions(testCmdMapping, 'history', ''); 65 | 66 | chai.expect(suggestions).to.deep.equal(['-c', '--clear']); 67 | }); 68 | 69 | it('should suggest all partial option', () => { 70 | const testCmdMapping = createCommandMapping({ 71 | 'history': { 72 | function: () => {}, 73 | optDef: { 74 | '-ab': '', 75 | '-abc': '' 76 | } 77 | } 78 | }); 79 | 80 | const suggestions = suggestCommandOptions(testCmdMapping, 'history', '-a'); 81 | 82 | chai.expect(suggestions).to.deep.equal(['-ab', '-abc']); 83 | }); 84 | 85 | it('should suggest nothing if no option def', () => { 86 | const testCmdMapping = createCommandMapping({ 87 | 'noOpts': { 88 | function: () => {}, 89 | optDef: {} 90 | } 91 | }); 92 | 93 | const suggestions = suggestCommandOptions(testCmdMapping, 'noOpts', ''); 94 | 95 | chai.expect(suggestions).to.deep.equal([]); 96 | }); 97 | 98 | it('should suggest nothing if partial input does not match option def', () => { 99 | const testCmdMapping = createCommandMapping({ 100 | 'noOpts': { 101 | function: () => {}, 102 | optDef: { 103 | '--match': '' 104 | } 105 | } 106 | }); 107 | 108 | const suggestions = suggestCommandOptions(testCmdMapping, 'noOpts', '--noMatch'); 109 | 110 | chai.expect(suggestions).to.deep.equal([]); 111 | }); 112 | }); 113 | 114 | describe('suggestFileSystemNames', () => { 115 | it('should suggest absolute root path names', () => { 116 | const testFS = createFileSystem({ 117 | '/folder/b/c/d/e/f/g': {} 118 | }); 119 | const suggestions = suggestFileSystemNames(testFS, '/', '/'); 120 | 121 | chai.expect(suggestions).to.deep.equal(['/folder']); 122 | }); 123 | 124 | it('should suggest absolute root path names with partial name', () => { 125 | const testFS = createFileSystem({ 126 | '/folder/b/c/d/e/f/g': {} 127 | }); 128 | const suggestions = suggestFileSystemNames(testFS, '/', '/fol'); 129 | 130 | chai.expect(suggestions).to.deep.equal(['/folder']); 131 | }); 132 | 133 | it('should suggest relative root path names', () => { 134 | const testFS = createFileSystem({ 135 | '/folder/b/c/d/e/f/g': {} 136 | }); 137 | const suggestions = suggestFileSystemNames(testFS, '/', ''); 138 | 139 | chai.expect(suggestions).to.deep.equal(['folder']); 140 | }); 141 | 142 | it('should suggest relative root path names with partial name', () => { 143 | const testFS = createFileSystem({ 144 | '/folder/b/c/d/e/f/g': {} 145 | }); 146 | const suggestions = suggestFileSystemNames(testFS, '/', 'fo'); 147 | 148 | chai.expect(suggestions).to.deep.equal(['folder']); 149 | }); 150 | 151 | it('should suggest absolute nested path names', () => { 152 | const testFS = createFileSystem({ 153 | '/root/b/1': {}, 154 | '/root/b/1/deeply/nested': {}, 155 | '/root/b/2': {}, 156 | '/root/b/3': {}, 157 | '/root/c/4': {}, 158 | '/root/c/5': {} 159 | }); 160 | const suggestions = suggestFileSystemNames(testFS, '/', '/root/b/'); 161 | 162 | chai.expect(suggestions).to.deep.equal(['/root/b/1', '/root/b/2', '/root/b/3']); 163 | }); 164 | 165 | it('should suggest absolute nested path names with partial foldername', () => { 166 | const testFS = createFileSystem({ 167 | '/root/b/partial': {} 168 | }); 169 | const suggestions = suggestFileSystemNames(testFS, '/', '/root/b/par'); 170 | 171 | chai.expect(suggestions).to.deep.equal(['/root/b/partial']); 172 | }); 173 | 174 | it('should suggest relative nested path names', () => { 175 | const testFS = createFileSystem({ 176 | '/root/b/1': {}, 177 | '/root/b/1/deeply/nested': {}, 178 | '/root/b/2': {}, 179 | '/root/b/3': {}, 180 | '/root/c/4': {}, 181 | '/root/c/5': {} 182 | }); 183 | const suggestions = suggestFileSystemNames(testFS, '/root/b', ''); 184 | 185 | chai.expect(suggestions).to.deep.equal(['1', '2', '3']); 186 | }); 187 | 188 | it('should suggest relative nested path names with partial foldername', () => { 189 | const testFS = createFileSystem({ 190 | '/root/b/partial': {} 191 | }); 192 | const suggestions = suggestFileSystemNames(testFS, '/root/b', 'par'); 193 | 194 | chai.expect(suggestions).to.deep.equal(['partial']); 195 | }); 196 | 197 | it('should suggest relative nested path names with nested partial foldername', () => { 198 | const testFS = createFileSystem({ 199 | '/root/b/partial': {} 200 | }); 201 | const suggestions = suggestFileSystemNames(testFS, '/root', 'b/par'); 202 | 203 | chai.expect(suggestions).to.deep.equal(['b/partial']); 204 | }); 205 | 206 | it('should suggest nested path names with ..', () => { 207 | const testFS = createFileSystem({ 208 | '/root/b/1': {}, 209 | '/root/b/1/deeply/nested': {}, 210 | '/root/b/2': {}, 211 | '/root/b/3': {}, 212 | '/root/c/4': {}, 213 | '/root/c/5': {} 214 | }); 215 | const suggestions = suggestFileSystemNames(testFS, '/root/b', '../'); 216 | 217 | chai.expect(suggestions).to.deep.equal(['b', 'c']); 218 | }); 219 | 220 | it('should suggest no matches', () => { 221 | const testFS = createFileSystem({ 222 | '/a': {} 223 | }); 224 | const suggestions = suggestFileSystemNames(testFS, '/a', ''); 225 | 226 | chai.expect(suggestions).to.deep.equal([]); 227 | }); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /test/emulator-state/command-mapping.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import { Seq, Map } from 'immutable'; 4 | 5 | chai.use(chaiImmutable); 6 | 7 | import * as CommandMapping from 'emulator-state/command-mapping'; 8 | 9 | describe('command-mapping', () => { 10 | const emptyCommandMapping = CommandMapping.create({}); 11 | 12 | describe('create', () => { 13 | it('should exist', () => { 14 | chai.assert.isFunction(CommandMapping.create); 15 | }); 16 | 17 | it('should create an immutable map', () => { 18 | chai.expect(emptyCommandMapping).to.be.instanceOf(Map); 19 | }); 20 | 21 | it('should create command map from JS object', () => { 22 | const cmdMapping = CommandMapping.create({ 23 | 'a': { 24 | function: () => true, 25 | optDef: {} 26 | } 27 | }); 28 | 29 | chai.expect(CommandMapping.getCommandFn(cmdMapping, 'a')()).to.equal(true); 30 | }); 31 | 32 | it('should throw error if missing command function', () => { 33 | const jsMapping = { 34 | 'a': { 35 | optDef: {} 36 | } 37 | }; 38 | 39 | chai.expect(() => CommandMapping.create(jsMapping)).to.throw(); 40 | }); 41 | 42 | it('should throw error if missing option definition', () => { 43 | const jsMapping = { 44 | 'a': { 45 | function: () => true 46 | } 47 | }; 48 | 49 | chai.expect(() => CommandMapping.create(jsMapping)).to.throw(); 50 | }); 51 | }); 52 | 53 | describe('isCommandSet', () => { 54 | const commandMapping = CommandMapping.create({ 55 | 'hello': { 56 | function: () => {}, 57 | optDef: {} 58 | } 59 | }); 60 | 61 | it('should exist', () => { 62 | chai.assert.isFunction(CommandMapping.isCommandSet); 63 | }); 64 | 65 | it('should return true if command exists', () => { 66 | chai.expect( 67 | CommandMapping.isCommandSet(commandMapping, 'hello') 68 | ).to.equal(true); 69 | }); 70 | 71 | it('should return false if command does not exist', () => { 72 | chai.expect( 73 | CommandMapping.isCommandSet(commandMapping, 'noSuchCommand') 74 | ).to.equal(false); 75 | }); 76 | }); 77 | 78 | describe('setCommand', () => { 79 | it('should exist', () => { 80 | chai.assert.isFunction(CommandMapping.setCommand); 81 | }); 82 | 83 | it('should add a command to an existing mapping', () => { 84 | const commandMapping = CommandMapping.create({ 85 | 'foo': { 86 | function: () => {}, 87 | optDef: {} 88 | } 89 | }); 90 | 91 | const addedFunction = () => true; 92 | 93 | const actualMapping = CommandMapping.setCommand( 94 | commandMapping, 'baz', addedFunction, {} 95 | ); 96 | 97 | const expectedMapping = CommandMapping.create({ 98 | 'foo': { 99 | function: () => {}, 100 | optDef: {} 101 | }, 102 | 'baz': { 103 | function: addedFunction, 104 | optDef: {} 105 | } 106 | }); 107 | 108 | chai.expect(actualMapping.keySeq()).to.equal(expectedMapping.keySeq()); 109 | chai.expect(actualMapping.get('baz')).to.equal(expectedMapping.get('baz')); 110 | }); 111 | 112 | it('should override a command if it already is defined', () => { 113 | const commandMapping = CommandMapping.create({ 114 | 'baz': { 115 | function: () => false, 116 | optDef: {'c': 'd'} 117 | } 118 | }); 119 | 120 | const actualMapping = CommandMapping.setCommand( 121 | commandMapping, 'baz', () => true, {'a': 'b'} 122 | ); 123 | 124 | const expectedMapping = CommandMapping.create({ 125 | 'baz': { 126 | function: () => true, 127 | optDef: {'a': 'b'} 128 | } 129 | }); 130 | 131 | chai.expect(actualMapping.keySeq()).to.equal(expectedMapping.keySeq()); 132 | }); 133 | 134 | it('should add a command to empty mapping', () => { 135 | const actualMapping = CommandMapping.setCommand( 136 | emptyCommandMapping, 'baz', () => true, {} 137 | ); 138 | 139 | const expectedMapping = CommandMapping.create({ 140 | 'baz': { 141 | function: () => true, 142 | optDef: {} 143 | } 144 | }); 145 | 146 | chai.expect(actualMapping.keySeq()).to.equal(expectedMapping.keySeq()); 147 | }); 148 | 149 | it('should throw error if missing option definition', () => { 150 | chai.expect(() => 151 | CommandMapping.setCommand( 152 | emptyCommandMapping, 'baz', () => true, undefined 153 | ) 154 | ).to.throw(); 155 | }); 156 | 157 | it('should throw error if missing function', () => { 158 | chai.expect(() => 159 | CommandMapping.setCommand( 160 | emptyCommandMapping, 'baz', undefined, {} 161 | ) 162 | ).to.throw(); 163 | }); 164 | }); 165 | 166 | describe('unsetCommand', () => { 167 | it('should exist', () => { 168 | chai.assert.isFunction(CommandMapping.unsetCommand); 169 | }); 170 | 171 | it('should remove a command which is set', () => { 172 | const commandMapping = CommandMapping.create({ 173 | 'hello': { 174 | function: () => {}, 175 | optDef: {} 176 | } 177 | }); 178 | 179 | chai.expect( 180 | CommandMapping.unsetCommand(commandMapping, 'hello') 181 | ).to.equal(emptyCommandMapping); 182 | }); 183 | 184 | it('should ignore removal if command does not exist', () => { 185 | chai.expect( 186 | CommandMapping.unsetCommand(emptyCommandMapping, 'noSuchCommand') 187 | ).to.equal(emptyCommandMapping); 188 | }); 189 | }); 190 | 191 | describe('getCommandFn', () => { 192 | it('should exist', () => { 193 | chai.assert.isFunction(CommandMapping.getCommandFn); 194 | }); 195 | 196 | it('should return function which is set', () => { 197 | const commandMapping = CommandMapping.create({ 198 | 'hello': { 199 | function: () => true, 200 | optDef: {} 201 | }, 202 | 'goodbye': { 203 | function: () => false, 204 | optDef: {} 205 | } 206 | }); 207 | 208 | const extractedFn = CommandMapping.getCommandFn(commandMapping, 'hello'); 209 | 210 | chai.expect(extractedFn()).to.equal(true); 211 | }); 212 | 213 | it('should return undefined if function is not set', () => { 214 | chai.expect( 215 | CommandMapping.getCommandFn(emptyCommandMapping, 'noSuchFunction') 216 | ).to.equal(undefined); 217 | }); 218 | }); 219 | 220 | describe('getCommandOptDef', () => { 221 | it('should exist', () => { 222 | chai.assert.isFunction(CommandMapping.getCommandOptDef); 223 | }); 224 | 225 | it('should return function which is set', () => { 226 | const commandMapping = CommandMapping.create({ 227 | 'hello': { 228 | function: () => true, 229 | optDef: {'key': 'value'} 230 | } 231 | }); 232 | 233 | const actualOptDef = CommandMapping.getCommandOptDef(commandMapping, 'hello'); 234 | 235 | chai.expect(actualOptDef).to.equal(new Map({'key': 'value'})); 236 | }); 237 | 238 | it('should return undefined if function is not set', () => { 239 | chai.expect( 240 | CommandMapping.getCommandOptDef(emptyCommandMapping, 'noSuchFunction') 241 | ).to.equal(undefined); 242 | }); 243 | }); 244 | 245 | describe('getCommandNames', () => { 246 | const commandMapping = CommandMapping.create({ 247 | 'a': { 248 | function: () => {}, 249 | optDef: {} 250 | }, 251 | 'b': { 252 | function: () => {}, 253 | optDef: {} 254 | }, 255 | 'c': { 256 | function: () => {}, 257 | optDef: {} 258 | } 259 | }); 260 | 261 | it('should exist', () => { 262 | chai.assert.isFunction(CommandMapping.getCommandNames); 263 | }); 264 | 265 | it('should return sequence of command names', () => { 266 | chai.expect( 267 | CommandMapping.getCommandNames(commandMapping) 268 | ).to.equal(Seq(['a', 'b', 'c'])); 269 | }); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /test/emulator/emulator.spec.js: -------------------------------------------------------------------------------- 1 | /* global beforeEach */ 2 | import chai from '../_plugins/state-equality-plugin'; 3 | import { List } from 'immutable'; 4 | 5 | import Emulator from 'emulator'; 6 | import EmulatorState from 'emulator-state/EmulatorState'; 7 | import { create as createOutputs } from 'emulator-state/outputs'; 8 | import { create as createCommandMapping } from 'emulator-state/command-mapping'; 9 | import { create as createFileSystem } from 'emulator-state/file-system'; 10 | import { makeHeaderOutput, makeTextOutput, makeErrorOutput } from 'emulator-output/output-factory'; 11 | import { makeError } from 'emulator/emulator-error'; 12 | 13 | const emptyState = EmulatorState.createEmpty(); 14 | 15 | const testCommandState = EmulatorState.create({ 16 | commandMapping: createCommandMapping({ 17 | 'setsEmptyState': { 18 | function: (state, commandOptions) => ({ 19 | state: emptyState 20 | }), 21 | optDef: {} 22 | }, 23 | 'setsOutputs': { 24 | function: (state, commandOptions) => ({ 25 | outputs: [makeTextOutput('hello')] 26 | }), 27 | optDef: {} 28 | }, 29 | 'setsSingleOutput': { 30 | function: (state, commandOptions) => ({ 31 | output: makeTextOutput('hello') 32 | }), 33 | optDef: {} 34 | }, 35 | 'setsEmptyStateAndOutputs': { 36 | function: (state, commandOptions) => ({ 37 | state: emptyState, 38 | outputs: [makeTextOutput('hello')] 39 | }), 40 | optDef: {} 41 | }, 42 | 'usesArguments': { 43 | function: (state, commandOptions) => ({ 44 | outputs: commandOptions.map((opt) => makeTextOutput(opt)) 45 | }), 46 | optDef: {} 47 | } 48 | }) 49 | }); 50 | 51 | let emulator; 52 | 53 | describe('emulator', () => { 54 | beforeEach(() => { 55 | emulator = new Emulator(); 56 | }); 57 | 58 | it('should update state from command', () => { 59 | const newState = emulator.execute(testCommandState, 'setsEmptyState'); 60 | 61 | chai.expect(newState).toEqualState(emptyState); 62 | }); 63 | 64 | it('should add emtpy text output and header if running empty command', () => { 65 | const newState = emulator.execute(testCommandState, ''); 66 | 67 | chai.expect([...newState.getOutputs()]).to.deep.equal( 68 | [ 69 | makeHeaderOutput('/', ''), 70 | makeTextOutput('') 71 | ] 72 | ); 73 | }); 74 | 75 | describe('emulator output', () => { 76 | it('should update outputs from command with list output', () => { 77 | const newState = emulator.execute(testCommandState, 'setsOutputs'); 78 | 79 | chai.expect([...newState.getOutputs()]).to.deep.equal( 80 | [ 81 | makeHeaderOutput('/', 'setsOutputs'), 82 | makeTextOutput('hello') 83 | ] 84 | ); 85 | }); 86 | 87 | it('should update outputs from command with single output', () => { 88 | const newState = emulator.execute(testCommandState, 'setsSingleOutput'); 89 | 90 | chai.expect([...newState.getOutputs()]).to.deep.equal( 91 | [ 92 | makeHeaderOutput('/', 'setsSingleOutput'), 93 | makeTextOutput('hello') 94 | ] 95 | ); 96 | }); 97 | 98 | it('should update state and outputs from command', () => { 99 | const newState = emulator.execute(testCommandState, 'setsEmptyStateAndOutputs'); 100 | 101 | chai.expect(newState).toEqualState(EmulatorState.create({ 102 | outputs: createOutputs([makeTextOutput('hello')]) 103 | })); 104 | }); 105 | 106 | it('should access parsed args from command', () => { 107 | const newState = emulator.execute(testCommandState, 'usesArguments --a1 b2 c3 d/e/f'); 108 | 109 | chai.expect([...newState.getOutputs()]).to.deep.equal( 110 | [ 111 | makeHeaderOutput('/', 'usesArguments --a1 b2 c3 d/e/f'), 112 | makeTextOutput('--a1'), 113 | makeTextOutput('b2'), 114 | makeTextOutput('c3'), 115 | makeTextOutput('d/e/f') 116 | ] 117 | ); 118 | }); 119 | 120 | it('should print a custom error message if passed one', () => { 121 | const errorMessage = "a custom error"; 122 | const newState = emulator.execute(testCommandState, '1234', [], errorMessage); 123 | 124 | chai.expect([...newState.getOutputs()]).to.deep.equal( 125 | [ 126 | makeHeaderOutput('/', '1234'), 127 | makeErrorOutput(makeError(errorMessage)) 128 | ] 129 | ); 130 | }); 131 | }); 132 | 133 | describe('emulator history', () => { 134 | it('should update history', () => { 135 | let newState = emulator.execute(testCommandState, 'baz && foo'); 136 | 137 | newState = emulator.execute(newState, 'bar'); 138 | 139 | chai.expect(newState.getHistory()).to.equal( 140 | List(['bar', 'baz && foo']) 141 | ); 142 | }); 143 | }); 144 | 145 | describe('suggest and autocomplete', () => { 146 | const testState = EmulatorState.create({ 147 | commandMapping: createCommandMapping({ 148 | 'commandOne': { 149 | function: () => {}, 150 | optDef: { 151 | '-e, --example': '', 152 | '-g, --egg': '' 153 | } 154 | }, 155 | 'commandTwo': { 156 | function: () => {}, 157 | optDef: {} 158 | } 159 | }), 160 | fs: createFileSystem({ 161 | '/spam/eggs/ham/bacon': {}, 162 | '/spam/eggs/foo': {} 163 | }) 164 | }); 165 | 166 | describe('suggest', () => { 167 | it('should suggest commands with empty input string', () => { 168 | const suggestions = emulator.suggest(testState, ''); 169 | 170 | chai.expect(suggestions).to.deep.equal( 171 | ['commandOne', 'commandTwo'] 172 | ); 173 | }); 174 | 175 | it('should suggest commands with partial input string', () => { 176 | const suggestions = emulator.suggest(testState, 'co'); 177 | 178 | chai.expect(suggestions).to.deep.equal( 179 | ['commandOne', 'commandTwo'], 180 | ); 181 | }); 182 | 183 | it('should suggest fs paths + commands options with command and space', () => { 184 | const suggestions = emulator.suggest(testState, 'commandOne '); 185 | 186 | chai.expect(suggestions).to.deep.equal( 187 | ['-e', '--example', '-g', '--egg', 'spam'], 188 | ); 189 | }); 190 | 191 | it('should suggest fs paths + commands options with partial input string', () => { 192 | const suggestions = emulator.suggest(testState, 'commandTwo sp'); 193 | 194 | chai.expect(suggestions).to.deep.equal( 195 | ['spam'] 196 | ); 197 | }); 198 | }); 199 | 200 | describe('autocomplete', () => { 201 | it('should not autocomplete with multiple matches', () => { 202 | const suggestions = emulator.autocomplete(testState, ''); 203 | 204 | chai.expect(suggestions).to.equal(''); 205 | }); 206 | 207 | it('should autocomplete with single command name match', () => { 208 | const suggestions = emulator.autocomplete(testState, 'commandT'); 209 | 210 | chai.expect(suggestions).to.equal('commandTwo'); 211 | }); 212 | 213 | it('should autocomplete with single file system match', () => { 214 | const suggestions = emulator.autocomplete(testState, 'commandTwo sp'); 215 | 216 | chai.expect(suggestions).to.equal('commandTwo spam'); 217 | }); 218 | 219 | it('should autocomplete argument', () => { 220 | const suggestions = emulator.autocomplete(testState, 'commandOne spam --ex'); 221 | 222 | chai.expect(suggestions).to.equal('commandOne spam --example'); 223 | }); 224 | 225 | it('should autocomplete single file', () => { 226 | const singleFileTestState = EmulatorState.create({ 227 | fs: createFileSystem({ 228 | '/foo': {} 229 | }) 230 | }); 231 | 232 | const suggestions = emulator.autocomplete(singleFileTestState, 'cd '); 233 | 234 | chai.expect(suggestions).to.equal('cd foo'); 235 | }); 236 | 237 | it('should autocomplete file system at end of string', () => { 238 | const suggestions = emulator.autocomplete(testState, 'commandTwo spam /spam/e'); 239 | 240 | chai.expect(suggestions).to.equal('commandTwo spam /spam/eggs'); 241 | }); 242 | }); 243 | }); 244 | }); 245 | --------------------------------------------------------------------------------