├── .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 |
--------------------------------------------------------------------------------