├── _config.yml ├── .eslintignore ├── .prettierignore ├── .prettierrc ├── TermyLogo.png ├── src ├── images │ ├── dog.png │ ├── folder.svg │ └── file.svg ├── __mocks__ │ └── svgMock.tsx ├── commands │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── mkdir.test.ts.snap │ │ │ ├── cat.test.tsx.snap │ │ │ ├── cd.test.ts.snap │ │ │ ├── ls.test.ts.snap │ │ │ └── rm.test.ts.snap │ │ ├── help.test.ts │ │ ├── pwd.test.ts │ │ ├── autoComplete.test.ts │ │ ├── cat.test.tsx │ │ ├── mkdir.test.ts │ │ ├── ls.test.ts │ │ ├── cd.test.ts │ │ └── rm.test.ts │ ├── index.ts │ ├── pwd.ts │ ├── help.tsx │ ├── ls.tsx │ ├── cat.tsx │ ├── mkdir.ts │ ├── cd.ts │ ├── autoComplete.ts │ ├── rm.ts │ └── utilities │ │ └── index.ts ├── components │ ├── TerminalImage.tsx │ ├── InputPrompt.tsx │ ├── History.tsx │ ├── HelpMenu.tsx │ ├── LsResult.tsx │ ├── AutoCompleteList.tsx │ └── Input.tsx ├── context │ └── TerminalContext.ts ├── styles │ ├── vars.scss │ └── Terminal.scss ├── types.d.ts ├── templates │ └── index.html ├── demo.tsx ├── data │ └── exampleFileSystem.tsx ├── helpers │ └── autoComplete.ts ├── index.tsx └── __tests__ │ ├── __snapshots__ │ └── index.test.tsx.snap │ └── index.test.tsx ├── tsconfig.eslint.json ├── .travis.yml ├── .gitignore ├── .editorconfig ├── .vscode └── settings.json ├── jest.config.js ├── docs ├── index.html └── index.css ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── .eslintrc.js ├── rollup.dev.config.js ├── CONTRIBUTING.MD ├── package.json ├── CHANGELOG.md └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | docs/ 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /TermyLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctaylo21/termy-the-terminal/HEAD/TermyLogo.png -------------------------------------------------------------------------------- /src/images/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctaylo21/termy-the-terminal/HEAD/src/images/dog.png -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "**/*.tsx", "**/*.ts", "**/*.js"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /src/__mocks__/svgMock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SvgMock: React.FC = (): JSX.Element => { 4 | return <>; 5 | }; 6 | 7 | export default SvgMock; 8 | -------------------------------------------------------------------------------- /src/commands/__tests__/__snapshots__/mkdir.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`mkdir suite failure should reject if path already exists 1`] = `"Path already exists"`; 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '10' 5 | cache: 6 | directories: 7 | - node_modules 8 | install: 9 | - npm install 10 | script: 11 | - npm test 12 | - npm run coveralls 13 | -------------------------------------------------------------------------------- /src/components/TerminalImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ImageProps { 4 | src: string; 5 | } 6 | 7 | const TerminalImage: React.FC = ({ src }): JSX.Element => ( 8 | 9 | ); 10 | 11 | export default TerminalImage; 12 | -------------------------------------------------------------------------------- /src/images/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/context/TerminalContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Command } from '..'; 3 | 4 | type CommandList = { 5 | [key: string]: Command; 6 | }; 7 | 8 | const TerminalContext = React.createContext({}); 9 | 10 | export default TerminalContext; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Depdency directory 2 | node_modules 3 | 4 | # Webpack compiled bundle directory 5 | dist 6 | 7 | # Tern files 8 | .tern-port 9 | .tern-project 10 | 11 | # coverage folder 12 | coverage 13 | 14 | # Coveralls 15 | .coveralls.yml 16 | 17 | # Environment 18 | .env 19 | -------------------------------------------------------------------------------- /src/styles/vars.scss: -------------------------------------------------------------------------------- 1 | $themeDark: ( 2 | background: #1b2b34, 3 | highlight: #4F5B66, 4 | fontColor: #d8dee9, 5 | green: #9ac794, 6 | blue: #5fb3b3, 7 | orange: #f99157, 8 | purple: #c594c5, 9 | fontSize: 18px, 10 | fontFamily: #{'Inconsolata', monospace}, 11 | ); 12 | -------------------------------------------------------------------------------- /src/commands/__tests__/__snapshots__/cat.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cat suite should reject if target is not a file 1`] = `"Target is not a file"`; 4 | 5 | exports[`cat suite should reject if target is not a valid path 1`] = `"Invalid target path"`; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | quote_type = single 13 | 14 | [**.json] 15 | quote_type = double -------------------------------------------------------------------------------- /src/commands/__tests__/__snapshots__/cd.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cd suite invalid cases empty path 1`] = `"path can not be empty."`; 4 | 5 | exports[`cd suite invalid cases nested cd to a file 1`] = `"path does not exist: home/folder1/folder2/file1"`; 6 | -------------------------------------------------------------------------------- /src/commands/__tests__/__snapshots__/ls.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ls suite should reject if invalid target given 1`] = `"Target folder does not exist"`; 4 | 5 | exports[`ls suite should return message if directory and empty 1`] = `"Nothing to show here"`; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.autoFixOnSave": true, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | { "language": "typescript", "autoFix": true }, 7 | { "language": "typescriptreact", "autoFix": true } 8 | ], 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll.eslint": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module '*.png' { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module '*.jpg' { 12 | const content: string; 13 | export default content; 14 | } 15 | 16 | declare module '*.gif' { 17 | const content: string; 18 | export default content; 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import cd from './cd'; 2 | import ls from './ls'; 3 | import mkdir from './mkdir'; 4 | import cat from './cat'; 5 | import help from './help'; 6 | import pwd from './pwd'; 7 | import rm from './rm'; 8 | import { Command } from '..'; 9 | 10 | const commands: { 11 | [key: string]: Command; 12 | } = { 13 | cat, 14 | cd, 15 | help, 16 | ls, 17 | mkdir, 18 | pwd, 19 | rm, 20 | }; 21 | 22 | export default commands; 23 | -------------------------------------------------------------------------------- /src/commands/__tests__/__snapshots__/rm.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`rm suite failure should reject if no options and target is a folder 1`] = `"Can't remove home. It is a directory."`; 4 | 5 | exports[`rm suite failure should reject if no target path provided 1`] = `"Missing argument to rm"`; 6 | 7 | exports[`rm suite failure should reject if path is invalid 1`] = `"Can't remove invalid. No such file or directory."`; 8 | -------------------------------------------------------------------------------- /src/components/InputPrompt.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface InputPromptProps { 4 | path: string; 5 | inputPrompt: string; 6 | } 7 | 8 | export const InputPrompt: React.FC = (props): JSX.Element => { 9 | const { path, inputPrompt } = props; 10 | 11 | return ( 12 | <> 13 | {path} 14 |   15 | {inputPrompt} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/commands/__tests__/help.test.ts: -------------------------------------------------------------------------------- 1 | import help from '../help'; 2 | const { autoCompleteHandler } = help; 3 | 4 | describe('help suite', (): void => { 5 | // Due to annoying mocking issues with React Context, we test the 6 | // handler that renders the help component with integration tests 7 | // and only test autocomplete here 8 | it('autocomplete should do nothing', async (): Promise => { 9 | const result = await autoCompleteHandler({}, '/home', ''); 10 | 11 | expect(result).toStrictEqual({ commandResult: null }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | automock: false, 3 | clearMocks: true, 4 | roots: ['/src'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | }, 8 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 9 | moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'], 10 | coveragePathIgnorePatterns: ['\\\\node_modules\\\\'], 11 | testPathIgnorePatterns: ['\\\\node_modules\\\\'], 12 | coverageDirectory: 'coverage', 13 | errorOnDeprecated: true, 14 | moduleNameMapper: { 15 | '\\.(scss|jpg|png)$': '/node_modules/jest-css-modules', 16 | '\\.(svg)$': '/src/__mocks__/svgMock.tsx', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/commands/__tests__/pwd.test.ts: -------------------------------------------------------------------------------- 1 | import pwd from '../pwd'; 2 | const { handler, autoCompleteHandler } = pwd; 3 | import exampleFileSystem from '../../data/exampleFileSystem'; 4 | 5 | describe('pwd suite', (): void => { 6 | it('should return current path', async (): Promise => { 7 | const result = await handler(exampleFileSystem, '/home'); 8 | 9 | expect(result).toStrictEqual({ commandResult: '/home' }); 10 | }); 11 | 12 | it('autocomplete should do nothing', async (): Promise => { 13 | const result = await autoCompleteHandler(exampleFileSystem, '/home', ''); 14 | 15 | expect(result).toStrictEqual({ commandResult: null }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/History.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HistoryItem } from '../index'; 3 | 4 | declare interface HistoryProps { 5 | history: HistoryItem[]; 6 | } 7 | 8 | export const History: React.FC = (props): JSX.Element => { 9 | const { history } = props; 10 | const commandList = history.map( 11 | (command): JSX.Element => ( 12 |
  • 13 | {command.input} 14 | {command.result} 15 |
  • 16 | ), 17 | ); 18 | 19 | return ( 20 |
    21 |
      {commandList}
    22 |
    23 | ); 24 | }; 25 | 26 | export default History; 27 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Termy the Terminal 7 | 8 | 9 | 10 | 11 | 12 | 13 | 26 | 27 |
    28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Termy the Terminal 7 | 8 | 9 | 10 | 11 | 12 | 13 | 26 | 27 |
    28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/images/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/HelpMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TerminalContext from '../context/TerminalContext'; 3 | 4 | export const HelpMenu: React.FC<{}> = (): JSX.Element => { 5 | return ( 6 | 7 | {(commands): JSX.Element => ( 8 |
    9 |
      10 | {Object.keys(commands) 11 | .filter((command) => commands[command].description) 12 | .map( 13 | (command): JSX.Element => ( 14 |
    • 15 | {command} -{' '} 16 | {commands[command].description} 17 |
    • 18 | ), 19 | )} 20 |
    21 |
    22 | )} 23 |
    24 | ); 25 | }; 26 | 27 | export default HelpMenu; 28 | -------------------------------------------------------------------------------- /src/components/LsResult.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import FolderIcon from '../images/folder.svg'; 3 | import FileIcon from '../images/file.svg'; 4 | import { ItemListType } from '../'; 5 | 6 | interface LsResultProps { 7 | lsResult: ItemListType; 8 | } 9 | 10 | const LsResult: React.FC = (props): JSX.Element => { 11 | const { lsResult } = props; 12 | 13 | const lsItems = Object.keys(lsResult).map( 14 | (key): JSX.Element => { 15 | if (lsResult[key].type === 'FOLDER') { 16 | return ( 17 |
  • 18 | {key} 19 |
  • 20 | ); 21 | } 22 | 23 | return ( 24 |
  • 25 | {key} 26 |
  • 27 | ); 28 | }, 29 | ); 30 | 31 | return
      {lsItems}
    ; 32 | }; 33 | 34 | export default LsResult; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "sourceMap": false, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "types": ["jest", "node"] 23 | }, 24 | "include": ["src/index.tsx", "./src/types.d.ts"], 25 | "exclude": ["node_modules", "dist", "rollup.config.js"], 26 | "typeRoots": ["node_modules/@types", "types.d.ts"], 27 | "paths": { 28 | "lodash/*": ["node_modules/@types/lodash-es/*"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/commands/pwd.ts: -------------------------------------------------------------------------------- 1 | import { AutoCompleteResponse, CommandResponse, FileSystem } from '../index'; 2 | 3 | /** 4 | * Returns current directory 5 | * 6 | * @returns Promise - resolves with current directory 7 | */ 8 | function pwd(_f: FileSystem, currentPath: string): Promise { 9 | return new Promise((resolve): void => { 10 | resolve({ 11 | commandResult: currentPath, 12 | }); 13 | }); 14 | } 15 | 16 | /** 17 | * Do nothing for pwd autocomplete 18 | */ 19 | function pwdAutoComplete( 20 | /* eslint-disable @typescript-eslint/no-unused-vars */ 21 | _fileSystem: FileSystem, 22 | _currentPath: string, 23 | _target: string, 24 | /* eslint-enable @typescript-eslint/no-unused-vars */ 25 | ): Promise { 26 | return new Promise((resolve): void => { 27 | resolve({ 28 | commandResult: null, 29 | }); 30 | }); 31 | } 32 | 33 | export default { 34 | autoCompleteHandler: pwdAutoComplete, 35 | description: 'Prints the current working directory', 36 | handler: pwd, 37 | }; 38 | -------------------------------------------------------------------------------- /src/commands/help.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HelpMenu from '../components/HelpMenu'; 3 | import { AutoCompleteResponse, CommandResponse, FileSystem } from '../index'; 4 | 5 | /** 6 | * Returns help menu for system commands 7 | * 8 | * @returns Promise - resolves with list of system commands 9 | */ 10 | function help(): Promise { 11 | return new Promise((resolve): void => { 12 | resolve({ 13 | commandResult: , 14 | }); 15 | }); 16 | } 17 | 18 | /** 19 | * Do nothing for pwd autocomplete 20 | */ 21 | function helpAutoComplete( 22 | /* eslint-disable @typescript-eslint/no-unused-vars */ 23 | _fileSystem: FileSystem, 24 | _currentPath: string, 25 | _target: string, 26 | /* eslint-enable @typescript-eslint/no-unused-vars */ 27 | ): Promise { 28 | return new Promise((resolve): void => { 29 | resolve({ 30 | commandResult: null, 31 | }); 32 | }); 33 | } 34 | 35 | export default { 36 | autoCompleteHandler: helpAutoComplete, 37 | description: 'Prints list of available commands', 38 | handler: help, 39 | }; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Caleb Taylor 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 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import cleaner from 'rollup-plugin-cleaner'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import external from 'rollup-plugin-peer-deps-external'; 4 | import scss from 'rollup-plugin-scss'; 5 | import resolve from 'rollup-plugin-node-resolve'; 6 | import svgr from '@svgr/rollup'; 7 | import typescript from 'rollup-plugin-typescript2'; 8 | import url from '@rollup/plugin-url'; 9 | import pkg from './package.json'; 10 | 11 | export const getBasePlugins = (targetFolder) => [ 12 | cleaner({ targets: [targetFolder] }), 13 | url({ exclude: ['**/*.svg'] }), 14 | svgr(), 15 | resolve(), 16 | scss({ 17 | output: `${targetFolder}/index.css`, 18 | }), 19 | ]; 20 | 21 | export default { 22 | input: 'src/index.tsx', 23 | output: [ 24 | { 25 | file: pkg.main, 26 | format: 'cjs', 27 | exports: 'named', 28 | sourcemap: false, 29 | }, 30 | { 31 | file: pkg.module, 32 | format: 'es', 33 | exports: 'named', 34 | sourcemap: false, 35 | }, 36 | ], 37 | external: Object.keys(pkg.peerDependencies), 38 | plugins: [ 39 | ...getBasePlugins('dist'), 40 | typescript({ 41 | rollupCommonJSResolveHack: true, 42 | clean: true, 43 | }), 44 | commonjs(), 45 | external(), 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/AutoCompleteList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface AutoCompleteList { 4 | [index: string]: { 5 | type: 'FOLDER' | 'FILE'; 6 | }; 7 | } 8 | 9 | interface AutoCompleteListProps { 10 | items: AutoCompleteList; 11 | activeItemIndex?: number; 12 | } 13 | 14 | const AutoCompleteList: React.FC = ( 15 | props, 16 | ): JSX.Element => { 17 | const { items, activeItemIndex } = props; 18 | 19 | const autoCompleteItems = Object.keys(items).map( 20 | (key, index): JSX.Element => { 21 | if (items[key].type === 'FOLDER') { 22 | return ( 23 | 31 | {key}/ 32 | 33 | ); 34 | } 35 | 36 | return ( 37 | 45 | {key} 46 | 47 | ); 48 | }, 49 | ); 50 | 51 | return
    {autoCompleteItems}
    ; 52 | }; 53 | 54 | export default AutoCompleteList; 55 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | env: { 4 | jest: true, 5 | }, 6 | extends: [ 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:react/recommended', 9 | 'plugin:prettier/recommended', 10 | 'prettier/@typescript-eslint', 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | sourceType: 'module', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | project: './tsconfig.eslint.json', 19 | 20 | // Using __dirname to solve this issue: 21 | // https://github.com/typescript-eslint/typescript-eslint/issues/251 22 | tsconfigRootDir: __dirname, 23 | }, 24 | rules: { 25 | indent: ['error', 2, { SwitchCase: 1 }], 26 | 'react/prop-types': false, 27 | '@typescript-eslint/prefer-interface': 'off', 28 | '@typescript-eslint/explicit-member-accessibility': 'off', 29 | '@typescript-eslint/explicit-function-return-type': 'off', 30 | 'react/prop-types': 'off', 31 | 'prettier/prettier': [ 32 | 'error', 33 | { 34 | endOfLine: 'auto', 35 | }, 36 | ], 37 | }, 38 | overrides: [ 39 | { 40 | files: ['*.ts', '*.tsx'], 41 | rules: { 42 | '@typescript-eslint/explicit-function-return-type': ['error'], 43 | }, 44 | }, 45 | ], 46 | settings: { 47 | react: { 48 | version: 'detect', 49 | }, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from 'react'; 2 | import { InputPrompt } from './InputPrompt'; 3 | 4 | interface InputProps { 5 | currentPath: string; 6 | inputValue: string; 7 | inputPrompt: string; 8 | handleChange?(event: ChangeEvent): void; 9 | handleKeyDown?(event: KeyboardEvent): void; 10 | handleKeyUp?(event: KeyboardEvent): void; 11 | handleSubmit?(event: FormEvent): void; 12 | readOnly: boolean; 13 | ref?: RefObject; 14 | } 15 | 16 | export const Input: React.FC = React.forwardRef(function Input( 17 | props, 18 | ref, 19 | ): JSX.Element { 20 | const { 21 | currentPath, 22 | handleChange, 23 | handleKeyDown, 24 | handleKeyUp, 25 | handleSubmit, 26 | inputValue, 27 | inputPrompt, 28 | readOnly, 29 | } = props; 30 | 31 | return ( 32 |
    33 |
    34 | 35 | 48 | 49 |
    50 | ); 51 | }); 52 | 53 | export default Input; 54 | -------------------------------------------------------------------------------- /rollup.dev.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from 'rollup-plugin-commonjs'; 2 | import copy from 'rollup-plugin-copy'; 3 | import replace from 'rollup-plugin-replace'; 4 | import serve from 'rollup-plugin-serve'; 5 | import typescript from 'rollup-plugin-typescript2'; 6 | 7 | import { getBasePlugins } from './rollup.config'; 8 | 9 | export default (commandLineArgs) => { 10 | const targetFolder = commandLineArgs.configDocBuild ? 'docs' : 'dist'; 11 | const config = { 12 | input: 'src/demo.tsx', 13 | output: [ 14 | { 15 | file: `${targetFolder}/demo.js`, 16 | format: 'iife', 17 | sourcemap: true, 18 | }, 19 | ], 20 | plugins: [ 21 | ...getBasePlugins(targetFolder), 22 | commonjs({ 23 | include: ['node_modules/**'], 24 | namedExports: { 25 | 'node_modules/react/index.js': [ 26 | 'Children', 27 | 'Component', 28 | 'PropTypes', 29 | 'createElement', 30 | ], 31 | 'node_modules/react-dom/index.js': ['render'], 32 | }, 33 | }), 34 | typescript({ 35 | rollupCommonJSResolveHack: true, 36 | clean: true, 37 | tsconfigOverride: { 38 | compilerOptions: { declaration: false, sourceMap: true }, 39 | include: ['src/index.tsx', './src/types.d.ts', 'src/demo.tsx'], 40 | }, 41 | }), 42 | copy({ 43 | targets: [{ src: 'src/templates/index.html', dest: targetFolder }], 44 | }), 45 | replace({ 46 | 'process.env.NODE_ENV': JSON.stringify('development'), 47 | }), 48 | ], 49 | }; 50 | 51 | if (!commandLineArgs.configDocBuild) { 52 | config.plugins.push(serve(targetFolder)); 53 | } 54 | 55 | return config; 56 | }; 57 | -------------------------------------------------------------------------------- /src/demo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import get from 'lodash/get'; 4 | import { 5 | autoComplete, 6 | CommandResponse, 7 | FileSystem, 8 | Terminal, 9 | utilities, 10 | } from './'; 11 | import exampleFileSystem from './data/exampleFileSystem'; 12 | 13 | const { getInternalPath, stripFileExtension } = utilities; 14 | 15 | const hello = { 16 | hello: { 17 | handler: function hello(): Promise { 18 | return new Promise((resolve): void => { 19 | resolve({ 20 | commandResult: 'world', 21 | }); 22 | }); 23 | }, 24 | }, 25 | length: { 26 | handler: function length( 27 | fileSystem: FileSystem, 28 | currentPath: string, 29 | targetPath: string, 30 | ): Promise { 31 | return new Promise((resolve, reject): void => { 32 | if (!targetPath) { 33 | reject('Invalid target path'); 34 | } 35 | 36 | const pathWithoutExtension = stripFileExtension(targetPath); 37 | const file = get( 38 | fileSystem, 39 | getInternalPath(currentPath, pathWithoutExtension), 40 | ); 41 | 42 | if (!file) { 43 | reject('Invalid target path'); 44 | } 45 | 46 | if (file.extension !== 'txt') { 47 | reject('Target is not a .txt file'); 48 | } 49 | 50 | let fileLength = 'Unknown length'; 51 | if (typeof file.content === 'string') { 52 | fileLength = '' + file.content.length; 53 | } 54 | 55 | resolve({ 56 | commandResult: fileLength, 57 | }); 58 | }); 59 | }, 60 | autoCompleteHandler: autoComplete, 61 | }, 62 | }; 63 | 64 | ReactDOM.render( 65 | , 66 | document.getElementById('terminal-container'), 67 | ); 68 | -------------------------------------------------------------------------------- /src/data/exampleFileSystem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FileSystem } from '../index'; 3 | import dogImg from '../../src/images/dog.png'; 4 | 5 | interface BlogPostProps { 6 | date: string; 7 | content: string; 8 | } 9 | 10 | export const BlogPost: React.FC = ({ 11 | date, 12 | content, 13 | }): JSX.Element => ( 14 | <> 15 |

    {date}

    16 |

    {content}

    17 | 18 | ); 19 | 20 | const exampleFileSystem: FileSystem = { 21 | home: { 22 | type: 'FOLDER', 23 | children: { 24 | user: { 25 | type: 'FOLDER', 26 | children: { 27 | test: { 28 | type: 'FOLDER', 29 | children: null, 30 | }, 31 | }, 32 | }, 33 | videos: { 34 | type: 'FOLDER', 35 | children: { 36 | file2: { 37 | type: 'FILE', 38 | content: 'Contents of file 2', 39 | extension: 'txt', 40 | }, 41 | }, 42 | }, 43 | dog: { 44 | type: 'FILE', 45 | content: dogImg, 46 | extension: 'png', 47 | }, 48 | file1: { 49 | type: 'FILE', 50 | content: 'Contents of file 1', 51 | extension: 'txt', 52 | }, 53 | file5: { 54 | type: 'FILE', 55 | content: 'Contents of file 5', 56 | extension: 'txt', 57 | }, 58 | }, 59 | }, 60 | docs: { 61 | type: 'FOLDER', 62 | children: null, 63 | }, 64 | file3: { 65 | type: 'FILE', 66 | content: 'Contents of file 3', 67 | extension: 'txt', 68 | }, 69 | file4: { 70 | type: 'FILE', 71 | content: 'Contents of file 4', 72 | extension: 'txt', 73 | }, 74 | blog: { 75 | type: 'FILE', 76 | content: , 77 | extension: 'txt', 78 | }, 79 | }; 80 | 81 | export default exampleFileSystem; 82 | -------------------------------------------------------------------------------- /src/commands/ls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LsResult from '../components/LsResult'; 3 | import { getTarget, buildItemList } from './utilities'; 4 | import { CommandResponse, FileSystem, AutoCompleteResponse } from '../index'; 5 | import autoComplete from './autoComplete'; 6 | 7 | /** 8 | * Given a fileysystem, lists all items for a given directory 9 | * 10 | * @param fileSystem {object} - filesystem to ls upon 11 | * @param currentPath {string} - current path within filesystem 12 | * @param targetPath {string} - path to list contents within 13 | * @returns Promise - resolves with contents of given path 14 | */ 15 | function ls( 16 | fileSystem: FileSystem, 17 | currentPath: string, 18 | targetPath = '', 19 | ): Promise { 20 | return new Promise((resolve, reject): void => { 21 | let targetFolderContents; 22 | try { 23 | targetFolderContents = getTarget(fileSystem, currentPath, targetPath); 24 | } catch (e) { 25 | return reject(e.message); 26 | } 27 | 28 | resolve({ 29 | commandResult: ( 30 | 31 | ), 32 | }); 33 | }); 34 | } 35 | 36 | /** 37 | * Given a fileysystem, current path, and target, list the items in the desired 38 | * folder that start with target string 39 | * 40 | * @param fileSystem {object} - filesystem to ls upon 41 | * @param currentPath {string} - current path within filesystem 42 | * @param target {string} - string to match against (maybe be path) 43 | * @returns Promise - resolves with contents that match target in path 44 | */ 45 | function lsAutoComplete( 46 | fileSystem: FileSystem, 47 | currentPath: string, 48 | target: string, 49 | ): Promise { 50 | return autoComplete(fileSystem, currentPath, target); 51 | } 52 | 53 | export default { 54 | autoCompleteHandler: lsAutoComplete, 55 | description: 'Lists the contents of the given directory', 56 | handler: ls, 57 | }; 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | # Contributing to JumpStart 2 | 3 | Want to contribute? Great! Here's a quick guide to get started. 4 | 5 | ## Git Commits 6 | This project uses [commitzen](https://github.com/commitizen/cz-cli) to ensure a consistent git commit message standard. 7 | When you are ready to commit code, use the command: 8 | 9 | ``` 10 | npm run cm 11 | ``` 12 | 13 | Now just follow the prompts commit your code. The description of the change should follow these standards: 14 | 15 | - use the imperative, present tense: "change" not "changed" nor "changes" 16 | - don't capitalize the first letter 17 | - no dot (.) at the end 18 | 19 | **Important:** If your commit fails (i.e. from a `pre-commit` hook), you can re-commit without having to re-enter all of your 20 | previous choices by running: 21 | 22 | ``` 23 | npm run cm -- --retry 24 | ``` 25 | 26 | ## Submitting a Pull Request 27 | 28 | Please use the [Github Flow](https://guides.github.com/introduction/flow/index.html) for any code changes. The typical process will be the following: 29 | 30 | 1. Fork the repo and create your branch from master 31 | 2. Any changes that can be documented, should be documented 32 | 3. All `npm` commands should still correctly function 33 | 4. Code should lint properly via `npm run lint` 34 | 5. Create the pull request! 35 | 36 | ## License 37 | By contributing, you agree that your contributions will be licensed under its MIT License. 38 | 39 | ## Reporting Issues 40 | 41 | If you have an issue, please [open a new issue](https://github.com/ctaylo21/JumpStart/issues). 42 | 43 | **Great Bug Reports** tend to have: 44 | 45 | - A quick summary and/or background 46 | - Steps to reproduce 47 | - Be specific! 48 | - Give sample code if you can. 49 | - What you expected would happen 50 | - What actually happens 51 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 52 | 53 | ## Code Standards 54 | 55 | All code written should conform to [eslint](https://eslint.org/). No errors should be present after running `npm run lint`. All commited `*.js` files 56 | are automatically linted on commits to ensure they adhere to project standards. 57 | 58 | -------------------------------------------------------------------------------- /src/commands/cat.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | getInternalPath, 4 | stripFileExtension, 5 | isImageExtension, 6 | } from './utilities'; 7 | import get from 'lodash/get'; 8 | import { 9 | AutoCompleteResponse, 10 | CommandResponse, 11 | FileSystem, 12 | TerminalImageFile, 13 | } from '../index'; 14 | import TerminalImage from '../components/TerminalImage'; 15 | import autoComplete from './autoComplete'; 16 | 17 | /** 18 | * Given a file system, returns contents for a given file 19 | * 20 | * @param fileSystem {object} - filesystem to cat upon 21 | * @param currentPath {string} - current path within filesystem 22 | * @param targetPath {string} - path of file to cat 23 | * @returns Promise - resolves with contents of file 24 | */ 25 | function cat( 26 | fileSystem: FileSystem, 27 | currentPath: string, 28 | targetPath: string, 29 | ): Promise { 30 | return new Promise((resolve, reject): void => { 31 | if (!targetPath) { 32 | reject('Invalid target path'); 33 | } 34 | 35 | const pathWithoutExtension = stripFileExtension(targetPath); 36 | const file = get( 37 | fileSystem, 38 | getInternalPath(currentPath, pathWithoutExtension), 39 | ); 40 | 41 | if (!file) { 42 | reject('Invalid target path'); 43 | } 44 | 45 | if (file.type === 'FILE') { 46 | if (isImageExtension(file.extension)) { 47 | resolve({ 48 | commandResult: ( 49 | 50 | ), 51 | }); 52 | } else { 53 | resolve({ 54 | commandResult: file.content, 55 | }); 56 | } 57 | } 58 | 59 | reject('Target is not a file'); 60 | }); 61 | } 62 | 63 | /** 64 | * Given a fileysystem, current path, and target, list the items in the desired 65 | * folder that start with target string 66 | * 67 | * @param fileSystem {object} - filesystem to ls upon 68 | * @param currentPath {string} - current path within filesystem 69 | * @param target {string} - string to match against (maybe be path) 70 | * @returns Promise - resolves with contents that match target in path 71 | */ 72 | function catAutoComplete( 73 | fileSystem: FileSystem, 74 | currentPath: string, 75 | target: string, 76 | ): Promise { 77 | return autoComplete(fileSystem, currentPath, target); 78 | } 79 | 80 | export default { 81 | autoCompleteHandler: catAutoComplete, 82 | description: 'Shows the contents of a file', 83 | handler: cat, 84 | }; 85 | -------------------------------------------------------------------------------- /src/commands/mkdir.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep'; 2 | import set from 'lodash/set'; 3 | import has from 'lodash/has'; 4 | import { getInternalPath } from './utilities/index'; 5 | import { 6 | AutoCompleteResponse, 7 | CommandResponse, 8 | FileSystem, 9 | ItemListType, 10 | TerminalFolder, 11 | } from '../index'; 12 | import autoComplete from './autoComplete'; 13 | 14 | /** 15 | * Given a folder path, creates that folder for the given file system and 16 | * returns the new file system if valid. If not, rejects with error. 17 | * 18 | * @param fileSystem {object} - filesystem to mkdir upon 19 | * @param currentPath {string} - current path within filesystem 20 | * @param folderPath {string} - folder path to create 21 | * @returns Promise - resolves with new path created if successful, rejects if not 22 | */ 23 | function mkdir( 24 | fileSystem: FileSystem, 25 | currentPath: string, 26 | folderPath: string, 27 | ): Promise { 28 | return new Promise((resolve, reject): void => { 29 | if (has(fileSystem, getInternalPath(currentPath, folderPath))) { 30 | reject('Path already exists'); 31 | } 32 | 33 | const newFolder: TerminalFolder = { 34 | type: 'FOLDER', 35 | children: null, 36 | }; 37 | 38 | const newFileSystem = cloneDeep(fileSystem); 39 | set(newFileSystem, getInternalPath(currentPath, folderPath), newFolder); 40 | 41 | resolve({ 42 | commandResult: `Folder created: ${folderPath}`, 43 | updatedState: { 44 | fileSystem: newFileSystem, 45 | }, 46 | }); 47 | }); 48 | } 49 | 50 | /** 51 | * Given a fileysystem, current path, and target, list the items in the desired 52 | * folder that start with target string 53 | * 54 | * @param fileSystem {object} - filesystem to ls upon 55 | * @param currentPath {string} - current path within filesystem 56 | * @param target {string} - string to match against (maybe be path) 57 | * @returns Promise - resolves with contents that match target in path 58 | */ 59 | function mkdirAutoComplete( 60 | fileSystem: FileSystem, 61 | currentPath: string, 62 | target: string, 63 | ): Promise { 64 | const filterNonFilesFn = (item: ItemListType): boolean => 65 | item[Object.keys(item)[0]].type === 'FOLDER'; 66 | 67 | return autoComplete(fileSystem, currentPath, target, filterNonFilesFn); 68 | } 69 | 70 | export default { 71 | autoCompleteHandler: mkdirAutoComplete, 72 | description: 'Creates a folder for a given path in the filesystem', 73 | handler: mkdir, 74 | }; 75 | -------------------------------------------------------------------------------- /src/commands/cd.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import has from 'lodash/has'; 3 | import { 4 | AutoCompleteResponse, 5 | CommandResponse, 6 | FileSystem, 7 | ItemListType, 8 | } from '../index'; 9 | import { 10 | convertInternalPathToExternal, 11 | getInternalPath, 12 | } from './utilities/index'; 13 | import autoComplete from './autoComplete'; 14 | 15 | /** 16 | * Given a file system, validates if changing directories from a given path 17 | * to a new path is possible, and returns the new path if so. 18 | * 19 | * @param fileSystem {object} - filesystem to cd upon 20 | * @param currentPath {string} - current path within filesystem 21 | * @param targetPath {string} - path to change to 22 | * @returns Promise - resolves with new path if successful, rejects if not 23 | */ 24 | function cd( 25 | fileSystem: FileSystem, 26 | currentPath: string, 27 | targetPath: string, 28 | ): Promise { 29 | return new Promise((resolve, reject): void => { 30 | if (!targetPath) { 31 | reject('path can not be empty.'); 32 | } 33 | const internalCdPath = getInternalPath(currentPath, targetPath); 34 | 35 | if (!internalCdPath) { 36 | resolve({ 37 | updatedState: { 38 | currentPath: '/', 39 | }, 40 | }); 41 | } 42 | 43 | if ( 44 | has(fileSystem, internalCdPath) && 45 | get(fileSystem, internalCdPath).type !== 'FILE' 46 | ) { 47 | resolve({ 48 | updatedState: { 49 | currentPath: convertInternalPathToExternal(internalCdPath), 50 | }, 51 | }); 52 | } 53 | 54 | reject(`path does not exist: ${targetPath}`); 55 | }); 56 | } 57 | 58 | /** 59 | * Given a fileysystem, current path, and target, list the items in the desired 60 | * folder that start with target string 61 | * 62 | * @param fileSystem {object} - filesystem to ls upon 63 | * @param currentPath {string} - current path within filesystem 64 | * @param target {string} - string to match against (maybe be path) 65 | * @returns Promise - resolves with contents that match target in path 66 | */ 67 | function cdAutoComplete( 68 | fileSystem: FileSystem, 69 | currentPath: string, 70 | target: string, 71 | ): Promise { 72 | const filterNonFilesFn = (item: ItemListType): boolean => 73 | item[Object.keys(item)[0]].type === 'FOLDER'; 74 | 75 | return autoComplete(fileSystem, currentPath, target, filterNonFilesFn); 76 | } 77 | 78 | export default { 79 | autoCompleteHandler: cdAutoComplete, 80 | description: 'Changes the current working directory', 81 | handler: cd, 82 | }; 83 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | #terminal-wrapper { 2 | background-color: #1b2b34; 3 | box-sizing: content-box; 4 | color: #d8dee9; 5 | font-family: Inconsolata, monospace; 6 | font-size: 18px; 7 | height: 100%; 8 | overflow: auto; 9 | padding: 0 1em; } 10 | #terminal-wrapper #input-container { 11 | padding: 1em 0; } 12 | #terminal-wrapper #input-container form { 13 | display: flex; } 14 | #terminal-wrapper #input-container form input { 15 | background: none; 16 | border: none; 17 | color: #d8dee9; 18 | font-family: Inconsolata, monospace; 19 | font-size: 18px; 20 | outline: none; 21 | -webkit-appearance: none; 22 | flex-grow: 100; 23 | margin: 0 10px; } 24 | #terminal-wrapper #input-container form #inputPrompt { 25 | color: #9ac794; } 26 | #terminal-wrapper #history-container > ul > li > #input-container { 27 | padding-bottom: 0; } 28 | #terminal-wrapper #history-container img { 29 | margin-top: 10px; } 30 | #terminal-wrapper #history-container #help-container ul > li { 31 | margin: 5px 0; } 32 | #terminal-wrapper #history-container #help-container ul > li > span.help-command-name { 33 | color: #c594c5; } 34 | #terminal-wrapper #history-container ul { 35 | list-style-type: none; 36 | margin: 0; 37 | padding: 0; } 38 | #terminal-wrapper #history-container ul.terminal-ls-list { 39 | margin: 10px 0 0 10px; } 40 | #terminal-wrapper #history-container ul.terminal-ls-list li { 41 | display: flex; 42 | align-items: center; } 43 | #terminal-wrapper #history-container ul.terminal-ls-list li svg { 44 | margin-right: 20px; } 45 | #terminal-wrapper #history-container ul.terminal-ls-list li.ls-folder { 46 | color: #f99157; } 47 | #terminal-wrapper #history-container ul.terminal-ls-list li.ls-folder svg { 48 | fill: #f99157; 49 | height: 24px; 50 | width: 24px; } 51 | #terminal-wrapper #history-container ul.terminal-ls-list li.ls-file { 52 | color: #5fb3b3; } 53 | #terminal-wrapper #history-container ul.terminal-ls-list li.ls-file svg { 54 | fill: #5fb3b3; 55 | margin-top: 2px; 56 | margin-bottom: 2px; 57 | height: 20px; 58 | width: 24px; } 59 | #terminal-wrapper .preview-list span { 60 | margin: 0px 10px; } 61 | #terminal-wrapper .preview-list span.auto-preview-folder { 62 | color: #f99157; } 63 | #terminal-wrapper .preview-list span.auto-preview-file { 64 | color: #5fb3b3; } 65 | #terminal-wrapper .preview-list span.active { 66 | background-color: #4F5B66; } 67 | -------------------------------------------------------------------------------- /src/styles/Terminal.scss: -------------------------------------------------------------------------------- 1 | @import './vars'; 2 | 3 | #terminal-wrapper { 4 | background-color: map-get($themeDark, background); 5 | box-sizing: content-box; 6 | color: map-get($themeDark, fontColor); 7 | font-family: map-get($themeDark, fontFamily); 8 | font-size: map-get($themeDark, fontSize); 9 | height: 100%; 10 | overflow: auto; 11 | padding: 0 1em; 12 | 13 | #input-container { 14 | padding: 1em 0; 15 | 16 | form { 17 | display: flex; 18 | 19 | input { 20 | background: none; 21 | border: none; 22 | color: map-get($themeDark, fontColor); 23 | font-family: map-get($themeDark, fontFamily); 24 | font-size: map-get($themeDark, fontSize); 25 | outline: none; 26 | -webkit-appearance: none; 27 | 28 | flex-grow: 100; 29 | margin: 0 10px; 30 | } 31 | 32 | #inputPrompt { 33 | color: map-get($themeDark, green); 34 | } 35 | } 36 | } 37 | 38 | #history-container { 39 | & > ul > li > #input-container { 40 | padding-bottom: 0; 41 | } 42 | 43 | img { 44 | margin-top: 10px; 45 | } 46 | 47 | #help-container ul > li { 48 | margin: 5px 0; 49 | 50 | & > span.help-command-name { 51 | color: map-get($themeDark, purple); 52 | } 53 | } 54 | 55 | ul { 56 | list-style-type: none; 57 | margin: 0; 58 | padding: 0; 59 | } 60 | 61 | ul.terminal-ls-list { 62 | margin: 10px 0 0 10px; 63 | 64 | li { 65 | display: flex; 66 | align-items: center; 67 | 68 | svg { 69 | margin-right: 20px; 70 | } 71 | 72 | &.ls-folder { 73 | color: map-get($themeDark, orange); 74 | 75 | svg { 76 | fill: map-get($themeDark, orange); 77 | height: 24px; 78 | width: 24px; 79 | } 80 | } 81 | 82 | &.ls-file { 83 | color: map-get($themeDark, blue); 84 | 85 | svg { 86 | fill: map-get($themeDark, blue); 87 | margin-top: 2px; 88 | margin-bottom: 2px; 89 | height: 20px; 90 | width: 24px; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | .preview-list { 98 | span { 99 | margin: 0px 10px; 100 | 101 | &.auto-preview-folder { 102 | color: map-get($themeDark, orange); 103 | } 104 | 105 | &.auto-preview-file { 106 | color: map-get($themeDark, blue); 107 | } 108 | 109 | &.active { 110 | background-color: map-get($themeDark, highlight); 111 | } 112 | } 113 | 114 | 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/helpers/autoComplete.ts: -------------------------------------------------------------------------------- 1 | import { ItemListType } from '../'; 2 | 3 | /** 4 | * Given a input value for a command with a target, return the new value 5 | * for a tab press. 6 | * 7 | * Cases: 8 | * 1) Matching a partial target like "fi" when "file1.txt" is an option 9 | * 2) Replacing last value full file path like "/home/user/"" when "user/"" is AC option 10 | * 3) Appending onto path like "home/" when "home" isn't in AC and is part of base path 11 | * 12 | * @param currentInputValue {string} - Current full input value 13 | * @param currentTargetPath {string} - Current full path of command target 14 | * @param targetFormattedName {string} - Full target name (include "/" for folders and file extensions) 15 | * @param targetPathToUpdate {string} - Section of target path to update 16 | * @returns {string} - Updated value to put in input 17 | */ 18 | export function getUpdatedInputValueFromTarget( 19 | currentInputValue: string, 20 | currentTargetPath: string, 21 | targetFormattedName: string, 22 | targetPathToUpdate?: string, 23 | ): string { 24 | let updatedInputValue = currentInputValue + targetFormattedName; 25 | if (targetPathToUpdate) { 26 | const isCurrentItemFolder = currentTargetPath.endsWith('/'); 27 | updatedInputValue = currentInputValue.replace( 28 | new RegExp( 29 | isCurrentItemFolder 30 | ? `${targetPathToUpdate}\/$` 31 | : `${targetPathToUpdate}$`, 32 | ), 33 | targetFormattedName, 34 | ); 35 | } 36 | 37 | return updatedInputValue; 38 | } 39 | 40 | /** 41 | * Given a list of files/folders and a key, return the user-friendly version 42 | * of that given item. This includes a trailing "/" for folders. 43 | * 44 | * @param fileSystem {object} - list of files/folders 45 | * @param itemKey {string} - key to grab from file system 46 | * @returns {string} - user-friendly item name 47 | */ 48 | export function formatItem(fileSystem: ItemListType, itemKey: number): string { 49 | const targetRawName = Object.keys(fileSystem)[itemKey]; 50 | return fileSystem[targetRawName].type === 'FOLDER' 51 | ? `${targetRawName}/` 52 | : targetRawName; 53 | } 54 | 55 | /** 56 | * Given the current target path for an autocomplete command, return the section 57 | * of the target path to update. Splits a nested path into parts and grabs the last 58 | * part of the path 59 | * 60 | * Example: 61 | * "home/us" => "us" 62 | * "home/user/test/" => "test" 63 | * "home" => "home" 64 | * 65 | * @param currentTargetPath {string} - current target path, may include multiple levels 66 | * @returns {string} - last part of the given path 67 | */ 68 | export function getTargetPath(currentTargetPath: string): string { 69 | return currentTargetPath 70 | .replace(/\/$/, '') 71 | .split('/') 72 | .slice(-1) 73 | .pop() as string; 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/autoComplete.ts: -------------------------------------------------------------------------------- 1 | import { AutoCompleteResponse, FileSystem } from '../index'; 2 | import { getTarget } from './utilities'; 3 | import { ItemListType } from '../'; 4 | 5 | /** 6 | * Takes an internally formatted filesystem and formats it into the 7 | * expected format for an ls command. Optionally takes a function to apply 8 | * to the intiial result to filter out certain items. 9 | * 10 | * @param directory {object} - internally formatted filesystem 11 | * @param filterFn {function} - optional fn to filter certain items 12 | */ 13 | function buildAutoCompleteData( 14 | fileSystem: FileSystem, 15 | autoCompleteMatch: string, 16 | filterFn: (item: ItemListType) => boolean, 17 | ): ItemListType { 18 | const autoCompleteMatchFn = (item: ItemListType): boolean => 19 | Object.keys(item)[0].startsWith(autoCompleteMatch); 20 | 21 | return Object.assign( 22 | {}, 23 | ...Object.keys(fileSystem) 24 | .map((item) => ({ 25 | [fileSystem[item].type === 'FILE' 26 | ? `${item}.${fileSystem[item].extension}` 27 | : item]: { 28 | type: fileSystem[item].type, 29 | }, 30 | })) 31 | .filter(autoCompleteMatchFn) 32 | .filter(filterFn), 33 | ); 34 | } 35 | 36 | /** 37 | * Given a fileysystem, current path, and target, list the items in the desired 38 | * folder that start with target string 39 | * 40 | * @param fileSystem {object} - filesystem to ls upon 41 | * @param currentPath {string} - current path within filesystem 42 | * @param target {string} - string to match against (maybe be path) 43 | * @returns Promise - resolves with contents that match target in path 44 | */ 45 | export default function autoComplete( 46 | fileSystem: FileSystem, 47 | currentPath: string, 48 | target: string, 49 | filterFn: (item: ItemListType) => boolean = (): boolean => true, 50 | ): Promise { 51 | return new Promise((resolve): void => { 52 | // Default to searching in currenty directory with simple target 53 | // that contains no path 54 | let autoCompleteMatch = target; 55 | let targetPath = ''; 56 | 57 | // Handle case where target is a nested path and 58 | // we need to pull off last part of path to match against 59 | const pathParts = target.split('/'); 60 | if (pathParts.length > 1) { 61 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 62 | autoCompleteMatch = pathParts.pop()!; 63 | targetPath = pathParts.join('/'); 64 | } 65 | 66 | let targetFolderContents; 67 | try { 68 | targetFolderContents = getTarget(fileSystem, currentPath, targetPath); 69 | } catch (e) { 70 | return resolve({ commandResult: undefined }); 71 | } 72 | 73 | resolve({ 74 | commandResult: buildAutoCompleteData( 75 | targetFolderContents, 76 | autoCompleteMatch, 77 | filterFn, 78 | ), 79 | }); 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/rm.ts: -------------------------------------------------------------------------------- 1 | import { AutoCompleteResponse, CommandResponse, FileSystem } from '../index'; 2 | import cloneDeep from 'lodash/cloneDeep'; 3 | import get from 'lodash/get'; 4 | import has from 'lodash/has'; 5 | import unset from 'lodash/unset'; 6 | import { getInternalPath } from './utilities/index'; 7 | import autoComplete from './autoComplete'; 8 | 9 | /** 10 | * Deletes path from given filesystem and returns updatd filesystem 11 | * without modifying original arg. 12 | * 13 | * @param fileSystem {object} - filesystem to act upon 14 | * @param pathToDelete {string} - internally-formatted path to delete 15 | */ 16 | function handleDelete( 17 | fileSystem: FileSystem, 18 | pathToDelete: string, 19 | ): CommandResponse { 20 | const newFileSystem = cloneDeep(fileSystem); 21 | unset(newFileSystem, pathToDelete); 22 | return { 23 | updatedState: { 24 | fileSystem: newFileSystem, 25 | }, 26 | }; 27 | } 28 | 29 | /** 30 | * Given a path, removes the object at that location if possible. Rejects if 31 | * the parameters aren't correct for the given item 32 | * 33 | * @param fileSystem {object} - filesystem to act upon 34 | * @param currentPath {string} - current path within filesystem 35 | * @param folderPath {string} - path of object to remove 36 | * @returns Promise - resolves if rm was successful, rejects if not 37 | */ 38 | function rm( 39 | fileSystem: FileSystem, 40 | currentPath: string, 41 | targetPath: string, 42 | options?: string, 43 | ): Promise { 44 | return new Promise((resolve, reject): void => { 45 | if (!targetPath) { 46 | reject('Missing argument to rm'); 47 | } 48 | 49 | const internalCdPath = getInternalPath(currentPath, targetPath); 50 | 51 | if (has(fileSystem, internalCdPath)) { 52 | if (get(fileSystem, internalCdPath).type === 'FOLDER') { 53 | if (options === '-r') { 54 | resolve(handleDelete(fileSystem, internalCdPath)); 55 | } else { 56 | reject(`Can't remove ${targetPath}. It is a directory.`); 57 | } 58 | } 59 | 60 | if (get(fileSystem, internalCdPath).type === 'FILE') { 61 | resolve(handleDelete(fileSystem, internalCdPath)); 62 | } 63 | } 64 | 65 | reject(`Can't remove ${targetPath}. No such file or directory.`); 66 | }); 67 | } 68 | 69 | /** 70 | * Given a fileysystem, current path, and target, list the items in the desired 71 | * folder that start with target string 72 | * 73 | * @param fileSystem {object} - filesystem to ls upon 74 | * @param currentPath {string} - current path within filesystem 75 | * @param target {string} - string to match against (maybe be path) 76 | * @returns Promise - resolves with contents that match target in path 77 | */ 78 | function rmAutoComplete( 79 | fileSystem: FileSystem, 80 | currentPath: string, 81 | target: string, 82 | ): Promise { 83 | return autoComplete(fileSystem, currentPath, target); 84 | } 85 | 86 | export default { 87 | autoCompleteHandler: rmAutoComplete, 88 | description: 'Removes a file or directory', 89 | handler: rm, 90 | }; 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "termy-the-terminal", 3 | "version": "1.3.0", 4 | "description": "Web-based terminal powered by React", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "jsnext:main": "dist/index.es.js", 8 | "types": "dist/index.d.ts", 9 | "engines": { 10 | "node": ">=8", 11 | "npm": ">=5" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "terminal", 16 | "typescript", 17 | "react-component" 18 | ], 19 | "license": "MIT", 20 | "sideEffects": false, 21 | "files": [ 22 | "dist" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git://github.com/ctaylo21/termy-the-terminal" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/ctaylo21/termy-the-terminal/issues" 30 | }, 31 | "scripts": { 32 | "test": "jest", 33 | "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls", 34 | "start": "rollup -c rollup.dev.config.js -w", 35 | "build": "npm run build:lib && npm run build:demo", 36 | "build:lib": "rollup -c", 37 | "build:demo": "rollup -c rollup.dev.config.js --configDocBuild", 38 | "commit": "git-cz", 39 | "prepare": "npm run build", 40 | "preversion": "npm run lint", 41 | "prepublishOnly": "npm test && npm run lint", 42 | "lint": "eslint src --ext ts,tsx", 43 | "release": "dotenv release-it" 44 | }, 45 | "husky": { 46 | "hooks": { 47 | "pre-commit": "lint-staged" 48 | } 49 | }, 50 | "lint-staged": { 51 | "*.{js,jsx,ts,tsx}": [ 52 | "eslint" 53 | ], 54 | "*.{js,jsx,ts,tsx,json,md}": [ 55 | "prettier --write" 56 | ] 57 | }, 58 | "author": "Caleb Taylor ", 59 | "peerDependencies": { 60 | "react": "^16.0.0", 61 | "react-dom": "^16.0.0" 62 | }, 63 | "dependencies": { 64 | "lodash-es": "^4.17.15" 65 | }, 66 | "devDependencies": { 67 | "@rollup/plugin-url": "^4.0.2", 68 | "@svgr/rollup": "^5.3.1", 69 | "@testing-library/react": "^10.0.2", 70 | "@testing-library/user-event": "^10.0.2", 71 | "@types/jest": "^25.2.1", 72 | "@types/lodash-es": "^4.17.3", 73 | "@types/node": "^13.13.0", 74 | "@types/react": "^16.9.34", 75 | "@types/react-dom": "^16.9.6", 76 | "@typescript-eslint/eslint-plugin": "^2.28.0", 77 | "@typescript-eslint/parser": "^2.28.0", 78 | "commitizen": "^4.0.4", 79 | "coveralls": "^3.0.11", 80 | "cz-conventional-changelog": "^3.1.0", 81 | "dotenv-cli": "^3.1.0", 82 | "eslint": "^6.8.0", 83 | "eslint-config-prettier": "^6.10.1", 84 | "eslint-plugin-jest": "^23.8.2", 85 | "eslint-plugin-prettier": "^3.1.3", 86 | "eslint-plugin-react": "^7.19.0", 87 | "husky": "^4.2.5", 88 | "jest": "^25.3.0", 89 | "jest-css-modules": "^2.1.0", 90 | "jest-dom": "^4.0.0", 91 | "lint-staged": "^10.1.4", 92 | "node-sass": "^4.14.1", 93 | "npm": "^6.14.4", 94 | "prettier": "^2.0.4", 95 | "react": "^16.8.6", 96 | "react-dom": "^16.8.6", 97 | "release-it": "^13.6.1", 98 | "rollup": "^2.6.1", 99 | "rollup-plugin-cleaner": "^1.0.0", 100 | "rollup-plugin-commonjs": "^10.1.0", 101 | "rollup-plugin-copy": "^3.3.0", 102 | "rollup-plugin-node-resolve": "^5.2.0", 103 | "rollup-plugin-peer-deps-external": "^2.2.2", 104 | "rollup-plugin-replace": "^2.2.0", 105 | "rollup-plugin-scss": "^2.4.0", 106 | "rollup-plugin-serve": "^1.0.1", 107 | "rollup-plugin-sourcemaps": "^0.5.0", 108 | "rollup-plugin-typescript2": "^0.27.0", 109 | "ts-jest": "^25.4.0", 110 | "typescript": "3.8.3" 111 | }, 112 | "config": { 113 | "commitizen": { 114 | "path": "./node_modules/cz-conventional-changelog" 115 | } 116 | }, 117 | "release-it": { 118 | "github": { 119 | "release": true 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.2.0 - 2020-4-18 4 | 5 | ### Added 6 | 7 | - Added autocomplete feature with keypress for all commands 8 | 9 | ## 1.1.1 - 2020-3-22 10 | 11 | ### Fixed 12 | 13 | - Update input to disable auto correct/capitalize/complete 14 | 15 | ## 1.1.0 - 2020-3-22 16 | 17 | ### Fixed 18 | 19 | - Removed image plugin that was breaking icons 20 | 21 | ## 1.0.6 - 2019-12-23 22 | 23 | ### Added 24 | 25 | - Support for images to be used with cat 26 | 27 | ## 1.0.5 - 2019-12-21 28 | 29 | ### Added 30 | 31 | - Support for arrow keys to move through command history 32 | 33 | ## Changed 34 | 35 | - Auto-focus of terminal input once Termy is mounted 36 | 37 | ## 1.0.4 - 2019-12-13 38 | 39 | ### Fixed 40 | 41 | - Incorrect README code example 42 | 43 | ## 1.0.3 - 2019-12-13 44 | 45 | ### Added 46 | 47 | - inputPrompt prop to Termy 48 | 49 | ## 1.0.2 - 2019-11-10 50 | 51 | ### Updated 52 | 53 | - Disable spellcheck in Termy 54 | 55 | ## 1.0.1 - 2019-08-04 56 | 57 | ### Updated 58 | 59 | - New logo for README 60 | - Installation instructions to README 61 | 62 | ## 1.0.0 - 2019-08-03 63 | 64 | ### Updated 65 | 66 | - Increase version to 1.0.0 for publishing 67 | 68 | ## 0.9.0 - 2019-08-03 69 | 70 | ### Added 71 | 72 | - rm command 73 | 74 | ## 0.8.0 - 2019-07-28 75 | 76 | ### Updated 77 | 78 | - Switch build to rollup instead of webpack 79 | 80 | ## 0.7.0 - 2019-07-28 81 | 82 | ### Updated 83 | 84 | - Update build to include types file 85 | 86 | ## 0.6.3 - 2019-07-27 87 | 88 | ### Updated 89 | 90 | - Use matchSnapshot instead of matchInlineSnapshot to reduce clutter 91 | 92 | ## 0.6.2 - 2019-07-15 93 | 94 | ### Updated 95 | 96 | - Scroll to bottom input when off screen 97 | 98 | ## 0.6.1 - 2019-07-15 99 | 100 | ### Updated 101 | 102 | - Update help menu UI 103 | 104 | ## 0.6.0 - 2019-07-12 105 | 106 | ### Added 107 | 108 | - Updated codebase to prepare for npm publish 109 | 110 | ## 0.5.2 - 2019-07-05 111 | 112 | ### Changed 113 | 114 | - Cleanup of terminal code to unify command interface 115 | 116 | ## 0.5.1 - 2019-07-04 117 | 118 | ### Changed 119 | 120 | - Refactor services to all match same contract 121 | 122 | ## 0.5.0 - 2019-05-27 123 | 124 | ### Added 125 | 126 | - Cat command 127 | 128 | ## 0.4.1 - 2019-05-27 129 | 130 | ### Changed 131 | 132 | - Add icons for ls command display 133 | 134 | ## 0.4.0 - 2019-05-26 135 | 136 | ### Added 137 | 138 | - Mkdir command 139 | 140 | ## 0.3.5 - 2019-05-05 141 | 142 | ### Added 143 | 144 | - Help command 145 | 146 | ## 0.3.4 - 2019-05-05 147 | 148 | ### Fixed 149 | 150 | - Bug where ls from nested path with no arg was broken 151 | 152 | ## 0.3.3 - 2019-05-05 153 | 154 | ### Changed 155 | 156 | - Updated UI for history container 157 | 158 | ## 0.3.2 - 2019-04-30 159 | 160 | ### Fixed 161 | 162 | - Bug where ls with no args from nested path was broken 163 | 164 | ## 0.3.1 - 2019-04-28 165 | 166 | ### Added 167 | 168 | - support for `..` in `ls` command paths 169 | 170 | ### Changed 171 | 172 | - refactor internal command args for consistency 173 | - abstracted path helpers into utilities 174 | 175 | ## 0.3.0 - 2019-04-28 176 | 177 | ### Added 178 | 179 | - `ls` command support 180 | 181 | ## 0.2.0 - 2019-04-27 182 | 183 | ### Added 184 | 185 | - `pwd` command support 186 | 187 | ## 0.1.1 - 2019-04-16 188 | 189 | ### Added 190 | 191 | - `cd` command support 192 | 193 | ## 0.1.0 - 2019-01-11 194 | 195 | ### Changed 196 | 197 | - Switched from standardJS to eslint 198 | - Added automated linting on commit via lint-staged & husky 199 | 200 | ## 0.0.7 - 2019-01-11 201 | 202 | ### Changed 203 | 204 | - Switched from regenerator-runtime to @babel/plugin-transform-runtime for async support 205 | 206 | ## 0.0.6 - 2019-01-09 207 | 208 | - Internal changes (incremented dependency versions) 209 | 210 | ## 0.0.5 - 2019-01-09 211 | 212 | ### Changed 213 | 214 | - Switched from webpack-serve to webpack-dev-server 215 | 216 | ## 0.0.4 - 2019-01-09 217 | 218 | - Internal changes (incremented dependency versions) 219 | -------------------------------------------------------------------------------- /src/commands/__tests__/autoComplete.test.ts: -------------------------------------------------------------------------------- 1 | import autoComplete from '../autoComplete'; 2 | import exampleFileSystem from '../../data/exampleFileSystem'; 3 | import { ItemListType } from '../../'; 4 | 5 | describe('auto complete', (): void => { 6 | test('empty value', async (): Promise => { 7 | const { commandResult } = await autoComplete( 8 | exampleFileSystem, 9 | '/home/user', 10 | '', 11 | ); 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 14 | expect(Object.keys(commandResult!)).toContain('test'); 15 | }); 16 | 17 | test('should filter single level target', async (): Promise => { 18 | const { commandResult } = await autoComplete(exampleFileSystem, '/', 'fi'); 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 21 | const autoCompleteValues = Object.keys(commandResult!); 22 | 23 | expect(autoCompleteValues).toContain('file3.txt'); 24 | expect(autoCompleteValues).toContain('file4.txt'); 25 | expect(autoCompleteValues).not.toContain('blog'); 26 | expect(autoCompleteValues).not.toContain('docs'); 27 | expect(autoCompleteValues).not.toContain('home'); 28 | }); 29 | 30 | test('invalid path should return nothing', async (): Promise => { 31 | const lsResult = await autoComplete(exampleFileSystem, '/bad/path', ''); 32 | 33 | expect(lsResult.commandResult).toBeUndefined(); 34 | }); 35 | 36 | test('relative path', async (): Promise => { 37 | const { commandResult } = await autoComplete( 38 | exampleFileSystem, 39 | '/', 40 | 'home/fi', 41 | ); 42 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 43 | const autoCompleteValues = Object.keys(commandResult!); 44 | 45 | expect(autoCompleteValues).toContain('file1.txt'); 46 | expect(autoCompleteValues).toContain('file5.txt'); 47 | expect(autoCompleteValues).not.toContain('user'); 48 | expect(autoCompleteValues).not.toContain('videos'); 49 | expect(autoCompleteValues).not.toContain('dog.png'); 50 | }); 51 | 52 | test('relative path with dotdot', async (): Promise => { 53 | const { commandResult } = await autoComplete( 54 | exampleFileSystem, 55 | '/', 56 | 'home/../home/fi', 57 | ); 58 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 59 | const autoCompleteValues = Object.keys(commandResult!); 60 | 61 | expect(autoCompleteValues).toContain('file1.txt'); 62 | expect(autoCompleteValues).toContain('file5.txt'); 63 | expect(autoCompleteValues).not.toContain('user'); 64 | expect(autoCompleteValues).not.toContain('videos'); 65 | expect(autoCompleteValues).not.toContain('dog.png'); 66 | }); 67 | 68 | test('absolute path', async (): Promise => { 69 | const { commandResult } = await autoComplete( 70 | exampleFileSystem, 71 | '/', 72 | '/home/d', 73 | ); 74 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 75 | const autoCompleteValues = Object.keys(commandResult!); 76 | 77 | expect(autoCompleteValues).toContain('dog.png'); 78 | expect(autoCompleteValues).not.toContain('user'); 79 | expect(autoCompleteValues).not.toContain('videos'); 80 | expect(autoCompleteValues).not.toContain('file1.txt'); 81 | expect(autoCompleteValues).not.toContain('file5.txt'); 82 | }); 83 | 84 | test('absolute path with ..', async (): Promise => { 85 | const { commandResult } = await autoComplete( 86 | exampleFileSystem, 87 | '/', 88 | '/home/user/../d', 89 | ); 90 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 91 | const autoCompleteValues = Object.keys(commandResult!); 92 | 93 | expect(autoCompleteValues).toContain('dog.png'); 94 | expect(autoCompleteValues).not.toContain('user'); 95 | expect(autoCompleteValues).not.toContain('videos'); 96 | expect(autoCompleteValues).not.toContain('file1.txt'); 97 | expect(autoCompleteValues).not.toContain('file5.txt'); 98 | }); 99 | 100 | test('should use filter function on reults', async (): Promise => { 101 | const filterNonFilesFn = (item: ItemListType): boolean => 102 | item[Object.keys(item)[0]].type === 'FOLDER'; 103 | 104 | const { commandResult } = await autoComplete( 105 | exampleFileSystem, 106 | '/', 107 | '', 108 | filterNonFilesFn, 109 | ); 110 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 111 | const autoCompleteValues = Object.keys(commandResult!); 112 | 113 | expect(autoCompleteValues).toContain('docs'); 114 | expect(autoCompleteValues).toContain('home'); 115 | expect(autoCompleteValues).not.toContain('file3.txt'); 116 | expect(autoCompleteValues).not.toContain('file4.txt'); 117 | expect(autoCompleteValues).not.toContain('blog.txt'); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/commands/utilities/index.ts: -------------------------------------------------------------------------------- 1 | import { FileSystem, ItemListType } from '../../index'; 2 | import get from 'lodash/get'; 3 | import has from 'lodash/has'; 4 | 5 | /** 6 | * Takes a valid Unix path and converts it into a format 7 | * that matches the internal data structure. Including replacing "/" 8 | * with ".children" and removing leading slash 9 | * 10 | * usr/home/test becomes usr.children.home.children.test 11 | * 12 | * @param {string} pathStr - path string to convert 13 | * @returns {string} - converted string 14 | */ 15 | export function convertPathToInternalFormat(pathStr: string): string { 16 | return pathStr 17 | .replace(/^\/+/g, '') 18 | .split('/') 19 | .map((elem, index, arr): string => { 20 | if (elem !== '..' && index !== arr.length - 1) { 21 | elem += '.children'; 22 | } 23 | return elem; 24 | }) 25 | .join('.'); 26 | } 27 | 28 | /** 29 | * Takes a unix path that may contain ".." and return a new 30 | * string that respects the ".." path segments i.e. 31 | * /home/user/.. => /home 32 | * 33 | * @param pathStr {string} - unix-formatted path 34 | * @returns {string} - new path after taking ".." into account 35 | */ 36 | export function handleDotDotInPath(pathStr: string): string { 37 | let currentDotDots = 0; 38 | const pathArr = pathStr.split('/').filter((path): boolean => path.length > 0); 39 | for (let i = pathArr.length - 1; i >= 0; i--) { 40 | if (pathArr[i] === '..') { 41 | currentDotDots++; 42 | } else { 43 | if (currentDotDots > 0) { 44 | pathArr.splice(i, 1); 45 | currentDotDots--; 46 | } 47 | } 48 | } 49 | 50 | return pathArr.filter((path): boolean => path !== '..').join('/'); 51 | } 52 | 53 | /** 54 | * Takes an path formatted for internal use and converts it to an 55 | * external format. i.e. 56 | * home.children.user => /home/user 57 | * 58 | * @param pathStr {string} - internally formatted path 59 | * @returns {string} - path string formatted for terminal use 60 | */ 61 | export function convertInternalPathToExternal(pathStr: string): string { 62 | return ( 63 | '/' + 64 | pathStr 65 | .split('.') 66 | .filter((path): boolean => path !== 'children') 67 | .join('/') 68 | ); 69 | } 70 | 71 | /** 72 | * Takes a file path (externally formatted) and trims the file extension from it i.e. 73 | * /path/to/file.txt => /path/to/file 74 | * file.txt => file 75 | * 76 | * @param filePath {string} - filePath to trim extension from 77 | * @returns {string} - file path without file extension 78 | */ 79 | export function stripFileExtension(filePath: string): string { 80 | return filePath.replace(/\.[^/.]+$/, ''); 81 | } 82 | 83 | /** 84 | * Takes a current path and a target path, and calculates the combined path and 85 | * returns it in internal format. If target path is absolute, currentPath is ignored. 86 | * 87 | * @param currentPath {string} - current path in system 88 | * @param targetPath {string} - target path in system 89 | */ 90 | export function getInternalPath( 91 | currentPath: string, 92 | targetPath: string, 93 | ): string { 94 | if (!targetPath) { 95 | return convertPathToInternalFormat(currentPath.replace(/^\/+/g, '')); 96 | } 97 | 98 | const normalizedPath = targetPath.startsWith('/') 99 | ? targetPath 100 | : currentPath === '/' 101 | ? `/${targetPath}` // eslint-disable-line indent 102 | : `${currentPath}/${targetPath}`; // eslint-disable-line indent 103 | 104 | return convertPathToInternalFormat( 105 | handleDotDotInPath(stripFileExtension(normalizedPath)), 106 | ); 107 | } 108 | 109 | /** 110 | * Checks if a file extension is a valid image file extension 111 | * 112 | * @param extension {string} - file extension to check 113 | * @returns {boolean} - whether or not file is image extension 114 | */ 115 | export function isImageExtension(extension: string): boolean { 116 | const imageExtensions = ['png', 'jpg', 'gif']; 117 | 118 | return imageExtensions.includes(extension); 119 | } 120 | 121 | type ParsedCommand = { 122 | commandName: string; 123 | commandOptions: string[]; 124 | commandTargets: string[]; 125 | }; 126 | 127 | /** 128 | * Parses a given string into the command name, the options (specified with leading "-"), 129 | * and the command targets 130 | * 131 | * @param command - input string to parse 132 | * @returns {object} - the parsed command 133 | */ 134 | export function parseCommand(command: string): ParsedCommand { 135 | const [commandName, ...args] = command.split(' '); 136 | const commandOptions = args.filter((arg: string) => arg.startsWith('-')); 137 | const commandTargets = args.filter((arg: string) => !arg.startsWith('-')); 138 | 139 | return { 140 | commandName, 141 | commandOptions, 142 | commandTargets, 143 | }; 144 | } 145 | 146 | /** 147 | * Given a filesystem and current path, return the target item from the 148 | * filesystem for a given garget path if it exists, or throw an error if 149 | * it doesn't. 150 | * 151 | * @param fileSystem {object} - Filesystem to search 152 | * @param currentPath {string} - Curernt path in filesystem 153 | * @param targetPath {string} - Path to target 154 | * @return {object} - Internal formatted filesystem from target 155 | */ 156 | export function getTarget( 157 | fileSystem: FileSystem, 158 | currentPath: string, 159 | targetPath: string, 160 | ): FileSystem { 161 | const internalPath = getInternalPath(currentPath, targetPath); 162 | 163 | if (internalPath === '/' || !internalPath) { 164 | return fileSystem; 165 | } else if (has(fileSystem, internalPath)) { 166 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 167 | const target = get(fileSystem, internalPath); 168 | if (target.type === 'FILE') { 169 | const [fileName] = internalPath.split('.').slice(-1); 170 | return { [fileName]: target }; 171 | } else { 172 | if (target.children) { 173 | return target.children; 174 | } else { 175 | throw new Error('Nothing to show here'); 176 | } 177 | } 178 | } 179 | 180 | throw new Error('Target folder does not exist'); 181 | } 182 | 183 | /** 184 | * Takes an internally formatted filesystem and formats it into an 185 | * an item list. 186 | * 187 | * @param fileSystem {object} - internally formatted filesystem 188 | * @returns {object} - fomratted list of files/folders 189 | */ 190 | export function buildItemList(fileSystem: FileSystem): ItemListType { 191 | return Object.assign( 192 | {}, 193 | ...Object.keys(fileSystem).map((item) => ({ 194 | [fileSystem[item].type === 'FILE' 195 | ? `${item}.${fileSystem[item].extension}` 196 | : item]: { 197 | type: fileSystem[item].type, 198 | }, 199 | })), 200 | ); 201 | } 202 | -------------------------------------------------------------------------------- /src/commands/__tests__/cat.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cat from '../cat'; 3 | const { handler, autoCompleteHandler } = cat; 4 | import exampleFileSystem, { BlogPost } from '../../data/exampleFileSystem'; 5 | import { render } from '@testing-library/react'; 6 | jest.mock('../../images/dog.png', () => 'abc/dog.png'); 7 | 8 | afterAll(() => { 9 | jest.clearAllMocks(); 10 | }); 11 | 12 | describe('cat suite', (): void => { 13 | it('should print contents of file with no path', async (): Promise => { 14 | return expect( 15 | handler(exampleFileSystem, '/home', 'file1.txt'), 16 | ).resolves.toStrictEqual({ 17 | commandResult: 'Contents of file 1', 18 | }); 19 | }); 20 | 21 | it('should print contents of file with path from root', async (): Promise< 22 | void 23 | > => { 24 | return expect( 25 | handler(exampleFileSystem, '/', 'home/videos/file2.txt'), 26 | ).resolves.toStrictEqual({ 27 | commandResult: 'Contents of file 2', 28 | }); 29 | }); 30 | 31 | it('should handle image extension', async (): Promise => { 32 | const { commandResult } = await handler( 33 | exampleFileSystem, 34 | '/', 35 | 'home/dog.png', 36 | ); 37 | 38 | const { container } = render(commandResult as JSX.Element); 39 | 40 | expect(container.getElementsByTagName('img').length).toBe(1); 41 | expect(container.getElementsByTagName('img')[0].src).toEqual( 42 | 'http://localhost/abc/dog.png', 43 | ); 44 | }); 45 | 46 | it('should print contents of file that contans react component', () => { 47 | return expect( 48 | handler(exampleFileSystem, '/', 'blog.txt'), 49 | ).resolves.toStrictEqual({ 50 | commandResult: , 51 | }); 52 | }); 53 | 54 | it('should print contents of file with path from nested path', async (): Promise< 55 | void 56 | > => { 57 | return expect( 58 | handler(exampleFileSystem, '/home', 'videos/file2.txt'), 59 | ).resolves.toStrictEqual({ 60 | commandResult: 'Contents of file 2', 61 | }); 62 | }); 63 | 64 | it('should reject if target is not a file', async (): Promise => { 65 | return expect( 66 | handler(exampleFileSystem, 'home', 'videos'), 67 | ).rejects.toMatchSnapshot(); 68 | }); 69 | 70 | it('should reject if target is not a valid path', async (): Promise => { 71 | return expect( 72 | handler(exampleFileSystem, '/', 'invalid'), 73 | ).rejects.toMatchSnapshot(); 74 | }); 75 | 76 | describe('auto complete', (): void => { 77 | test('empty value', async (): Promise => { 78 | const { commandResult } = await autoCompleteHandler( 79 | exampleFileSystem, 80 | '/home/user', 81 | '', 82 | ); 83 | 84 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 85 | expect(Object.keys(commandResult!)).toContain('test'); 86 | }); 87 | 88 | test('should filter single level target', async (): Promise => { 89 | const { commandResult } = await autoCompleteHandler( 90 | exampleFileSystem, 91 | '/', 92 | 'fi', 93 | ); 94 | 95 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 96 | const autoCompleteValues = Object.keys(commandResult!); 97 | 98 | expect(autoCompleteValues).toContain('file3.txt'); 99 | expect(autoCompleteValues).toContain('file4.txt'); 100 | expect(autoCompleteValues).not.toContain('blog'); 101 | expect(autoCompleteValues).not.toContain('docs'); 102 | expect(autoCompleteValues).not.toContain('home'); 103 | }); 104 | 105 | test('invalid path should return nothing', async (): Promise => { 106 | const lsResult = await autoCompleteHandler( 107 | exampleFileSystem, 108 | '/bad/path', 109 | '', 110 | ); 111 | 112 | expect(lsResult.commandResult).toBeUndefined(); 113 | }); 114 | 115 | test('relative path', async (): Promise => { 116 | const { commandResult } = await autoCompleteHandler( 117 | exampleFileSystem, 118 | '/', 119 | 'home/fi', 120 | ); 121 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 122 | const autoCompleteValues = Object.keys(commandResult!); 123 | 124 | expect(autoCompleteValues).toContain('file1.txt'); 125 | expect(autoCompleteValues).toContain('file5.txt'); 126 | expect(autoCompleteValues).not.toContain('user'); 127 | expect(autoCompleteValues).not.toContain('videos'); 128 | expect(autoCompleteValues).not.toContain('dog.png'); 129 | }); 130 | 131 | test('relative path with dotdot', async (): Promise => { 132 | const { commandResult } = await autoCompleteHandler( 133 | exampleFileSystem, 134 | '/', 135 | 'home/../home/fi', 136 | ); 137 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 138 | const autoCompleteValues = Object.keys(commandResult!); 139 | 140 | expect(autoCompleteValues).toContain('file1.txt'); 141 | expect(autoCompleteValues).toContain('file5.txt'); 142 | expect(autoCompleteValues).not.toContain('user'); 143 | expect(autoCompleteValues).not.toContain('videos'); 144 | expect(autoCompleteValues).not.toContain('dog.png'); 145 | }); 146 | 147 | test('absolute path', async (): Promise => { 148 | const { commandResult } = await autoCompleteHandler( 149 | exampleFileSystem, 150 | '/', 151 | '/home/d', 152 | ); 153 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 154 | const autoCompleteValues = Object.keys(commandResult!); 155 | 156 | expect(autoCompleteValues).toContain('dog.png'); 157 | expect(autoCompleteValues).not.toContain('user'); 158 | expect(autoCompleteValues).not.toContain('videos'); 159 | expect(autoCompleteValues).not.toContain('file1.txt'); 160 | expect(autoCompleteValues).not.toContain('file5.txt'); 161 | }); 162 | 163 | test('absolute path with ..', async (): Promise => { 164 | const { commandResult } = await autoCompleteHandler( 165 | exampleFileSystem, 166 | '/', 167 | '/home/user/../d', 168 | ); 169 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 170 | const autoCompleteValues = Object.keys(commandResult!); 171 | 172 | expect(autoCompleteValues).toContain('dog.png'); 173 | expect(autoCompleteValues).not.toContain('user'); 174 | expect(autoCompleteValues).not.toContain('videos'); 175 | expect(autoCompleteValues).not.toContain('file1.txt'); 176 | expect(autoCompleteValues).not.toContain('file5.txt'); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/commands/__tests__/mkdir.test.ts: -------------------------------------------------------------------------------- 1 | import mkdir from '../mkdir'; 2 | const { handler, autoCompleteHandler } = mkdir; 3 | import cloneDeep from 'lodash/cloneDeep'; 4 | import set from 'lodash/set'; 5 | import exampleFileSystem from '../../data/exampleFileSystem'; 6 | 7 | describe('mkdir suite', (): void => { 8 | describe('success', (): void => { 9 | it('should create directory from root', async (): Promise => { 10 | const expectedFileSystem = cloneDeep(exampleFileSystem); 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 13 | set(expectedFileSystem, 'newDir', { 14 | type: 'FOLDER', 15 | children: null, 16 | }); 17 | 18 | return expect( 19 | handler(exampleFileSystem, '/', 'newDir'), 20 | ).resolves.toStrictEqual({ 21 | commandResult: 'Folder created: newDir', 22 | updatedState: { 23 | fileSystem: expectedFileSystem, 24 | }, 25 | }); 26 | }); 27 | 28 | it('should create directory from nested path', async (): Promise => { 29 | const expectedFileSystem = cloneDeep(exampleFileSystem); 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 32 | set(expectedFileSystem, 'home.children.newDir', { 33 | type: 'FOLDER', 34 | children: null, 35 | }); 36 | 37 | return expect( 38 | handler(exampleFileSystem, '/home', 'newDir'), 39 | ).resolves.toStrictEqual({ 40 | commandResult: 'Folder created: newDir', 41 | updatedState: { 42 | fileSystem: expectedFileSystem, 43 | }, 44 | }); 45 | }); 46 | 47 | it('should create directory given a folder path', async (): Promise< 48 | void 49 | > => { 50 | const expectedFileSystem = cloneDeep(exampleFileSystem); 51 | 52 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 53 | set(expectedFileSystem, 'home.children.newDir', { 54 | type: 'FOLDER', 55 | children: null, 56 | }); 57 | 58 | return expect( 59 | handler(exampleFileSystem, '/', 'home/newDir'), 60 | ).resolves.toStrictEqual({ 61 | commandResult: 'Folder created: home/newDir', 62 | updatedState: { 63 | fileSystem: expectedFileSystem, 64 | }, 65 | }); 66 | }); 67 | }); 68 | 69 | describe('failure', (): void => { 70 | it('should reject if path already exists', async (): Promise => { 71 | return expect( 72 | handler(exampleFileSystem, '/home', 'user'), 73 | ).rejects.toMatchSnapshot(); 74 | }); 75 | }); 76 | 77 | describe('auto complete', (): void => { 78 | test('empty value', async (): Promise => { 79 | const { commandResult } = await autoCompleteHandler( 80 | exampleFileSystem, 81 | '/home', 82 | '', 83 | ); 84 | 85 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 86 | const items = Object.keys(commandResult!); 87 | 88 | expect(items).toContain('user'); 89 | expect(items).toContain('videos'); 90 | expect(items).not.toContain('file1.txt'); 91 | expect(items).not.toContain('file5.txt'); 92 | expect(items).not.toContain('dog.png'); 93 | }); 94 | 95 | test('should filter single level target', async (): Promise => { 96 | const { commandResult } = await autoCompleteHandler( 97 | exampleFileSystem, 98 | '/', 99 | 'ho', 100 | ); 101 | 102 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 103 | const autoCompleteValues = Object.keys(commandResult!); 104 | 105 | expect(autoCompleteValues).toContain('home'); 106 | expect(autoCompleteValues).not.toContain('file3.txt'); 107 | expect(autoCompleteValues).not.toContain('file4.txt'); 108 | expect(autoCompleteValues).not.toContain('blog'); 109 | expect(autoCompleteValues).not.toContain('docs'); 110 | }); 111 | 112 | test('invalid path should return nothing', async (): Promise => { 113 | const lsResult = await autoCompleteHandler( 114 | exampleFileSystem, 115 | '/bad/path', 116 | '', 117 | ); 118 | 119 | expect(lsResult.commandResult).toBeUndefined(); 120 | }); 121 | 122 | test('relative path', async (): Promise => { 123 | const { commandResult } = await autoCompleteHandler( 124 | exampleFileSystem, 125 | '/', 126 | 'home/us', 127 | ); 128 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 129 | const autoCompleteValues = Object.keys(commandResult!); 130 | 131 | expect(autoCompleteValues).toContain('user'); 132 | expect(autoCompleteValues).not.toContain('dog.png'); 133 | expect(autoCompleteValues).not.toContain('file1.txt'); 134 | expect(autoCompleteValues).not.toContain('file5.txt'); 135 | expect(autoCompleteValues).not.toContain('videos'); 136 | }); 137 | 138 | test('relative path with dotdot', async (): Promise => { 139 | const { commandResult } = await autoCompleteHandler( 140 | exampleFileSystem, 141 | '/', 142 | 'home/../home/us', 143 | ); 144 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 145 | const autoCompleteValues = Object.keys(commandResult!); 146 | 147 | expect(autoCompleteValues).toContain('user'); 148 | expect(autoCompleteValues).not.toContain('dog.png'); 149 | expect(autoCompleteValues).not.toContain('file1.txt'); 150 | expect(autoCompleteValues).not.toContain('file5.txt'); 151 | expect(autoCompleteValues).not.toContain('videos'); 152 | }); 153 | 154 | test('absolute path', async (): Promise => { 155 | const { commandResult } = await autoCompleteHandler( 156 | exampleFileSystem, 157 | '/', 158 | '/home/us', 159 | ); 160 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 161 | const autoCompleteValues = Object.keys(commandResult!); 162 | 163 | expect(autoCompleteValues).toContain('user'); 164 | expect(autoCompleteValues).not.toContain('dog.png'); 165 | expect(autoCompleteValues).not.toContain('file1.txt'); 166 | expect(autoCompleteValues).not.toContain('file5.txt'); 167 | expect(autoCompleteValues).not.toContain('videos'); 168 | }); 169 | 170 | test('absolute path with ..', async (): Promise => { 171 | const { commandResult } = await autoCompleteHandler( 172 | exampleFileSystem, 173 | '/', 174 | '/home/user/../us', 175 | ); 176 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 177 | const autoCompleteValues = Object.keys(commandResult!); 178 | 179 | expect(autoCompleteValues).toContain('user'); 180 | expect(autoCompleteValues).not.toContain('dog.png'); 181 | expect(autoCompleteValues).not.toContain('file1.txt'); 182 | expect(autoCompleteValues).not.toContain('file5.txt'); 183 | expect(autoCompleteValues).not.toContain('videos'); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /src/commands/__tests__/ls.test.ts: -------------------------------------------------------------------------------- 1 | import ls from '../ls'; 2 | const { handler, autoCompleteHandler } = ls; 3 | import exampleFileSystem from '../../data/exampleFileSystem'; 4 | import { render } from '@testing-library/react'; 5 | 6 | describe('ls suite', (): void => { 7 | test('root with no directory', async (): Promise => { 8 | const lsResult = await handler(exampleFileSystem, '/'); 9 | const { container } = render(lsResult.commandResult as JSX.Element); 10 | 11 | expect(container.innerHTML).toContain('docs'); 12 | expect(container.innerHTML).toContain('home'); 13 | }); 14 | 15 | test('nested path with no directory', async (): Promise => { 16 | const lsResult = await handler(exampleFileSystem, '/home'); 17 | const { container } = render(lsResult.commandResult as JSX.Element); 18 | 19 | expect(container.innerHTML).toContain('user'); 20 | expect(container.innerHTML).toContain('videos'); 21 | expect(container.innerHTML).toContain('file1.txt'); 22 | }); 23 | 24 | test('relative path from root', async (): Promise => { 25 | const lsResult = await handler(exampleFileSystem, '/', '/home'); 26 | const { container } = render(lsResult.commandResult as JSX.Element); 27 | 28 | expect(container.innerHTML).toContain('user'); 29 | expect(container.innerHTML).toContain('videos'); 30 | expect(container.innerHTML).toContain('file1.txt'); 31 | }); 32 | 33 | test('relative path from nested path', async (): Promise => { 34 | const lsResult = await handler(exampleFileSystem, '/home', 'user'); 35 | const { container } = render(lsResult.commandResult as JSX.Element); 36 | 37 | expect(container.innerHTML).toContain('test'); 38 | }); 39 | 40 | test('root with dotdot', async (): Promise => { 41 | const lsResult = await handler(exampleFileSystem, '/', '..'); 42 | const { container } = render(lsResult.commandResult as JSX.Element); 43 | 44 | expect(container.innerHTML).toContain('home'); 45 | expect(container.innerHTML).toContain('docs'); 46 | }); 47 | 48 | test('nestd path with dotdot', async (): Promise => { 49 | const lsResult = await handler( 50 | exampleFileSystem, 51 | '/home', 52 | '../home/user/..', 53 | ); 54 | const { container } = render(lsResult.commandResult as JSX.Element); 55 | 56 | expect(container.innerHTML).toContain('user'); 57 | expect(container.innerHTML).toContain('videos'); 58 | expect(container.innerHTML).toContain('file1.txt'); 59 | }); 60 | 61 | test('should reject if invalid target given', async (): Promise => { 62 | return expect( 63 | handler(exampleFileSystem, '/invalid'), 64 | ).rejects.toMatchSnapshot(); 65 | }); 66 | 67 | test('empty path from nested location', async (): Promise => { 68 | const lsResult = await handler(exampleFileSystem, '/home/user', ''); 69 | const { container } = render(lsResult.commandResult as JSX.Element); 70 | 71 | expect(container.innerHTML).toContain('test'); 72 | }); 73 | 74 | test('should return file if given', async (): Promise => { 75 | const lsResult = await handler(exampleFileSystem, '/', 'file4.txt'); 76 | const { container } = render(lsResult.commandResult as JSX.Element); 77 | 78 | expect(container.innerHTML).toContain('file4.txt'); 79 | }); 80 | 81 | test('should return message if directory and empty', async (): Promise< 82 | void 83 | > => { 84 | return expect( 85 | handler(exampleFileSystem, '/home', 'user/test'), 86 | ).rejects.toMatchSnapshot(); 87 | }); 88 | 89 | describe('auto complete', (): void => { 90 | test('empty value', async (): Promise => { 91 | const { commandResult } = await autoCompleteHandler( 92 | exampleFileSystem, 93 | '/home/user', 94 | '', 95 | ); 96 | 97 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 98 | expect(Object.keys(commandResult!)).toContain('test'); 99 | }); 100 | 101 | test('should filter single level target', async (): Promise => { 102 | const { commandResult } = await autoCompleteHandler( 103 | exampleFileSystem, 104 | '/', 105 | 'fi', 106 | ); 107 | 108 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 109 | const autoCompleteValues = Object.keys(commandResult!); 110 | 111 | expect(autoCompleteValues).toContain('file3.txt'); 112 | expect(autoCompleteValues).toContain('file4.txt'); 113 | expect(autoCompleteValues).not.toContain('blog'); 114 | expect(autoCompleteValues).not.toContain('docs'); 115 | expect(autoCompleteValues).not.toContain('home'); 116 | }); 117 | 118 | test('invalid path should return nothing', async (): Promise => { 119 | const lsResult = await autoCompleteHandler( 120 | exampleFileSystem, 121 | '/bad/path', 122 | '', 123 | ); 124 | 125 | expect(lsResult.commandResult).toBeUndefined(); 126 | }); 127 | 128 | test('relative path', async (): Promise => { 129 | const { commandResult } = await autoCompleteHandler( 130 | exampleFileSystem, 131 | '/', 132 | 'home/fi', 133 | ); 134 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 135 | const autoCompleteValues = Object.keys(commandResult!); 136 | 137 | expect(autoCompleteValues).toContain('file1.txt'); 138 | expect(autoCompleteValues).toContain('file5.txt'); 139 | expect(autoCompleteValues).not.toContain('user'); 140 | expect(autoCompleteValues).not.toContain('videos'); 141 | expect(autoCompleteValues).not.toContain('dog.png'); 142 | }); 143 | 144 | test('relative path with dotdot', async (): Promise => { 145 | const { commandResult } = await autoCompleteHandler( 146 | exampleFileSystem, 147 | '/', 148 | 'home/../home/fi', 149 | ); 150 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 151 | const autoCompleteValues = Object.keys(commandResult!); 152 | 153 | expect(autoCompleteValues).toContain('file1.txt'); 154 | expect(autoCompleteValues).toContain('file5.txt'); 155 | expect(autoCompleteValues).not.toContain('user'); 156 | expect(autoCompleteValues).not.toContain('videos'); 157 | expect(autoCompleteValues).not.toContain('dog.png'); 158 | }); 159 | 160 | test('absolute path', async (): Promise => { 161 | const { commandResult } = await autoCompleteHandler( 162 | exampleFileSystem, 163 | '/', 164 | '/home/d', 165 | ); 166 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 167 | const autoCompleteValues = Object.keys(commandResult!); 168 | 169 | expect(autoCompleteValues).toContain('dog.png'); 170 | expect(autoCompleteValues).not.toContain('user'); 171 | expect(autoCompleteValues).not.toContain('videos'); 172 | expect(autoCompleteValues).not.toContain('file1.txt'); 173 | expect(autoCompleteValues).not.toContain('file5.txt'); 174 | }); 175 | 176 | test('absolute path with ..', async (): Promise => { 177 | const { commandResult } = await autoCompleteHandler( 178 | exampleFileSystem, 179 | '/', 180 | '/home/user/../d', 181 | ); 182 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 183 | const autoCompleteValues = Object.keys(commandResult!); 184 | 185 | expect(autoCompleteValues).toContain('dog.png'); 186 | expect(autoCompleteValues).not.toContain('user'); 187 | expect(autoCompleteValues).not.toContain('videos'); 188 | expect(autoCompleteValues).not.toContain('file1.txt'); 189 | expect(autoCompleteValues).not.toContain('file5.txt'); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /src/commands/__tests__/cd.test.ts: -------------------------------------------------------------------------------- 1 | import cd from '../cd'; 2 | const { handler, autoCompleteHandler } = cd; 3 | import exampleFileSystem from '../../data/exampleFileSystem'; 4 | import { FileSystem } from '../../index'; 5 | 6 | describe('cd suite', (): void => { 7 | describe('valid cases', (): void => { 8 | describe('from root', (): void => { 9 | test('1 level path', async (): Promise => { 10 | const fileSystem: FileSystem = { 11 | home: { 12 | children: null, 13 | type: 'FOLDER', 14 | }, 15 | }; 16 | 17 | return expect(handler(fileSystem, '/', 'home')).resolves.toEqual({ 18 | updatedState: { 19 | currentPath: '/home', 20 | }, 21 | }); 22 | }); 23 | 24 | test('multi-level cd', async (): Promise => { 25 | return expect( 26 | handler(exampleFileSystem, '/', 'home/user/test'), 27 | ).resolves.toEqual({ 28 | updatedState: { 29 | currentPath: '/home/user/test', 30 | }, 31 | }); 32 | }); 33 | 34 | test('.. above root level', async (): Promise => { 35 | return expect(handler(exampleFileSystem, '/', '..')).resolves.toEqual({ 36 | updatedState: { 37 | currentPath: '/', 38 | }, 39 | }); 40 | }); 41 | }); 42 | 43 | describe('from nested path', (): void => { 44 | test('1 level cd', async (): Promise => { 45 | return expect( 46 | handler(exampleFileSystem, '/home', 'user'), 47 | ).resolves.toEqual({ 48 | updatedState: { 49 | currentPath: '/home/user', 50 | }, 51 | }); 52 | }); 53 | 54 | test('.. 1 level to root', async (): Promise => { 55 | return expect( 56 | handler(exampleFileSystem, '/home', '..'), 57 | ).resolves.toEqual({ 58 | updatedState: { 59 | currentPath: '/', 60 | }, 61 | }); 62 | }); 63 | 64 | test('.. 1 level', async (): Promise => { 65 | return expect( 66 | handler(exampleFileSystem, '/home/user/test', '..'), 67 | ).resolves.toEqual({ 68 | updatedState: { 69 | currentPath: '/home/user', 70 | }, 71 | }); 72 | }); 73 | 74 | test('.. multiple levels', async (): Promise => { 75 | return expect( 76 | handler(exampleFileSystem, '/home/folder1/folder2', '../..'), 77 | ).resolves.toEqual({ 78 | updatedState: { 79 | currentPath: '/home', 80 | }, 81 | }); 82 | }); 83 | 84 | test('.. multiple levels in separate paths', (): Promise => { 85 | return expect( 86 | handler( 87 | exampleFileSystem, 88 | '/home/folder1/folder2', 89 | '../folder2/../../', 90 | ), 91 | ).resolves.toEqual({ 92 | updatedState: { 93 | currentPath: '/home', 94 | }, 95 | }); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('invalid cases', (): void => { 101 | test('empty path', async (): Promise => { 102 | const fileSystem: FileSystem = { 103 | home: { 104 | children: null, 105 | type: 'FOLDER', 106 | }, 107 | }; 108 | return expect(handler(fileSystem, 'path', '')).rejects.toMatchSnapshot(); 109 | }); 110 | 111 | test('nested cd to a file', async (): Promise => { 112 | return expect( 113 | handler(exampleFileSystem, 'path', 'home/folder1/folder2/file1'), 114 | ).rejects.toMatchSnapshot(); 115 | }); 116 | }); 117 | 118 | describe('auto complete', (): void => { 119 | test('empty value', async (): Promise => { 120 | const { commandResult } = await autoCompleteHandler( 121 | exampleFileSystem, 122 | '/home', 123 | '', 124 | ); 125 | 126 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 127 | const items = Object.keys(commandResult!); 128 | 129 | expect(items).toContain('user'); 130 | expect(items).toContain('videos'); 131 | expect(items).not.toContain('file1.txt'); 132 | expect(items).not.toContain('file5.txt'); 133 | expect(items).not.toContain('dog.png'); 134 | }); 135 | 136 | test('should filter single level target', async (): Promise => { 137 | const { commandResult } = await autoCompleteHandler( 138 | exampleFileSystem, 139 | '/', 140 | 'ho', 141 | ); 142 | 143 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 144 | const autoCompleteValues = Object.keys(commandResult!); 145 | 146 | expect(autoCompleteValues).toContain('home'); 147 | expect(autoCompleteValues).not.toContain('file3.txt'); 148 | expect(autoCompleteValues).not.toContain('file4.txt'); 149 | expect(autoCompleteValues).not.toContain('blog'); 150 | expect(autoCompleteValues).not.toContain('docs'); 151 | }); 152 | 153 | test('invalid path should return nothing', async (): Promise => { 154 | const lsResult = await autoCompleteHandler( 155 | exampleFileSystem, 156 | '/bad/path', 157 | '', 158 | ); 159 | 160 | expect(lsResult.commandResult).toBeUndefined(); 161 | }); 162 | 163 | test('relative path', async (): Promise => { 164 | const { commandResult } = await autoCompleteHandler( 165 | exampleFileSystem, 166 | '/', 167 | 'home/us', 168 | ); 169 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 170 | const autoCompleteValues = Object.keys(commandResult!); 171 | 172 | expect(autoCompleteValues).toContain('user'); 173 | expect(autoCompleteValues).not.toContain('dog.png'); 174 | expect(autoCompleteValues).not.toContain('file1.txt'); 175 | expect(autoCompleteValues).not.toContain('file5.txt'); 176 | expect(autoCompleteValues).not.toContain('videos'); 177 | }); 178 | 179 | test('relative path with dotdot', async (): Promise => { 180 | const { commandResult } = await autoCompleteHandler( 181 | exampleFileSystem, 182 | '/', 183 | 'home/../home/us', 184 | ); 185 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 186 | const autoCompleteValues = Object.keys(commandResult!); 187 | 188 | expect(autoCompleteValues).toContain('user'); 189 | expect(autoCompleteValues).not.toContain('dog.png'); 190 | expect(autoCompleteValues).not.toContain('file1.txt'); 191 | expect(autoCompleteValues).not.toContain('file5.txt'); 192 | expect(autoCompleteValues).not.toContain('videos'); 193 | }); 194 | 195 | test('absolute path', async (): Promise => { 196 | const { commandResult } = await autoCompleteHandler( 197 | exampleFileSystem, 198 | '/', 199 | '/home/us', 200 | ); 201 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 202 | const autoCompleteValues = Object.keys(commandResult!); 203 | 204 | expect(autoCompleteValues).toContain('user'); 205 | expect(autoCompleteValues).not.toContain('dog.png'); 206 | expect(autoCompleteValues).not.toContain('file1.txt'); 207 | expect(autoCompleteValues).not.toContain('file5.txt'); 208 | expect(autoCompleteValues).not.toContain('videos'); 209 | }); 210 | 211 | test('absolute path with ..', async (): Promise => { 212 | const { commandResult } = await autoCompleteHandler( 213 | exampleFileSystem, 214 | '/', 215 | '/home/user/../us', 216 | ); 217 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 218 | const autoCompleteValues = Object.keys(commandResult!); 219 | 220 | expect(autoCompleteValues).toContain('user'); 221 | expect(autoCompleteValues).not.toContain('dog.png'); 222 | expect(autoCompleteValues).not.toContain('file1.txt'); 223 | expect(autoCompleteValues).not.toContain('file5.txt'); 224 | expect(autoCompleteValues).not.toContain('videos'); 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /src/commands/__tests__/rm.test.ts: -------------------------------------------------------------------------------- 1 | import rm from '../rm'; 2 | const { handler, autoCompleteHandler } = rm; 3 | import exampleFileSystem from '../../data/exampleFileSystem'; 4 | import { FileSystem } from '../../index'; 5 | import cloneDeep from 'lodash/cloneDeep'; 6 | 7 | describe('rm suite', (): void => { 8 | describe('success', (): void => { 9 | describe('non-folders', (): void => { 10 | it('should remove a file with no options', async (): Promise => { 11 | const expectedFileSystem = cloneDeep(exampleFileSystem); 12 | delete expectedFileSystem.file3; 13 | 14 | return expect( 15 | handler(exampleFileSystem, '/', 'file3.txt'), 16 | ).resolves.toEqual({ 17 | updatedState: { 18 | fileSystem: expectedFileSystem, 19 | }, 20 | }); 21 | }); 22 | 23 | it('should remove a nested file', async (): Promise => { 24 | const expectedFileSystem = cloneDeep(exampleFileSystem); 25 | delete ((expectedFileSystem.home.children as FileSystem).videos 26 | .children as FileSystem).file2; 27 | 28 | return expect( 29 | handler(exampleFileSystem, '/', 'home/videos/file2.txt'), 30 | ).resolves.toEqual({ 31 | updatedState: { 32 | fileSystem: expectedFileSystem, 33 | }, 34 | }); 35 | }); 36 | 37 | it('should remove file in parent path', async (): Promise => { 38 | const expectedFileSystem = cloneDeep(exampleFileSystem); 39 | delete expectedFileSystem.file3; 40 | 41 | return expect( 42 | handler(exampleFileSystem, '/home/videos', '../../file3.txt'), 43 | ).resolves.toEqual({ 44 | updatedState: { 45 | fileSystem: expectedFileSystem, 46 | }, 47 | }); 48 | }); 49 | }); 50 | 51 | describe('folders', (): void => { 52 | it('should delete at same level', async (): Promise => { 53 | const expectedFileSystem = cloneDeep(exampleFileSystem); 54 | delete expectedFileSystem.docs; 55 | 56 | return expect( 57 | handler(exampleFileSystem, '/', 'docs', '-r'), 58 | ).resolves.toEqual({ 59 | updatedState: { 60 | fileSystem: expectedFileSystem, 61 | }, 62 | }); 63 | }); 64 | 65 | it('should delete from nested path', async (): Promise => { 66 | const expectedFileSystem = cloneDeep(exampleFileSystem); 67 | delete (expectedFileSystem.home.children as FileSystem).videos; 68 | 69 | return expect( 70 | handler(exampleFileSystem, '/', 'home/videos', '-r'), 71 | ).resolves.toEqual({ 72 | updatedState: { 73 | fileSystem: expectedFileSystem, 74 | }, 75 | }); 76 | }); 77 | 78 | it('should delete from parent path', async (): Promise => { 79 | const expectedFileSystem = cloneDeep(exampleFileSystem); 80 | delete expectedFileSystem.docs; 81 | 82 | return expect( 83 | handler(exampleFileSystem, '/home/videos', '../../docs', '-r'), 84 | ).resolves.toEqual({ 85 | updatedState: { 86 | fileSystem: expectedFileSystem, 87 | }, 88 | }); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('failure', (): void => { 94 | it('should reject if path is invalid', async (): Promise => { 95 | return expect( 96 | handler(exampleFileSystem, '/', 'invalid'), 97 | ).rejects.toMatchSnapshot(); 98 | }); 99 | 100 | it('should reject if no target path provided', async (): Promise => { 101 | return expect( 102 | handler(exampleFileSystem, '/', ''), 103 | ).rejects.toMatchSnapshot(); 104 | }); 105 | 106 | it('should reject if no options and target is a folder', async (): Promise< 107 | void 108 | > => { 109 | return expect( 110 | handler(exampleFileSystem, '/', 'home'), 111 | ).rejects.toMatchSnapshot(); 112 | }); 113 | }); 114 | 115 | describe('auto complete', (): void => { 116 | test('empty value', async (): Promise => { 117 | const { commandResult } = await autoCompleteHandler( 118 | exampleFileSystem, 119 | '/home/user', 120 | '', 121 | ); 122 | 123 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 124 | expect(Object.keys(commandResult!)).toContain('test'); 125 | }); 126 | 127 | test('should filter single level target', async (): Promise => { 128 | const { commandResult } = await autoCompleteHandler( 129 | exampleFileSystem, 130 | '/', 131 | 'fi', 132 | ); 133 | 134 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 135 | const autoCompleteValues = Object.keys(commandResult!); 136 | 137 | expect(autoCompleteValues).toContain('file3.txt'); 138 | expect(autoCompleteValues).toContain('file4.txt'); 139 | expect(autoCompleteValues).not.toContain('blog'); 140 | expect(autoCompleteValues).not.toContain('docs'); 141 | expect(autoCompleteValues).not.toContain('home'); 142 | }); 143 | 144 | test('invalid path should return nothing', async (): Promise => { 145 | const lsResult = await autoCompleteHandler( 146 | exampleFileSystem, 147 | '/bad/path', 148 | '', 149 | ); 150 | 151 | expect(lsResult.commandResult).toBeUndefined(); 152 | }); 153 | 154 | test('relative path', async (): Promise => { 155 | const { commandResult } = await autoCompleteHandler( 156 | exampleFileSystem, 157 | '/', 158 | 'home/fi', 159 | ); 160 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 161 | const autoCompleteValues = Object.keys(commandResult!); 162 | 163 | expect(autoCompleteValues).toContain('file1.txt'); 164 | expect(autoCompleteValues).toContain('file5.txt'); 165 | expect(autoCompleteValues).not.toContain('user'); 166 | expect(autoCompleteValues).not.toContain('videos'); 167 | expect(autoCompleteValues).not.toContain('dog.png'); 168 | }); 169 | 170 | test('relative path with dotdot', async (): Promise => { 171 | const { commandResult } = await autoCompleteHandler( 172 | exampleFileSystem, 173 | '/', 174 | 'home/../home/fi', 175 | ); 176 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 177 | const autoCompleteValues = Object.keys(commandResult!); 178 | 179 | expect(autoCompleteValues).toContain('file1.txt'); 180 | expect(autoCompleteValues).toContain('file5.txt'); 181 | expect(autoCompleteValues).not.toContain('user'); 182 | expect(autoCompleteValues).not.toContain('videos'); 183 | expect(autoCompleteValues).not.toContain('dog.png'); 184 | }); 185 | 186 | test('absolute path', async (): Promise => { 187 | const { commandResult } = await autoCompleteHandler( 188 | exampleFileSystem, 189 | '/', 190 | '/home/d', 191 | ); 192 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 193 | const autoCompleteValues = Object.keys(commandResult!); 194 | 195 | expect(autoCompleteValues).toContain('dog.png'); 196 | expect(autoCompleteValues).not.toContain('user'); 197 | expect(autoCompleteValues).not.toContain('videos'); 198 | expect(autoCompleteValues).not.toContain('file1.txt'); 199 | expect(autoCompleteValues).not.toContain('file5.txt'); 200 | }); 201 | 202 | test('absolute path with ..', async (): Promise => { 203 | const { commandResult } = await autoCompleteHandler( 204 | exampleFileSystem, 205 | '/', 206 | '/home/user/../d', 207 | ); 208 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 209 | const autoCompleteValues = Object.keys(commandResult!); 210 | 211 | expect(autoCompleteValues).toContain('dog.png'); 212 | expect(autoCompleteValues).not.toContain('user'); 213 | expect(autoCompleteValues).not.toContain('videos'); 214 | expect(autoCompleteValues).not.toContain('file1.txt'); 215 | expect(autoCompleteValues).not.toContain('file5.txt'); 216 | }); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 36 | 37 |
    7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Coverage Status 19 | 20 | 21 | 22 | 23 | Build Status 24 | 25 | 26 | 27 | 28 | bundle size 29 | 30 | 31 | 32 | 33 | NPM Version 34 | 35 |
    38 | 39 | # Termy the Terminal 40 | 41 | A web-based terminal powered by React. Check out the [demo](https://ctaylo21.github.io/termy-the-terminal/). 42 | 43 | ## Table of Contents 44 | 45 | - [Termy the Terminal](#termy-the-terminal) 46 | - [Table of Contents](#table-of-contents) 47 | - [Installation](#installation) 48 | - [Usage](#usage) 49 | - [Terminal Props](#terminal-props) 50 | - [General](#general) 51 | - [Command History](#command-history) 52 | - [Auto Complete](#auto-complete) 53 | - [Custom Commands](#custom-commands) 54 | - [Commands](#commands) 55 | - [cd [DIRECTORY]](#cd-directory) 56 | - [pwd](#pwd) 57 | - [ls [DIRECTORY]](#ls-directory) 58 | - [mkdir [DIRECTORY]](#mkdir-directory) 59 | - [cat [FILE]](#cat-file) 60 | - [rm [OPTIONS] [FILE]](#rm-options-file) 61 | - [help](#help) 62 | 63 | ## Installation 64 | 65 | The package can be installed via NPM: 66 | 67 | npm i termy-the-terminal 68 | 69 | You will need to install the React and ReactDOM packages separately as they aren't included in this package. 70 | 71 | ## Usage 72 | 73 | ```jsx 74 | import React from 'react'; 75 | import ReactDOM from 'react-dom'; 76 | import { Terminal } from 'termy-the-terminal'; 77 | import 'termy-the-terminal/dist/index.css'; 78 | import exampleFileSystem from './data/exampleFileSystem'; 79 | 80 | ReactDOM.render( 81 | , 82 | document.getElementById('terminal-container'), 83 | ); 84 | ``` 85 | 86 | ### Terminal Props 87 | 88 | | Prop Name | Description | Required | 89 | | -------------- | ------------------------------------------------------------------------ | -------- | 90 | | fileSystem | A properly formatted (see below) JSON object representing the filesystem | Yes | 91 | | inputPrompt | String to use as input prompt (default is `$>`) | No | 92 | | customCommands | Custom commands to add to the terminal | No | 93 | 94 | The `fileSystem` prop needs to be a particular format ([code example](src/data/exampleFileSystem.ts)): 95 | 96 | ```javascript 97 | import dogImg from '../../src/images/dog.png'; 98 | 99 | const BlogPost ({date, content}) => ( 100 | <> 101 |

    {date}

    102 |

    {content}

    103 | 104 | ); 105 | 106 | const exampleFileSystem = { 107 | home: { 108 | type: 'FOLDER', 109 | children: { 110 | user: { 111 | type: 'FOLDER', 112 | children: null, 113 | }, 114 | file1: { 115 | type: 'FILE', 116 | content: 'Contents of file 1', 117 | extension: 'txt', 118 | }, 119 | dog: { 120 | type: 'FILE', 121 | content: dogImg, 122 | extension: 'png', 123 | }, 124 | }, 125 | }, 126 | docs: { 127 | type: 'FOLDER', 128 | children: null, 129 | }, 130 | blog: { 131 | type: 'FILE', 132 | content: , 133 | extension: 'txt' 134 | }, 135 | }; 136 | ``` 137 | 138 | **Important**: To support using `cat` to display images from your filesystem, you need to pass a valid image location and valid extension (`'jpg'`, `'png'`, or `'gif'`). To follow the example above, you will need to make sure your bundler (webpack, rollup, etc..) supports importing images. For an example of this in webpack, see the [weback docs](https://webpack.js.org/guides/asset-management/#loading-images), and for rollup, check out [@rollup/plugin-image](https://github.com/rollup/plugins/tree/master/packages/image). 139 | 140 | ## General 141 | 142 | The following sections include general features that Termy supports outside of the terminal commands. 143 | 144 | ### Command History 145 | 146 | Termy supports using the arrow keys (up and down) to move through the command history. 147 | 148 | ### Auto Complete 149 | 150 | Termy supports using the `tab` key to trigger autocomplete for commands to complete a target path. This includes using multiple `tab` presses to cycle through the possible auto-complete options for a given command. 151 | 152 | ### Custom Commands 153 | 154 | You can add custom commands to Termy by passing in your commands as the `customCommands` prop. Here is an example command "hello" that just prints "world" when executed: 155 | 156 | ```jsx 157 | const hello = { 158 | hello: { 159 | // Function that handles command execution 160 | handler: function hello(): Promise { 161 | return new Promise((resolve): void => { 162 | resolve({ 163 | commandResult: 'world', 164 | }); 165 | }); 166 | }, 167 | }, 168 | }; 169 | 170 | ReactDOM.render( 171 | , 172 | document.getElementById('terminal-container'), 173 | ); 174 | ``` 175 | 176 | You can also make a more complex command that acts upon the files and folders of the filesystem. Here is an example 177 | that will print the length of contents of a file, but only if it is a `.txt` file and the content is a simple string. 178 | It also supports autocomplete by using default `autoComplete` method and some utility functions exported from the base file. 179 | 180 | ```jsx 181 | import React from 'react'; 182 | import ReactDOM from 'react-dom'; 183 | import get from 'lodash/get'; 184 | import { 185 | autoComplete, 186 | CommandResponse, 187 | FileSystem, 188 | Terminal, 189 | utilities 190 | } from 'termy-the-terminal'; 191 | const { getInternalPath, stripFileExtension } = utilities; 192 | import exampleFileSystem from './data/exampleFileSystem'; 193 | 194 | const lengthCommand = { 195 | length: { 196 | handler: function length( 197 | fileSystem: FileSystem, 198 | currentPath: string, 199 | targetPath: string, 200 | ): Promise { 201 | return new Promise((resolve, reject): void => { 202 | if (!targetPath) { 203 | reject('Invalid target path'); 204 | } 205 | 206 | const pathWithoutExtension = stripFileExtension(targetPath); 207 | const file = get( 208 | fileSystem, 209 | getInternalPath(currentPath, pathWithoutExtension), 210 | ); 211 | 212 | if (!file) { 213 | reject('Invalid target path'); 214 | } 215 | 216 | if (file.extension !== 'txt') { 217 | reject('Target is not a .txt file'); 218 | } 219 | 220 | let fileLength = 'Unknown length'; 221 | if (typeof file.content === 'string') { 222 | fileLength = '' + file.content.length; 223 | } 224 | 225 | resolve({ 226 | commandResult: fileLength, 227 | }); 228 | }); 229 | }, 230 | autoCompleteHandler: autoComplete, // Function that returns results for autocomplete for given command 231 | description: 'Calculates the length of a given text file' // Description that will be show from "help" command 232 | }, 233 | }; 234 | 235 | ReactDOM.render( 236 | , 237 | document.getElementById('terminal-container'), 238 | ``` 239 | 240 | You can add multiple commands to your `customCommands` prop as each command name is just defined by its key in the object you pass in. 241 | 242 | ```jsx 243 | // Create two custom commands, "hello" and "length" 244 | const customCommands = { 245 | hello: { 246 | handler: // hello handler defined here 247 | // No autoCompleteHandler function defined so auto complete isn't supported for this command 248 | }, 249 | length: { 250 | handler: // length handler defined here 251 | autoCompleteHandler: // autocomplete handler defined here for length command 252 | description: 'Some description' // include a description if you want command to appear when "help" is executed 253 | } 254 | }; 255 | ``` 256 | 257 | ## Commands 258 | 259 | The following commands are supported by Termy. 260 | 261 | ### `cd [DIRECTORY]` 262 | 263 | Supports changing directory, including use `..` to move up a level 264 | 265 | ```bash 266 | # cd with relative path 267 | /home $> cd user/test 268 | 269 | /home/user/test $> cd user/test 270 | 271 | # cd from absolute path 272 | / $> cd /home/user/test 273 | 274 | /home/user/test $> 275 | 276 | # cd using ".." 277 | /home $> cd .. 278 | 279 | / $> 280 | 281 | # cd using nested path with ".." 282 | / $> cd /home/user/../user 283 | 284 | /home/user $> 285 | ``` 286 | 287 | ### `pwd` 288 | 289 | Prints current directory to the console 290 | 291 | ```bash 292 | /home/user $> pwd 293 | /home/user 294 | ``` 295 | 296 | ### `ls [DIRECTORY]` 297 | 298 | Lists information about files and directories within the file system. With no arguments, 299 | it will use the current directory. 300 | 301 | ```bash 302 | /home/user $> ls 303 | # Contents of /home/user 304 | 305 | /home/user $> ls /home 306 | # Contents of /home 307 | 308 | /home/user $> ls .. 309 | # Contents of /home 310 | ``` 311 | 312 | ### `mkdir [DIRECTORY]` 313 | 314 | Creates a folder for a given path in the filesystem 315 | 316 | ```bash 317 | # mkdir with relative path 318 | / $> mkdir test 319 | Folder created: test 320 | 321 | # mkdir with absolute path 322 | / $> mkdir /home/banana 323 | Folder created: /home/banana 324 | 325 | # mkdir with ".." path 326 | /home/user $> mkdir ../test2 327 | Folder created: ../test2 #/home/test2 328 | ``` 329 | 330 | ### `cat [FILE]` 331 | 332 | Shows the contents of a file. Both basic text files and images are supported (with some dependencies, see the [Usage](#usage) 333 | section). 334 | 335 | ```bash 336 | /home $> cat file1.txt 337 | # Contents of file1.txt 338 | 339 | /home $> cat videos/file2.txt 340 | # Contents of file2.txt 341 | 342 | /home $> cat home/dog.png 343 | / \__ 344 | ( @\___ 345 | / O 346 | / (_____/ 347 | /_____/ U 348 | ``` 349 | 350 | ### `rm [OPTIONS] [FILE]` 351 | 352 | Remove a file or directory from the filesystem 353 | 354 | **Options** 355 | 356 | - `-r` - remove directories and their contents recursively 357 | 358 | ```bash 359 | / $> rm -r home 360 | # home directory deleted 361 | 362 | /home $> rm videos/file2.txt 363 | # file2.txt deleted 364 | ``` 365 | 366 | ### `help` 367 | 368 | Prints available commands for the terminal with descriptions. 369 | 370 | ``` 371 | / $> help 372 | cd - Changes the current working directory 373 | pwd - Prints the current working directory 374 | ls - Lists the contents of the given directory 375 | mkdir - Creates a folder for a given path in the filesystem 376 | cat - Shows the contents of a file 377 | rm - Removes a file or directory 378 | help - Prints list of available commands 379 | ``` 380 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, Component, FormEvent, KeyboardEvent } from 'react'; 2 | import AutoCompleteList from './components/AutoCompleteList'; 3 | import History from './components/History'; 4 | import Input from './components/Input'; 5 | import commands from './commands'; 6 | import './styles/Terminal.scss'; 7 | import { parseCommand } from './commands/utilities'; 8 | import { 9 | formatItem, 10 | getTargetPath, 11 | getUpdatedInputValueFromTarget, 12 | } from './helpers/autoComplete'; 13 | import TerminalContext from './context/TerminalContext'; 14 | 15 | let commandList = commands; 16 | 17 | // Export utility methods for external use 18 | export * as utilities from './commands/utilities'; 19 | 20 | // Export default autoComplete function for external use 21 | export { default as autoComplete } from './commands/autoComplete'; 22 | 23 | export interface TerminalState { 24 | autoCompleteIsActive: boolean; 25 | autoCompleteActiveItem: number; 26 | currentCommandId: number; 27 | currentHistoryId: number; 28 | currentPath: string; 29 | fileSystem: FileSystem; 30 | history: HistoryItem[]; 31 | inputPrompt: string; 32 | inputValue: string; 33 | autoCompleteItems?: ItemListType; 34 | } 35 | 36 | export interface HistoryItem { 37 | input: JSX.Element; 38 | id: number; 39 | result: CommandResponse['commandResult']; 40 | value: string; 41 | } 42 | 43 | export interface CommandHandler { 44 | ( 45 | fileSystem?: FileSystem, 46 | currentPath?: string, 47 | targetPath?: string, 48 | options?: string, 49 | ): Promise; 50 | } 51 | 52 | export interface CommandAutoCompleteHandler { 53 | (fileSystem: FileSystem, currentPath: string, target: string): Promise< 54 | AutoCompleteResponse 55 | >; 56 | } 57 | 58 | export interface Command { 59 | autoCompleteHandler?: CommandAutoCompleteHandler; 60 | description?: string; 61 | handler: CommandHandler; 62 | } 63 | 64 | export interface TerminalProps { 65 | fileSystem: FileSystem; 66 | inputPrompt?: string; 67 | customCommands?: { 68 | [key: string]: Command; 69 | }; 70 | } 71 | 72 | export interface FileSystem { 73 | [key: string]: TerminalFolder | TerminalFile; 74 | } 75 | 76 | export type TerminalFile = TerminalTextFile | TerminalImageFile; 77 | 78 | export interface TerminalTextFile { 79 | [key: string]: 'FILE' | string | JSX.Element; 80 | type: 'FILE'; 81 | content: string | JSX.Element; 82 | extension: 'txt'; 83 | } 84 | 85 | export interface TerminalImageFile { 86 | [key: string]: 'FILE' | string; 87 | type: 'FILE'; 88 | content: string; 89 | extension: 'jpg' | 'png' | 'gif'; 90 | } 91 | 92 | export interface TerminalFolder { 93 | [key: string]: 'FOLDER' | FileSystem | null; 94 | type: 'FOLDER'; 95 | children: FileSystem | null; 96 | } 97 | 98 | export type CommandResponse = { 99 | updatedState?: Partial; 100 | commandResult?: JSX.Element | string; 101 | }; 102 | 103 | export interface ItemListType { 104 | [index: string]: { 105 | type: 'FOLDER' | 'FILE'; 106 | }; 107 | } 108 | 109 | export type AutoCompleteResponse = { 110 | commandResult?: ItemListType | null; 111 | }; 112 | 113 | export class Terminal extends Component { 114 | public readonly state: TerminalState = { 115 | autoCompleteIsActive: false, 116 | autoCompleteActiveItem: -1, 117 | currentCommandId: 0, 118 | currentPath: '/', 119 | currentHistoryId: -1, 120 | fileSystem: this.props.fileSystem, 121 | history: [], 122 | inputPrompt: this.props.inputPrompt || '$>', 123 | inputValue: '', 124 | }; 125 | 126 | private inputWrapper = React.createRef(); 127 | 128 | private terminalInput = React.createRef(); 129 | 130 | public componentDidMount(): void { 131 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 132 | this.terminalInput.current!.focus(); 133 | 134 | // Add custom user commands to command list 135 | const { customCommands = {} } = this.props; 136 | commandList = Object.assign({}, commandList, customCommands); 137 | } 138 | 139 | public componentDidUpdate(): void { 140 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 141 | this.inputWrapper.current!.scrollIntoView({ behavior: 'smooth' }); 142 | } 143 | 144 | private handleChange = (event: ChangeEvent): void => { 145 | this.setState({ 146 | inputValue: event.target.value, 147 | }); 148 | }; 149 | 150 | private handleKeyDown = (event: KeyboardEvent): void => { 151 | // Prevent behavior of up arrow moving cursor to beginning of line in Chrome (and possibly others) 152 | if ( 153 | event.keyCode == 38 || 154 | event.key === 'ArrowUp' || 155 | event.keyCode == 9 || 156 | event.key === 'Tab' 157 | ) { 158 | event.preventDefault(); 159 | } 160 | 161 | const handleUpArrowKeyPress = (): void => { 162 | // Handle no history item to show 163 | if (this.state.currentHistoryId < 0) { 164 | return; 165 | } 166 | 167 | // Handle showing very first item from history 168 | if (this.state.currentHistoryId === 0) { 169 | this.setState({ 170 | inputValue: this.state.history[this.state.currentHistoryId].value, 171 | }); 172 | } 173 | 174 | // Handle show previous history item 175 | if (this.state.currentHistoryId > 0) { 176 | this.setState({ 177 | currentHistoryId: this.state.currentHistoryId - 1, 178 | inputValue: this.state.history[this.state.currentHistoryId].value, 179 | }); 180 | } 181 | }; 182 | 183 | const handleDownArrowKeyPress = (): void => { 184 | // Handle showing next history item 185 | if (this.state.currentHistoryId + 1 < this.state.currentCommandId) { 186 | this.setState({ 187 | currentHistoryId: this.state.currentHistoryId + 1, 188 | inputValue: this.state.history[this.state.currentHistoryId + 1].value, 189 | }); 190 | } 191 | 192 | // Handle when no next history item exists 193 | if (this.state.currentHistoryId + 1 >= this.state.currentCommandId) { 194 | this.setState({ 195 | inputValue: '', 196 | }); 197 | } 198 | }; 199 | 200 | const handleTabPress = async (): Promise => { 201 | const { 202 | autoCompleteActiveItem, 203 | autoCompleteIsActive, 204 | autoCompleteItems, 205 | inputValue, 206 | currentPath, 207 | fileSystem, 208 | } = this.state; 209 | const { commandName, commandTargets } = parseCommand(inputValue); 210 | 211 | // Tab pressed before a target is available so just return 212 | if (commandTargets.length < 1) { 213 | return; 214 | } 215 | 216 | const cycleThroughAutoCompleteItems = (itemList: ItemListType): void => { 217 | let newAutoCompleteActiveItemIndex = 0; 218 | if (autoCompleteActiveItem < Object.keys(itemList).length - 1) { 219 | newAutoCompleteActiveItemIndex = autoCompleteActiveItem + 1; 220 | } 221 | 222 | // If the current target isn't in AC list and ends with a "/", 223 | // it must be part of the base path and thus we append to it 224 | // instead of replacing it 225 | let targetPathToUpdate = getTargetPath(commandTargets[0]); 226 | if ( 227 | targetPathToUpdate !== 228 | Object.keys(itemList)[autoCompleteActiveItem] && 229 | commandTargets[0].endsWith('/') 230 | ) { 231 | targetPathToUpdate = ''; 232 | } 233 | 234 | const updatedInputValue = getUpdatedInputValueFromTarget( 235 | inputValue, 236 | commandTargets[0], 237 | formatItem(itemList, newAutoCompleteActiveItemIndex), 238 | targetPathToUpdate, 239 | ); 240 | 241 | this.setState( 242 | Object.assign({ 243 | autoCompleteActiveItem: newAutoCompleteActiveItemIndex, 244 | inputValue: updatedInputValue, 245 | }), 246 | ); 247 | }; 248 | 249 | const generateAutoCompleteList = async (): Promise => { 250 | let commandResult: AutoCompleteResponse['commandResult']; 251 | const autoCompleteHandler = 252 | commandList[commandName]?.autoCompleteHandler; 253 | 254 | if (autoCompleteHandler) { 255 | ({ commandResult } = await autoCompleteHandler( 256 | fileSystem, 257 | currentPath, 258 | commandTargets[0], 259 | )); 260 | } else { 261 | // Do nothing if tab is not supported 262 | return; 263 | } 264 | 265 | if (commandResult) { 266 | // If only one autocomplete option is available, just use it 267 | if (Object.keys(commandResult).length === 1) { 268 | // If the last part of current target path is a folder, 269 | // set target path to empty 270 | let targetPathToUpdate = getTargetPath(commandTargets[0]); 271 | if (commandTargets[0].endsWith('/')) { 272 | targetPathToUpdate = ''; 273 | } 274 | const updatedInputValue = getUpdatedInputValueFromTarget( 275 | inputValue, 276 | commandTargets[0], 277 | formatItem(commandResult, 0), 278 | targetPathToUpdate, 279 | ); 280 | 281 | this.setState( 282 | Object.assign({ 283 | inputValue: updatedInputValue, 284 | }), 285 | ); 286 | } else { 287 | // Else show all autocomplete options 288 | this.setState( 289 | Object.assign({ 290 | autoCompleteIsActive: true, 291 | autoCompleteItems: commandResult, 292 | }), 293 | ); 294 | } 295 | } 296 | }; 297 | 298 | if (autoCompleteIsActive && autoCompleteItems) { 299 | cycleThroughAutoCompleteItems(autoCompleteItems); 300 | } else { 301 | generateAutoCompleteList(); 302 | } 303 | }; 304 | 305 | // If we do anything other than tab, clear autocomplete state 306 | if (!(event.keyCode == 9 || event.key === 'Tab')) { 307 | this.setState( 308 | Object.assign({ 309 | autoCompleteActiveItem: -1, 310 | autoCompleteIsActive: false, 311 | autoCompleteItems: undefined, 312 | }), 313 | ); 314 | } 315 | 316 | if (event.keyCode == 38 || event.key === 'ArrowUp') { 317 | handleUpArrowKeyPress(); 318 | } 319 | 320 | if (event.keyCode == 40 || event.key === 'ArrowDown') { 321 | handleDownArrowKeyPress(); 322 | } 323 | 324 | if (event.keyCode == 9 || event.key === 'Tab') { 325 | handleTabPress(); 326 | } 327 | }; 328 | 329 | private handleSubmit = async ( 330 | event: FormEvent, 331 | ): Promise => { 332 | event.preventDefault(); 333 | 334 | const { 335 | history, 336 | inputValue, 337 | currentPath, 338 | inputPrompt, 339 | fileSystem, 340 | } = this.state; 341 | const { commandName, commandOptions, commandTargets } = parseCommand( 342 | inputValue, 343 | ); 344 | 345 | let commandResult: CommandResponse['commandResult']; 346 | let updatedState: CommandResponse['updatedState'] = {}; 347 | if (commandName in commandList) { 348 | try { 349 | ({ commandResult, updatedState = {} } = await commandList[ 350 | commandName 351 | ].handler( 352 | fileSystem, 353 | currentPath, 354 | commandTargets[0], 355 | ...commandOptions, 356 | )); 357 | } catch (e) { 358 | commandResult = `Error: ${e}`; 359 | } 360 | } else { 361 | commandResult = `command not found: ${commandName}`; 362 | } 363 | 364 | const updatedHistory = history.concat({ 365 | input: ( 366 | 372 | ), 373 | id: this.state.currentCommandId, 374 | result: commandResult, 375 | value: inputValue, 376 | }); 377 | 378 | this.setState( 379 | Object.assign( 380 | { 381 | autoCompleteIsActive: false, 382 | currentCommandId: this.state.currentCommandId + 1, 383 | currentHistoryId: this.state.currentCommandId, 384 | history: updatedHistory, 385 | inputValue: '', 386 | autoCompleteItems: undefined, 387 | }, 388 | updatedState, 389 | ) as TerminalState, 390 | ); 391 | }; 392 | 393 | public render(): JSX.Element { 394 | const { 395 | autoCompleteActiveItem, 396 | currentPath, 397 | history, 398 | inputPrompt, 399 | inputValue, 400 | autoCompleteItems, 401 | } = this.state; 402 | 403 | return ( 404 | 405 |
    406 | 407 |
    408 | 418 |
    419 |
    423 | {autoCompleteItems && ( 424 | 428 | )} 429 |
    430 |
    431 |
    432 | ); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`autocomplete with tab ls ls tab with argument and relative nested path 1`] = `"
    file1.txtfile5.txt
    "`; 4 | 5 | exports[`autocomplete with tab ls ls tab with no argument 1`] = `"
    home/docs/file3.txtfile4.txtblog.txt
    "`; 6 | 7 | exports[`autocomplete with tab rm rm tab with argument and relative nested path 1`] = `"
    file1.txtfile5.txt
    "`; 8 | 9 | exports[`autocomplete with tab rm rm tab with no target but command args 1`] = `"
    home/docs/file3.txtfile4.txtblog.txt
    "`; 10 | 11 | exports[`cat should handle cat with no space 1`] = `"
  • / $>
    Error: Invalid target path
  • "`; 12 | 13 | exports[`cat should list contents of file that contains react component 1`] = `"
  • / $>

    3/22

    Today is a good day

  • "`; 14 | 15 | exports[`cat should list contents of file with path 1`] = `"
  • / $>
    Contents of file 1
  • "`; 16 | 17 | exports[`cat should show error when cat on invalid path 1`] = `"
  • / $>
    Error: Invalid target path
  • "`; 18 | 19 | exports[`cat should show error when cat on non file 1`] = `"
  • / $>
    Error: Target is not a file
  • "`; 20 | 21 | exports[`cat should support cat on images 1`] = `"
  • / $>
  • "`; 22 | 23 | exports[`cd should cd one level 1`] = `"
  • / $>
  • "`; 24 | 25 | exports[`cd should handle invalid cd 1`] = `"
  • / $>
    Error: path does not exist: invalid
  • "`; 26 | 27 | exports[`cd should multiple levels with .. 1`] = `"
  • / $>
  • "`; 28 | 29 | exports[`cd should support cd with absolute path from nested path 1`] = `"
  • / $>
  • "`; 30 | 31 | exports[`cd should support cd with absolute path from nested path 2`] = `"
  • / $>
  • /home/user $>
  • "`; 32 | 33 | exports[`custom commands should allow custom command to be passed as prop and used 1`] = `"
  • / $>
    world
  • "`; 34 | 35 | exports[`custom commands should allow for commands to be added to help result with a description 1`] = `"
  • / $>
    • cat - Shows the contents of a file
    • cd - Changes the current working directory
    • help - Prints list of available commands
    • ls - Lists the contents of the given directory
    • mkdir - Creates a folder for a given path in the filesystem
    • pwd - Prints the current working directory
    • rm - Removes a file or directory
    • hello - don't panic
  • "`; 36 | 37 | exports[`general invalid command 1`] = `"
  • / $>
    command not found: invalid-command
  • "`; 38 | 39 | exports[`general invalid command 2`] = `"
  • / $>
    command not found: invalid-command
  • "`; 40 | 41 | exports[`help should print help menu 1`] = `"
  • / $>
    • cat - Shows the contents of a file
    • cd - Changes the current working directory
    • help - Prints list of available commands
    • ls - Lists the contents of the given directory
    • mkdir - Creates a folder for a given path in the filesystem
    • pwd - Prints the current working directory
    • rm - Removes a file or directory
  • "`; 42 | 43 | exports[`ls should correctly return contents for absolute path from nested path 1`] = `"
  • / $>
  • /home $>
    • test
  • "`; 44 | 45 | exports[`ls should correctly return contents for given relative directory from nested path 1`] = `"
  • / $>
  • /home $>
    • test
  • "`; 46 | 47 | exports[`ls should correctly return contents for given relative directory from root 1`] = `"
  • / $>
    • user
    • videos
    • dog.png
    • file1.txt
    • file5.txt
  • "`; 48 | 49 | exports[`ls should handle invalid directory for ls 1`] = `"
  • / $>
    Error: Target folder does not exist
  • "`; 50 | 51 | exports[`ls should list all content from current directory 1`] = `"
  • / $>
  • /home $>
    • user
    • videos
    • dog.png
    • file1.txt
    • file5.txt
  • "`; 52 | 53 | exports[`mkdir should create new directory from nested path 1`] = `"
  • / $>
  • "`; 54 | 55 | exports[`mkdir should create new directory from nested path 2`] = `"
  • / $>
  • /home $>
    Folder created: banana
  • "`; 56 | 57 | exports[`mkdir should create new directory from nested path 3`] = `"
  • / $>
  • /home $>
    Folder created: banana
  • /home $>
  • "`; 58 | 59 | exports[`mkdir should create new directory from root 1`] = `"
  • / $>
    Folder created: banana
  • "`; 60 | 61 | exports[`mkdir should create new directory from root 2`] = `"
  • / $>
    Folder created: banana
  • / $>
  • "`; 62 | 63 | exports[`mkdir should handle invalid mkdir command 1`] = `"
  • / $>
    Error: Path already exists
  • "`; 64 | 65 | exports[`pwd should correctly return current directory 1`] = `"
  • / $>
    /
  • "`; 66 | 67 | exports[`pwd should correctly return directory after cd 1`] = `"
  • / $>
  • /home/user/test $>
    /home/user/test
  • "`; 68 | 69 | exports[`rm should remove file from root 1`] = `"
  • / $>
  • "`; 70 | 71 | exports[`rm should remove file from root 2`] = `"
  • / $>
  • / $>
    Error: Invalid target path
  • "`; 72 | 73 | exports[`rm should remove folder from parent path 1`] = `"
  • / $>
  • /home/user $>
  • "`; 74 | 75 | exports[`rm should remove folder from root 1`] = `"
  • / $>
  • "`; 76 | 77 | exports[`rm should remove folder from root 2`] = `"
  • / $>
  • / $>
    Error: path does not exist: home
  • "`; 78 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | cleanup, 4 | fireEvent, 5 | render, 6 | waitFor, 7 | findByLabelText, 8 | } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import { CommandResponse, Terminal } from '..'; 11 | import exampleFileSystem from '../data/exampleFileSystem'; 12 | import autoComplete from '../commands/autoComplete'; 13 | import commands from '../commands'; 14 | jest.mock('../../images/dog.png', () => 'abc/dog.png'); 15 | 16 | beforeAll((): void => { 17 | Element.prototype.scrollIntoView = jest.fn(); 18 | }); 19 | 20 | afterEach(cleanup); 21 | 22 | afterAll(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | const fireTabInput = async (input: HTMLInputElement): Promise => { 27 | const tabEvent = new KeyboardEvent('keydown', { 28 | bubbles: true, 29 | code: '9', 30 | key: 'Tab', 31 | }); 32 | 33 | return new Promise((resolve) => { 34 | resolve(fireEvent(input, tabEvent)); 35 | }); 36 | }; 37 | 38 | describe('initialization', (): void => { 39 | test('custom input prompt', async (): Promise => { 40 | const { getByText } = render( 41 | '} 44 | />, 45 | ); 46 | const inputPrompt = getByText('custom prompt >'); 47 | 48 | expect(inputPrompt).not.toBeNull(); 49 | }); 50 | 51 | test('default input prompt should be $>', async (): Promise => { 52 | const { getByText } = render(); 53 | const inputPrompt = getByText('$>'); 54 | 55 | expect(inputPrompt).not.toBeNull(); 56 | }); 57 | 58 | test('should focus terminal input on page load', async (): Promise => { 59 | const { getByLabelText } = render( 60 | , 61 | ); 62 | const input = getByLabelText('terminal-input'); 63 | 64 | expect(document.activeElement).toEqual(input); 65 | }); 66 | }); 67 | 68 | describe('general', (): void => { 69 | test('invalid command', async (): Promise => { 70 | const { container, getByLabelText } = render( 71 | , 72 | ); 73 | const input = getByLabelText('terminal-input'); 74 | 75 | fireEvent.change(input, { target: { value: 'invalid-command' } }); 76 | fireEvent.submit(input); 77 | 78 | const history = await findByLabelText(container, 'terminal-history'); 79 | 80 | expect(history.innerHTML).toMatchSnapshot(); 81 | }); 82 | 83 | test('invalid command', async (): Promise => { 84 | const { container, getByLabelText } = render( 85 | , 86 | ); 87 | const input = getByLabelText('terminal-input'); 88 | 89 | fireEvent.change(input, { target: { value: 'invalid-command' } }); 90 | fireEvent.submit(input); 91 | 92 | const history = await findByLabelText(container, 'terminal-history'); 93 | 94 | expect(history.innerHTML).toMatchSnapshot(); 95 | }); 96 | }); 97 | 98 | describe('autocomplete with tab', (): void => { 99 | describe('general', (): void => { 100 | test('tab without space for target should do nothing', async (): Promise< 101 | void 102 | > => { 103 | const { container, getByLabelText } = render( 104 | , 105 | ); 106 | 107 | const input = getByLabelText('terminal-input') as HTMLInputElement; 108 | await userEvent.type(input, 'rm -r'); 109 | await fireTabInput(input); 110 | 111 | const autoCompleteContent = await findByLabelText( 112 | container, 113 | 'autocomplete-preview', 114 | ); 115 | 116 | expect(autoCompleteContent.innerHTML).toBe(''); 117 | expect(input.value).toBe('rm -r'); 118 | }); 119 | 120 | test('should call "e.preventDefault" on tab key press', async (): Promise< 121 | void 122 | > => { 123 | const { getByLabelText } = render( 124 | , 125 | ); 126 | const input = getByLabelText('terminal-input') as HTMLInputElement; 127 | 128 | const tabEvent = new KeyboardEvent('keydown', { 129 | bubbles: true, 130 | code: '9', 131 | key: 'Tab', 132 | }); 133 | Object.assign(tabEvent, { preventDefault: jest.fn() }); 134 | 135 | fireEvent(input, tabEvent); 136 | 137 | await waitFor(() => { 138 | expect(tabEvent.preventDefault).toHaveBeenCalledTimes(1); 139 | }); 140 | }); 141 | 142 | test('should do nothing on tab with invalid command', async (): Promise< 143 | void 144 | > => { 145 | const { container, getByLabelText } = render( 146 | , 147 | ); 148 | 149 | const input = getByLabelText('terminal-input') as HTMLInputElement; 150 | 151 | fireEvent.change(input, { target: { value: 'invalid ' } }); 152 | await fireTabInput(input); 153 | 154 | const autoCompleteContent = await findByLabelText( 155 | container, 156 | 'autocomplete-preview', 157 | ); 158 | 159 | expect(autoCompleteContent.innerHTML).toBe(''); 160 | expect(input.value).toBe('invalid '); 161 | }); 162 | }); 163 | 164 | describe('rm', (): void => { 165 | test('rm tab with argument and relative nested path', async (): Promise< 166 | void 167 | > => { 168 | const { container, getByLabelText } = render( 169 | , 170 | ); 171 | 172 | const input = getByLabelText('terminal-input') as HTMLInputElement; 173 | await userEvent.type(input, 'rm home/fi'); 174 | await fireTabInput(input); 175 | 176 | const autoCompleteContent = await findByLabelText( 177 | container, 178 | 'autocomplete-preview', 179 | ); 180 | 181 | expect(autoCompleteContent.innerHTML).toContain('file1.txt'); 182 | expect(autoCompleteContent.innerHTML).toContain('file5.txt'); 183 | expect(autoCompleteContent.innerHTML).toMatchSnapshot(); 184 | expect(input.value).toBe('rm home/fi'); 185 | }); 186 | 187 | test('pressing tab with autocomplete menu visible should cycle through', async (): Promise< 188 | void 189 | > => { 190 | const { container, getByLabelText } = render( 191 | , 192 | ); 193 | const input = getByLabelText('terminal-input') as HTMLInputElement; 194 | await userEvent.type(input, 'rm fi'); 195 | await fireTabInput(input); 196 | await fireTabInput(input); 197 | 198 | const autoCompleteContent = await findByLabelText( 199 | container, 200 | 'autocomplete-preview', 201 | ); 202 | expect(autoCompleteContent.innerHTML).toContain('file3.txt'); 203 | expect(autoCompleteContent.innerHTML).toContain('file4.txt'); 204 | expect(input.value).toBe('rm file3.txt'); 205 | 206 | await fireTabInput(input); 207 | expect(input.value).toBe('rm file4.txt'); 208 | 209 | await fireTabInput(input); 210 | expect(input.value).toBe('rm file3.txt'); 211 | }); 212 | 213 | test('rm tab with no target but command args', async (): Promise => { 214 | const { container, getByLabelText } = render( 215 | , 216 | ); 217 | 218 | const input = getByLabelText('terminal-input') as HTMLInputElement; 219 | await userEvent.type(input, 'rm -r '); 220 | await fireTabInput(input); 221 | 222 | const autoCompleteContent = await findByLabelText( 223 | container, 224 | 'autocomplete-preview', 225 | ); 226 | 227 | Object.keys(exampleFileSystem).forEach((item) => { 228 | expect(autoCompleteContent.innerHTML).toContain(item); 229 | }); 230 | expect(autoCompleteContent.innerHTML).toMatchSnapshot(); 231 | expect(input.value).toBe('rm -r '); 232 | }); 233 | }); 234 | 235 | describe('ls', (): void => { 236 | test('ls tab with no argument', async (): Promise => { 237 | const { container, getByLabelText } = render( 238 | , 239 | ); 240 | 241 | const input = getByLabelText('terminal-input') as HTMLInputElement; 242 | await userEvent.type(input, 'ls '); 243 | await fireTabInput(input); 244 | 245 | const autoCompleteContent = await findByLabelText( 246 | container, 247 | 'autocomplete-preview', 248 | ); 249 | 250 | Object.keys(exampleFileSystem).forEach((item) => { 251 | expect(autoCompleteContent.innerHTML).toContain(item); 252 | }); 253 | expect(autoCompleteContent.innerHTML).toMatchSnapshot(); 254 | expect(input.value).toBe('ls '); 255 | }); 256 | 257 | test('ls tab with argument and relative nested path', async (): Promise< 258 | void 259 | > => { 260 | const { container, getByLabelText } = render( 261 | , 262 | ); 263 | 264 | const input = getByLabelText('terminal-input') as HTMLInputElement; 265 | await userEvent.type(input, 'ls home/fi'); 266 | await fireTabInput(input); 267 | 268 | const autoCompleteContent = await findByLabelText( 269 | container, 270 | 'autocomplete-preview', 271 | ); 272 | 273 | expect(autoCompleteContent.innerHTML).toContain('file1.txt'); 274 | expect(autoCompleteContent.innerHTML).toContain('file5.txt'); 275 | expect(autoCompleteContent.innerHTML).toMatchSnapshot(); 276 | expect(input.value).toBe('ls home/fi'); 277 | }); 278 | 279 | test('pressing tab with autocomplete menu visible should cycle through', async (): Promise< 280 | void 281 | > => { 282 | const { container, getByLabelText } = render( 283 | , 284 | ); 285 | const input = getByLabelText('terminal-input') as HTMLInputElement; 286 | await userEvent.type(input, 'ls fi'); 287 | await fireTabInput(input); 288 | await fireTabInput(input); 289 | 290 | const autoCompleteContent = await findByLabelText( 291 | container, 292 | 'autocomplete-preview', 293 | ); 294 | expect(autoCompleteContent.innerHTML).toContain('file3.txt'); 295 | expect(autoCompleteContent.innerHTML).toContain('file4.txt'); 296 | expect(input.value).toBe('ls file3.txt'); 297 | 298 | await fireTabInput(input); 299 | expect(input.value).toBe('ls file4.txt'); 300 | 301 | await fireTabInput(input); 302 | expect(input.value).toBe('ls file3.txt'); 303 | }); 304 | 305 | test('tab with no argument should correctly cycle with more tabs', async (): Promise< 306 | void 307 | > => { 308 | const { getByLabelText } = render( 309 | , 310 | ); 311 | const input = getByLabelText('terminal-input') as HTMLInputElement; 312 | await userEvent.type(input, 'ls '); 313 | await fireTabInput(input); 314 | await fireTabInput(input); 315 | 316 | expect(input.value).toBe('ls home/'); 317 | 318 | await fireTabInput(input); 319 | expect(input.value).toBe('ls docs/'); 320 | 321 | await fireTabInput(input); 322 | expect(input.value).toBe('ls file3.txt'); 323 | }); 324 | 325 | test('should clear preview display once command is executed', async (): Promise< 326 | void 327 | > => { 328 | const { container, getByLabelText } = render( 329 | , 330 | ); 331 | 332 | const input = getByLabelText('terminal-input') as HTMLInputElement; 333 | await userEvent.type(input, 'ls home/fi'); 334 | await fireTabInput(input); 335 | 336 | const autoCompleteContent = await findByLabelText( 337 | container, 338 | 'autocomplete-preview', 339 | ); 340 | 341 | expect(autoCompleteContent.innerHTML).toContain('file1.txt'); 342 | expect(autoCompleteContent.innerHTML).toContain('file5.txt'); 343 | 344 | input.value = ''; 345 | await userEvent.type(input, 'ls home/user'); 346 | fireEvent.submit(input); 347 | 348 | await waitFor(() => { 349 | expect(autoCompleteContent.innerHTML).toBe(''); 350 | expect(input.value).toBe(''); 351 | }); 352 | }); 353 | 354 | test('tab when no autocomplete command exists should do nothing', async (): Promise< 355 | void 356 | > => { 357 | const { container, getByLabelText } = render( 358 | , 359 | ); 360 | 361 | const input = getByLabelText('terminal-input') as HTMLInputElement; 362 | await userEvent.type(input, 'help'); 363 | await fireTabInput(input); 364 | 365 | const autoCompleteContent = await findByLabelText( 366 | container, 367 | 'autocomplete-preview', 368 | ); 369 | 370 | expect(autoCompleteContent.innerHTML).toBe(''); 371 | expect(input.value).toBe('help'); 372 | }); 373 | 374 | test('tab press with single item should autofill it', async (): Promise< 375 | void 376 | > => { 377 | const { container, getByLabelText } = render( 378 | , 379 | ); 380 | const input = getByLabelText('terminal-input') as HTMLInputElement; 381 | await userEvent.type(input, 'ls ho'); 382 | await fireTabInput(input); 383 | 384 | const autoCompleteContent = await findByLabelText( 385 | container, 386 | 'autocomplete-preview', 387 | ); 388 | expect(input.value).toBe('ls home/'); 389 | expect(autoCompleteContent.innerHTML).toBe(''); 390 | }); 391 | 392 | test('multiple tab presses with changing targets', async (): Promise< 393 | void 394 | > => { 395 | const { container, getByLabelText } = render( 396 | , 397 | ); 398 | const input = getByLabelText('terminal-input') as HTMLInputElement; 399 | await userEvent.type(input, 'ls fi'); 400 | await fireTabInput(input); 401 | await fireTabInput(input); 402 | 403 | const autoCompleteContent = await findByLabelText( 404 | container, 405 | 'autocomplete-preview', 406 | ); 407 | expect(autoCompleteContent.innerHTML).toContain('file3.txt'); 408 | expect(autoCompleteContent.innerHTML).toContain('file4.txt'); 409 | expect(input.value).toBe('ls file3.txt'); 410 | 411 | input.value = ''; 412 | await userEvent.type(input, 'ls ho'); 413 | await fireTabInput(input); 414 | 415 | expect(input.value).toBe('ls home/'); 416 | }); 417 | 418 | test('tab multiple times to complete a nested path single options', async (): Promise< 419 | void 420 | > => { 421 | const { getByLabelText } = render( 422 | , 423 | ); 424 | const input = getByLabelText('terminal-input') as HTMLInputElement; 425 | await userEvent.type(input, 'ls ho'); 426 | await fireTabInput(input); 427 | 428 | expect(input.value).toBe('ls home/'); 429 | 430 | await userEvent.type(input, 'vi'); 431 | await fireTabInput(input); 432 | 433 | expect(input.value).toBe('ls home/videos/'); 434 | 435 | await userEvent.type(input, 'file'); 436 | await fireTabInput(input); 437 | 438 | expect(input.value).toBe('ls home/videos/file2.txt'); 439 | }); 440 | 441 | test('tab multiple times to complete a nested path multiple options', async (): Promise< 442 | void 443 | > => { 444 | const { container, getByLabelText } = render( 445 | , 446 | ); 447 | const input = getByLabelText('terminal-input') as HTMLInputElement; 448 | await userEvent.type(input, 'ls ho'); 449 | await fireTabInput(input); 450 | 451 | expect(input.value).toBe('ls home/'); 452 | 453 | await userEvent.type(input, 'fi'); 454 | await fireTabInput(input); 455 | await fireTabInput(input); 456 | 457 | expect(input.value).toBe('ls home/file1.txt'); 458 | 459 | const autoCompleteContent = await findByLabelText( 460 | container, 461 | 'autocomplete-preview', 462 | ); 463 | expect(autoCompleteContent.innerHTML).toContain('file1.txt'); 464 | expect(autoCompleteContent.innerHTML).toContain('file5.txt'); 465 | }); 466 | 467 | test('tab multiple times with nested folders', async (): Promise => { 468 | const { getByLabelText } = render( 469 | , 470 | ); 471 | const input = getByLabelText('terminal-input') as HTMLInputElement; 472 | await userEvent.type(input, 'ls home/'); 473 | await fireTabInput(input); 474 | await fireTabInput(input); 475 | 476 | expect(input.value).toBe('ls home/user/'); 477 | await fireTabInput(input); 478 | 479 | expect(input.value).toBe('ls home/videos/'); 480 | }); 481 | 482 | test('tab with .. in the nested path with partial match', async (): Promise< 483 | void 484 | > => { 485 | const { getByLabelText } = render( 486 | , 487 | ); 488 | const input = getByLabelText('terminal-input') as HTMLInputElement; 489 | await userEvent.type(input, 'ls home/user/../u'); 490 | await fireTabInput(input); 491 | 492 | expect(input.value).toBe('ls home/user/../user/'); 493 | }); 494 | 495 | test('tab with .. in the nested path from root', async (): Promise< 496 | void 497 | > => { 498 | const { container, getByLabelText } = render( 499 | , 500 | ); 501 | const input = getByLabelText('terminal-input') as HTMLInputElement; 502 | await userEvent.type(input, 'ls home/user/../'); 503 | await fireTabInput(input); 504 | 505 | const autoCompleteContent = await findByLabelText( 506 | container, 507 | 'autocomplete-preview', 508 | ); 509 | expect(autoCompleteContent.innerHTML).toContain('user/'); 510 | expect(autoCompleteContent.innerHTML).toContain('videos/'); 511 | expect(autoCompleteContent.innerHTML).toContain('dog.png'); 512 | expect(autoCompleteContent.innerHTML).toContain('file1.txt'); 513 | expect(autoCompleteContent.innerHTML).toContain('file5.txt'); 514 | 515 | await fireTabInput(input); 516 | expect(input.value).toBe('ls home/user/../user/'); 517 | }); 518 | 519 | test('tab with empty folder target', async (): Promise => { 520 | const { container, getByLabelText } = render( 521 | , 522 | ); 523 | const input = getByLabelText('terminal-input') as HTMLInputElement; 524 | await userEvent.type(input, 'ls home/user/test'); 525 | await fireTabInput(input); 526 | 527 | expect(input.value).toBe('ls home/user/test/'); 528 | await fireTabInput(input); 529 | 530 | const autoCompleteContent = await findByLabelText( 531 | container, 532 | 'autocomplete-preview', 533 | ); 534 | expect(input.value).toBe('ls home/user/test/'); 535 | expect(autoCompleteContent.innerHTML).toBe(''); 536 | }); 537 | }); 538 | 539 | describe('cd', (): void => { 540 | test('nested path with a single option', async (): Promise => { 541 | const { container, getByLabelText } = render( 542 | , 543 | ); 544 | 545 | const input = getByLabelText('terminal-input') as HTMLInputElement; 546 | await userEvent.type(input, 'cd home/user/'); 547 | await fireTabInput(input); 548 | 549 | const autoCompleteContent = await findByLabelText( 550 | container, 551 | 'autocomplete-preview', 552 | ); 553 | 554 | expect(autoCompleteContent.innerHTML).toBe(''); 555 | expect(input.value).toEqual('cd home/user/test/'); 556 | }); 557 | 558 | test('tab press with single item should autofill it', async (): Promise< 559 | void 560 | > => { 561 | const { container, getByLabelText } = render( 562 | , 563 | ); 564 | const input = getByLabelText('terminal-input') as HTMLInputElement; 565 | await userEvent.type(input, 'cd ho'); 566 | await fireTabInput(input); 567 | 568 | const autoCompleteContent = await findByLabelText( 569 | container, 570 | 'autocomplete-preview', 571 | ); 572 | expect(input.value).toBe('cd home/'); 573 | expect(autoCompleteContent.innerHTML).toBe(''); 574 | }); 575 | 576 | test('multiple tab presses with changing targets', async (): Promise< 577 | void 578 | > => { 579 | const { container, getByLabelText } = render( 580 | , 581 | ); 582 | const input = getByLabelText('terminal-input') as HTMLInputElement; 583 | await userEvent.type(input, 'cd '); 584 | await fireTabInput(input); 585 | await fireTabInput(input); 586 | 587 | const autoCompleteContent = await findByLabelText( 588 | container, 589 | 'autocomplete-preview', 590 | ); 591 | expect(autoCompleteContent.innerHTML).toContain('home/'); 592 | expect(autoCompleteContent.innerHTML).toContain('docs/'); 593 | expect(input.value).toBe('cd home/'); 594 | 595 | input.value = ''; 596 | await userEvent.type(input, 'cd do'); 597 | await fireTabInput(input); 598 | 599 | expect(input.value).toBe('cd docs/'); 600 | }); 601 | }); 602 | }); 603 | 604 | describe('cd', (): void => { 605 | test('should handle invalid cd', async (): Promise => { 606 | const { container, getByLabelText, getByTestId } = render( 607 | , 608 | ); 609 | 610 | const input = getByLabelText('terminal-input'); 611 | const currentPath = getByTestId('input-prompt-path'); 612 | 613 | fireEvent.change(input, { target: { value: 'cd invalid' } }); 614 | fireEvent.submit(input); 615 | 616 | const history = await findByLabelText(container, 'terminal-history'); 617 | 618 | expect(history.innerHTML).toMatchSnapshot(); 619 | expect(currentPath.innerHTML).toEqual('/'); 620 | }); 621 | 622 | test('should cd one level', async (): Promise => { 623 | const { container, getByLabelText, getByTestId } = render( 624 | , 625 | ); 626 | 627 | const input = getByLabelText('terminal-input'); 628 | const currentPath = getByTestId('input-prompt-path'); 629 | 630 | fireEvent.change(input, { target: { value: 'cd home' } }); 631 | fireEvent.submit(input); 632 | 633 | const history = await findByLabelText(container, 'terminal-history'); 634 | 635 | expect(history.innerHTML).toMatchSnapshot(); 636 | expect(currentPath.innerHTML).toEqual('/home'); 637 | }); 638 | 639 | test('should multiple levels with ..', async (): Promise => { 640 | const { container, getByLabelText, getByTestId } = render( 641 | , 642 | ); 643 | 644 | const input = getByLabelText('terminal-input'); 645 | const currentPath = getByTestId('input-prompt-path'); 646 | 647 | fireEvent.change(input, { 648 | target: { value: 'cd home/../home/user/../user/test' }, 649 | }); 650 | fireEvent.submit(input); 651 | 652 | const history = await findByLabelText(container, 'terminal-history'); 653 | 654 | expect(history.innerHTML).toMatchSnapshot(); 655 | expect(currentPath.innerHTML).toEqual('/home/user/test'); 656 | }); 657 | 658 | test('should support cd with absolute path from nested path', async (): Promise< 659 | void 660 | > => { 661 | const { container, getByLabelText, getByTestId } = render( 662 | , 663 | ); 664 | 665 | const input = getByLabelText('terminal-input'); 666 | const currentPath = getByTestId('input-prompt-path'); 667 | 668 | fireEvent.change(input, { 669 | target: { value: 'cd /home/user' }, 670 | }); 671 | fireEvent.submit(input); 672 | 673 | let history = await findByLabelText(container, 'terminal-history'); 674 | 675 | expect(history.innerHTML).toMatchSnapshot(); 676 | expect(currentPath.innerHTML).toEqual('/home/user'); 677 | 678 | fireEvent.change(input, { target: { value: 'cd /home' } }); 679 | fireEvent.submit(input); 680 | 681 | history = await findByLabelText(container, 'terminal-history'); 682 | 683 | expect(history.innerHTML).toMatchSnapshot(); 684 | expect(currentPath.innerHTML).toEqual('/home'); 685 | }); 686 | }); 687 | 688 | describe('pwd', (): void => { 689 | test('should correctly return current directory', async (): Promise => { 690 | const { container, getByLabelText, getByTestId } = render( 691 | , 692 | ); 693 | 694 | const input = getByLabelText('terminal-input'); 695 | const currentPath = getByTestId('input-prompt-path'); 696 | 697 | fireEvent.change(input, { target: { value: 'pwd' } }); 698 | fireEvent.submit(input); 699 | 700 | const history = await findByLabelText(container, 'terminal-history'); 701 | 702 | expect(history.innerHTML).toMatchSnapshot(); 703 | expect(currentPath.innerHTML).toEqual('/'); 704 | }); 705 | 706 | test('should correctly return directory after cd', async (): Promise< 707 | void 708 | > => { 709 | const { container, getByLabelText, getByTestId } = render( 710 | , 711 | ); 712 | 713 | const input = getByLabelText('terminal-input'); 714 | const currentPath = getByTestId('input-prompt-path'); 715 | 716 | fireEvent.change(input, { target: { value: 'cd home/user/test' } }); 717 | fireEvent.submit(input); 718 | 719 | /* eslint-disable-next-line @typescript-eslint/no-empty-function */ 720 | await waitFor(() => {}); 721 | 722 | fireEvent.change(input, { target: { value: 'pwd' } }); 723 | fireEvent.submit(input); 724 | 725 | const history = await findByLabelText(container, 'terminal-history'); 726 | 727 | expect(history.innerHTML).toMatchSnapshot(); 728 | expect(currentPath.innerHTML).toEqual('/home/user/test'); 729 | }); 730 | }); 731 | 732 | describe('ls', (): void => { 733 | test('should list all content from current directory', async (): Promise< 734 | void 735 | > => { 736 | const { container, getByLabelText } = render( 737 | , 738 | ); 739 | 740 | const input = getByLabelText('terminal-input'); 741 | 742 | fireEvent.change(input, { target: { value: 'cd home' } }); 743 | fireEvent.submit(input); 744 | 745 | /* eslint-disable-next-line @typescript-eslint/no-empty-function */ 746 | await waitFor(() => {}); 747 | 748 | fireEvent.change(input, { target: { value: 'ls' } }); 749 | fireEvent.submit(input); 750 | 751 | const history = await findByLabelText(container, 'terminal-history'); 752 | 753 | expect(history.innerHTML).toMatchSnapshot(); 754 | }); 755 | 756 | test('should correctly return contents for given relative directory from root', async (): Promise< 757 | void 758 | > => { 759 | const { container, getByLabelText } = render( 760 | , 761 | ); 762 | 763 | const input = getByLabelText('terminal-input'); 764 | 765 | fireEvent.change(input, { target: { value: 'ls home' } }); 766 | fireEvent.submit(input); 767 | 768 | const history = await findByLabelText(container, 'terminal-history'); 769 | 770 | expect(history.innerHTML).toMatchSnapshot(); 771 | }); 772 | 773 | test('should correctly return contents for given relative directory from nested path', async (): Promise< 774 | void 775 | > => { 776 | const { container, getByLabelText } = render( 777 | , 778 | ); 779 | 780 | const input = getByLabelText('terminal-input'); 781 | 782 | fireEvent.change(input, { target: { value: 'cd home' } }); 783 | fireEvent.submit(input); 784 | 785 | /* eslint-disable-next-line @typescript-eslint/no-empty-function */ 786 | await waitFor(() => {}); 787 | 788 | fireEvent.change(input, { target: { value: 'ls user' } }); 789 | fireEvent.submit(input); 790 | 791 | const history = await findByLabelText(container, 'terminal-history'); 792 | 793 | expect(history.innerHTML).toMatchSnapshot(); 794 | }); 795 | 796 | test('should correctly return contents for absolute path from nested path', async (): Promise< 797 | void 798 | > => { 799 | const { container, getByLabelText } = render( 800 | , 801 | ); 802 | 803 | const input = getByLabelText('terminal-input'); 804 | 805 | fireEvent.change(input, { target: { value: 'cd home' } }); 806 | fireEvent.submit(input); 807 | 808 | /* eslint-disable-next-line @typescript-eslint/no-empty-function */ 809 | await waitFor(() => {}); 810 | 811 | fireEvent.change(input, { target: { value: 'ls /home/user' } }); 812 | fireEvent.submit(input); 813 | 814 | const history = await findByLabelText(container, 'terminal-history'); 815 | 816 | expect(history.innerHTML).toMatchSnapshot(); 817 | }); 818 | 819 | test('should handle invalid directory for ls', async (): Promise => { 820 | const { container, getByLabelText } = render( 821 | , 822 | ); 823 | 824 | const input = getByLabelText('terminal-input'); 825 | 826 | fireEvent.change(input, { target: { value: 'ls invalid' } }); 827 | fireEvent.submit(input); 828 | 829 | const history = await findByLabelText(container, 'terminal-history'); 830 | 831 | expect(history.innerHTML).toMatchSnapshot(); 832 | }); 833 | }); 834 | 835 | describe('help', (): void => { 836 | test('should print help menu', async (): Promise => { 837 | const { container, getByLabelText } = render( 838 | , 839 | ); 840 | 841 | const input = getByLabelText('terminal-input'); 842 | 843 | fireEvent.change(input, { target: { value: 'help' } }); 844 | fireEvent.submit(input); 845 | 846 | const history = await findByLabelText(container, 'terminal-history'); 847 | 848 | expect(history.innerHTML).toMatchSnapshot(); 849 | Object.keys(commands).forEach((service): void => { 850 | if (service.indexOf('AutoComplete') === -1) { 851 | expect(container.innerHTML).toContain(service); 852 | } 853 | }); 854 | }); 855 | }); 856 | 857 | describe('mkdir', (): void => { 858 | test('should create new directory from root', async (): Promise => { 859 | const { container, getByLabelText, getByTestId } = render( 860 | , 861 | ); 862 | 863 | const input = getByLabelText('terminal-input'); 864 | const currentPath = getByTestId('input-prompt-path'); 865 | 866 | fireEvent.change(input, { target: { value: 'mkdir banana' } }); 867 | fireEvent.submit(input); 868 | 869 | let history = await findByLabelText(container, 'terminal-history'); 870 | 871 | expect(history.innerHTML).toMatchSnapshot(); 872 | 873 | fireEvent.change(input, { target: { value: 'cd banana' } }); 874 | fireEvent.submit(input); 875 | 876 | history = await findByLabelText(container, 'terminal-history'); 877 | 878 | expect(history.innerHTML).toMatchSnapshot(); 879 | expect(currentPath.innerHTML).toEqual('/banana'); 880 | }); 881 | 882 | test('should create new directory from nested path', async (): Promise< 883 | void 884 | > => { 885 | const { container, getByLabelText, getByTestId } = render( 886 | , 887 | ); 888 | 889 | const input = getByLabelText('terminal-input'); 890 | const currentPath = getByTestId('input-prompt-path'); 891 | 892 | fireEvent.change(input, { target: { value: 'cd home' } }); 893 | fireEvent.submit(input); 894 | 895 | let history = await findByLabelText(container, 'terminal-history'); 896 | 897 | expect(history.innerHTML).toMatchSnapshot(); 898 | 899 | fireEvent.change(input, { target: { value: 'mkdir banana' } }); 900 | fireEvent.submit(input); 901 | 902 | history = await findByLabelText(container, 'terminal-history'); 903 | 904 | expect(history.innerHTML).toMatchSnapshot(); 905 | 906 | fireEvent.change(input, { target: { value: 'cd /home/banana' } }); 907 | fireEvent.submit(input); 908 | 909 | history = await findByLabelText(container, 'terminal-history'); 910 | 911 | expect(history.innerHTML).toMatchSnapshot(); 912 | expect(currentPath.innerHTML).toEqual('/home/banana'); 913 | }); 914 | 915 | test('should handle invalid mkdir command', async (): Promise => { 916 | const { container, getByLabelText } = render( 917 | , 918 | ); 919 | 920 | const input = getByLabelText('terminal-input'); 921 | 922 | fireEvent.change(input, { target: { value: 'mkdir home' } }); 923 | fireEvent.submit(input); 924 | 925 | const history = await findByLabelText(container, 'terminal-history'); 926 | 927 | expect(history.innerHTML).toMatchSnapshot(); 928 | }); 929 | }); 930 | 931 | describe('cat', (): void => { 932 | test('should handle cat with no space', async (): Promise => { 933 | const { container, getByLabelText } = render( 934 | , 935 | ); 936 | 937 | const input = getByLabelText('terminal-input'); 938 | 939 | fireEvent.change(input, { target: { value: 'cat' } }); 940 | fireEvent.submit(input); 941 | 942 | const history = await findByLabelText(container, 'terminal-history'); 943 | 944 | expect(history.innerHTML).toContain('Error: Invalid target path'); 945 | expect(history.innerHTML).toMatchSnapshot(); 946 | }); 947 | }); 948 | 949 | describe('rm', (): void => { 950 | test('should remove file from root', async (): Promise => { 951 | const { container, getByLabelText } = render( 952 | , 953 | ); 954 | 955 | const input = getByLabelText('terminal-input'); 956 | 957 | fireEvent.change(input, { target: { value: 'rm file3.txt' } }); 958 | fireEvent.submit(input); 959 | 960 | let history = await findByLabelText(container, 'terminal-history'); 961 | 962 | expect(history.innerHTML).toMatchSnapshot(); 963 | 964 | fireEvent.change(input, { target: { value: 'cat file3.txt' } }); 965 | fireEvent.submit(input); 966 | 967 | history = await findByLabelText(container, 'terminal-history'); 968 | 969 | expect(history.innerHTML).toMatchSnapshot(); 970 | expect(history.innerHTML).not.toContain('Contents of file 3'); 971 | }); 972 | 973 | test('should remove folder from root', async (): Promise => { 974 | const { container, getByLabelText } = render( 975 | , 976 | ); 977 | 978 | const input = getByLabelText('terminal-input'); 979 | 980 | fireEvent.change(input, { target: { value: 'rm -r home' } }); 981 | fireEvent.submit(input); 982 | 983 | let history = await findByLabelText(container, 'terminal-history'); 984 | 985 | expect(history.innerHTML).toMatchSnapshot(); 986 | 987 | fireEvent.change(input, { target: { value: 'cd home' } }); 988 | fireEvent.submit(input); 989 | 990 | history = await findByLabelText(container, 'terminal-history'); 991 | 992 | expect(history.innerHTML).toMatchSnapshot(); 993 | expect(history.innerHTML).toContain('path does not exist: home'); 994 | }); 995 | 996 | test('should remove folder from parent path', async (): Promise => { 997 | const { container, getByLabelText } = render( 998 | , 999 | ); 1000 | 1001 | const input = getByLabelText('terminal-input'); 1002 | 1003 | fireEvent.change(input, { target: { value: 'cd home/user' } }); 1004 | fireEvent.submit(input); 1005 | 1006 | /* eslint-disable-next-line @typescript-eslint/no-empty-function */ 1007 | await waitFor(() => {}); 1008 | 1009 | fireEvent.change(input, { target: { value: 'rm -r ../../docs' } }); 1010 | fireEvent.submit(input); 1011 | 1012 | const history = await findByLabelText(container, 'terminal-history'); 1013 | 1014 | expect(history.innerHTML).toMatchSnapshot(); 1015 | }); 1016 | }); 1017 | 1018 | describe('cat', (): void => { 1019 | test('should list contents of file with path', async (): Promise => { 1020 | const { container, getByLabelText } = render( 1021 | , 1022 | ); 1023 | 1024 | const input = getByLabelText('terminal-input'); 1025 | 1026 | fireEvent.change(input, { target: { value: 'cat home/file1.txt' } }); 1027 | fireEvent.submit(input); 1028 | 1029 | const history = await findByLabelText(container, 'terminal-history'); 1030 | 1031 | expect(history.innerHTML).toContain('Contents of file 1'); 1032 | 1033 | expect(history.innerHTML).toMatchSnapshot(); 1034 | }); 1035 | 1036 | test('should list contents of file that contains react component', async (): Promise< 1037 | void 1038 | > => { 1039 | const { container, getByLabelText } = render( 1040 | , 1041 | ); 1042 | 1043 | const input = getByLabelText('terminal-input'); 1044 | 1045 | fireEvent.change(input, { target: { value: 'cat blog.txt' } }); 1046 | fireEvent.submit(input); 1047 | 1048 | const history = await findByLabelText(container, 'terminal-history'); 1049 | 1050 | expect(history.innerHTML).toContain('3/22'); 1051 | expect(history.innerHTML).toContain('Today is a good day'); 1052 | 1053 | expect(history.innerHTML).toMatchSnapshot(); 1054 | }); 1055 | 1056 | test('should show error when cat on non file', async (): Promise => { 1057 | const { container, getByLabelText } = render( 1058 | , 1059 | ); 1060 | 1061 | const input = getByLabelText('terminal-input'); 1062 | 1063 | fireEvent.change(input, { target: { value: 'cat home' } }); 1064 | fireEvent.submit(input); 1065 | 1066 | const history = await findByLabelText(container, 'terminal-history'); 1067 | 1068 | expect(history.innerHTML).toMatchSnapshot(); 1069 | }); 1070 | 1071 | test('should show error when cat on invalid path', async (): Promise< 1072 | void 1073 | > => { 1074 | const { container, getByLabelText } = render( 1075 | , 1076 | ); 1077 | 1078 | const input = getByLabelText('terminal-input'); 1079 | 1080 | fireEvent.change(input, { target: { value: 'cat invalid.txt' } }); 1081 | fireEvent.submit(input); 1082 | 1083 | const history = await findByLabelText(container, 'terminal-history'); 1084 | 1085 | expect(history.innerHTML).toMatchSnapshot(); 1086 | }); 1087 | 1088 | test('should support cat on images', async (): Promise => { 1089 | const { container, getByLabelText } = render( 1090 | , 1091 | ); 1092 | 1093 | const input = getByLabelText('terminal-input'); 1094 | 1095 | fireEvent.change(input, { target: { value: 'cat home/dog.png' } }); 1096 | fireEvent.submit(input); 1097 | 1098 | const history = await findByLabelText(container, 'terminal-history'); 1099 | 1100 | expect(history.innerHTML).toContain(''); 1101 | expect(history.innerHTML).toMatchSnapshot(); 1102 | }); 1103 | }); 1104 | 1105 | describe('history', (): void => { 1106 | test('up key should auto-fill previous command into input', async (): Promise< 1107 | void 1108 | > => { 1109 | const { getByLabelText } = render( 1110 | , 1111 | ); 1112 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1113 | 1114 | fireEvent.change(input, { target: { value: 'cd home' } }); 1115 | fireEvent.submit(input); 1116 | 1117 | await waitFor(() => { 1118 | expect(input.value).toBe(''); 1119 | }); 1120 | 1121 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1122 | await waitFor(() => { 1123 | expect(input.value).toBe('cd home'); 1124 | }); 1125 | }); 1126 | 1127 | test('up key should do nothing if no history items', async (): Promise< 1128 | void 1129 | > => { 1130 | const { getByLabelText } = render( 1131 | , 1132 | ); 1133 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1134 | 1135 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1136 | await waitFor(() => { 1137 | expect(input.value).toBe(''); 1138 | }); 1139 | }); 1140 | 1141 | test('up key should handle multiple history items', async (): Promise< 1142 | void 1143 | > => { 1144 | const { getByLabelText } = render( 1145 | , 1146 | ); 1147 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1148 | 1149 | fireEvent.change(input, { target: { value: 'cd home' } }); 1150 | fireEvent.submit(input); 1151 | 1152 | await waitFor(() => { 1153 | expect(input.value).toBe(''); 1154 | }); 1155 | 1156 | fireEvent.change(input, { target: { value: 'pwd' } }); 1157 | fireEvent.submit(input); 1158 | 1159 | await waitFor(() => { 1160 | expect(input.value).toBe(''); 1161 | }); 1162 | 1163 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1164 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1165 | await waitFor(() => { 1166 | expect(input.value).toBe('cd home'); 1167 | }); 1168 | }); 1169 | 1170 | test('should handle pressing up with nothing left in history', async (): Promise< 1171 | void 1172 | > => { 1173 | const { getByLabelText } = render( 1174 | , 1175 | ); 1176 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1177 | 1178 | fireEvent.change(input, { target: { value: 'cd home' } }); 1179 | fireEvent.submit(input); 1180 | 1181 | await waitFor(() => { 1182 | expect(input.value).toBe(''); 1183 | }); 1184 | 1185 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1186 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1187 | await waitFor(() => { 1188 | expect(input.value).toBe('cd home'); 1189 | }); 1190 | }); 1191 | 1192 | test('should call "e.preventDefault" on up arrow keyDown handler', async (): Promise< 1193 | void 1194 | > => { 1195 | const { getByLabelText } = render( 1196 | , 1197 | ); 1198 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1199 | 1200 | const keyDownEvent = new KeyboardEvent('keydown', { 1201 | bubbles: true, 1202 | code: '38', 1203 | key: 'ArrowUp', 1204 | }); 1205 | Object.assign(keyDownEvent, { preventDefault: jest.fn() }); 1206 | 1207 | fireEvent(input, keyDownEvent); 1208 | 1209 | await waitFor(() => { 1210 | expect(keyDownEvent.preventDefault).toHaveBeenCalledTimes(1); 1211 | }); 1212 | }); 1213 | 1214 | test('should not call "e.preventDefault" on up down keyDown handler', async (): Promise< 1215 | void 1216 | > => { 1217 | const { getByLabelText } = render( 1218 | , 1219 | ); 1220 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1221 | 1222 | const keyDownEvent = new KeyboardEvent('keydown', { 1223 | bubbles: true, 1224 | code: '40', 1225 | key: 'ArrowDown', 1226 | }); 1227 | Object.assign(keyDownEvent, { preventDefault: jest.fn() }); 1228 | 1229 | fireEvent(input, keyDownEvent); 1230 | 1231 | await waitFor(() => { 1232 | expect(keyDownEvent.preventDefault).not.toHaveBeenCalled(); 1233 | }); 1234 | }); 1235 | 1236 | test('down key should let you go to more recent history items', async (): Promise< 1237 | void 1238 | > => { 1239 | const { getByLabelText } = render( 1240 | , 1241 | ); 1242 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1243 | 1244 | fireEvent.change(input, { target: { value: 'cd home' } }); 1245 | fireEvent.submit(input); 1246 | 1247 | await waitFor(() => { 1248 | expect(input.value).toBe(''); 1249 | }); 1250 | 1251 | fireEvent.change(input, { target: { value: 'pwd' } }); 1252 | fireEvent.submit(input); 1253 | 1254 | await waitFor(() => { 1255 | expect(input.value).toBe(''); 1256 | }); 1257 | 1258 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1259 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1260 | fireEvent.keyDown(input, { key: 'ArrowDown', code: 40 }); 1261 | await waitFor(() => { 1262 | expect(input.value).toBe('pwd'); 1263 | }); 1264 | }); 1265 | 1266 | test('pressing down on most recent history item should make input empty', async (): Promise< 1267 | void 1268 | > => { 1269 | const { getByLabelText } = render( 1270 | , 1271 | ); 1272 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1273 | 1274 | fireEvent.change(input, { target: { value: 'cd home' } }); 1275 | fireEvent.submit(input); 1276 | 1277 | await waitFor(() => { 1278 | expect(input.value).toBe(''); 1279 | }); 1280 | 1281 | fireEvent.keyDown(input, { key: 'ArrowUp', code: 38 }); 1282 | fireEvent.keyDown(input, { key: 'ArrowDown', code: 40 }); 1283 | fireEvent.keyDown(input, { key: 'ArrowDown', code: 40 }); 1284 | await waitFor(() => { 1285 | expect(input.value).toBe(''); 1286 | }); 1287 | }); 1288 | 1289 | test('down key should do nothing if no history items', async (): Promise< 1290 | void 1291 | > => { 1292 | const { getByLabelText } = render( 1293 | , 1294 | ); 1295 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1296 | 1297 | fireEvent.keyDown(input, { key: 'ArrowDown', code: 40 }); 1298 | await waitFor(() => { 1299 | expect(input.value).toBe(''); 1300 | }); 1301 | }); 1302 | }); 1303 | 1304 | describe('custom commands', (): void => { 1305 | test('should allow custom command to be passed as prop and used', async (): Promise< 1306 | void 1307 | > => { 1308 | const hello = { 1309 | hello: { 1310 | handler: function hello(): Promise { 1311 | return new Promise((resolve): void => { 1312 | resolve({ 1313 | commandResult: 'world', 1314 | }); 1315 | }); 1316 | }, 1317 | }, 1318 | }; 1319 | 1320 | const { container, getByLabelText } = render( 1321 | , 1322 | ); 1323 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1324 | 1325 | fireEvent.change(input, { target: { value: 'hello' } }); 1326 | fireEvent.submit(input); 1327 | 1328 | const history = await findByLabelText(container, 'terminal-history'); 1329 | 1330 | expect(history.innerHTML).toMatchSnapshot(); 1331 | expect(history.innerHTML).toContain('world'); 1332 | }); 1333 | 1334 | test('should allow for commands to be added to help result with a description', async (): Promise< 1335 | void 1336 | > => { 1337 | const hello = { 1338 | hello: { 1339 | handler: function hello(): Promise { 1340 | return new Promise((resolve): void => { 1341 | resolve({ 1342 | commandResult: 'world', 1343 | }); 1344 | }); 1345 | }, 1346 | description: "don't panic", 1347 | }, 1348 | }; 1349 | 1350 | const { container, getByLabelText } = render( 1351 | , 1352 | ); 1353 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1354 | 1355 | fireEvent.change(input, { target: { value: 'help' } }); 1356 | fireEvent.submit(input); 1357 | 1358 | const history = await findByLabelText(container, 'terminal-history'); 1359 | 1360 | expect(history.innerHTML).toMatchSnapshot(); 1361 | expect(history.innerHTML).toContain("don't panic"); 1362 | expect(history.innerHTML).toContain('hello'); 1363 | }); 1364 | 1365 | test('should use custom autoComplete method', async (): Promise => { 1366 | const hello = { 1367 | hello: { 1368 | handler: function hello(): Promise { 1369 | return new Promise((resolve): void => { 1370 | resolve({ 1371 | commandResult: 'world', 1372 | }); 1373 | }); 1374 | }, 1375 | autoCompleteHandler: autoComplete, 1376 | }, 1377 | }; 1378 | 1379 | const { container, getByLabelText } = render( 1380 | , 1381 | ); 1382 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1383 | 1384 | await userEvent.type(input, 'hello fi'); 1385 | await fireTabInput(input); 1386 | await fireTabInput(input); 1387 | 1388 | const autoCompleteContent = await findByLabelText( 1389 | container, 1390 | 'autocomplete-preview', 1391 | ); 1392 | expect(autoCompleteContent.innerHTML).toContain('file3.txt'); 1393 | expect(autoCompleteContent.innerHTML).toContain('file4.txt'); 1394 | expect(input.value).toBe('hello file3.txt'); 1395 | 1396 | await fireTabInput(input); 1397 | expect(input.value).toBe('hello file4.txt'); 1398 | 1399 | await fireTabInput(input); 1400 | expect(input.value).toBe('hello file3.txt'); 1401 | }); 1402 | 1403 | test('should do nothing for autocomplete on custom command if not defined', async (): Promise< 1404 | void 1405 | > => { 1406 | const hello = { 1407 | hello: { 1408 | handler: function hello(): Promise { 1409 | return new Promise((resolve): void => { 1410 | resolve({ 1411 | commandResult: 'world', 1412 | }); 1413 | }); 1414 | }, 1415 | }, 1416 | }; 1417 | 1418 | const { container, getByLabelText } = render( 1419 | , 1420 | ); 1421 | const input = getByLabelText('terminal-input') as HTMLInputElement; 1422 | 1423 | await userEvent.type(input, 'hello '); 1424 | await fireTabInput(input); 1425 | 1426 | const autoCompleteContent = await findByLabelText( 1427 | container, 1428 | 'autocomplete-preview', 1429 | ); 1430 | 1431 | expect(autoCompleteContent.innerHTML).toBe(''); 1432 | expect(input.value).toBe('hello '); 1433 | }); 1434 | }); 1435 | --------------------------------------------------------------------------------