├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── cli.js ├── package-lock.json ├── package.json ├── screenshot-intro.png ├── screenshot-main.png └── src ├── core.hooks ├── use-runner │ ├── runner.hook.js │ └── runner.worker.js └── use-scanner │ ├── scanner.hook.js │ └── scanner.worker.js ├── core.libs ├── depends-on.js └── logger.js ├── core.ui └── loader.js ├── index.js ├── index.state.js ├── page.home ├── home.js └── ui │ ├── cli.js │ ├── files.js │ ├── help.js │ ├── result.js │ └── tabs.js └── page.loader └── loader.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | job_defaults: &job_defaults 4 | docker: 5 | - image: "circleci/node:12" 6 | working_directory: ~/tape-ui-repo 7 | 8 | job_filter: &job_filter 9 | filters: 10 | branches: 11 | only: 12 | - master 13 | 14 | jobs: 15 | setup: 16 | <<: *job_defaults 17 | 18 | steps: 19 | - checkout 20 | 21 | - run: 22 | name: "tape-ui: Install npm packages" 23 | command: "npm run setup" 24 | 25 | - save_cache: 26 | paths: 27 | - node_modules 28 | - bin 29 | - src 30 | - .git 31 | - .gitignore 32 | - .eslintrc 33 | - .prettierrc 34 | - package.json 35 | - package-lock.json 36 | key: tape-ui-{{ .Branch }}-{{ .Revision }} 37 | 38 | test: 39 | <<: *job_defaults 40 | 41 | steps: 42 | - checkout 43 | 44 | - restore_cache: 45 | keys: 46 | - tape-ui-{{ .Branch }}-{{ .Revision }} 47 | 48 | - run: 49 | name: "tape-ui: Run linter" 50 | command: "npm run lint" 51 | 52 | publish: 53 | <<: *job_defaults 54 | 55 | steps: 56 | - checkout 57 | 58 | - restore_cache: 59 | keys: 60 | - tape-ui-{{ .Branch }}-{{ .Revision }} 61 | 62 | - run: 63 | name: "tape-ui: Release to npm with semantic-release" 64 | command: "npx semantic-release" 65 | 66 | workflows: 67 | npm_publish: 68 | jobs: 69 | - setup: 70 | <<: *job_filter 71 | - test: 72 | <<: *job_filter 73 | requires: 74 | - setup 75 | - publish: 76 | <<: *job_filter 77 | requires: 78 | - test 79 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "@asd14/eslint-config/targets/node", 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 9 8 | }, 9 | "rules": { 10 | }, 11 | "settings": { 12 | // Recommended if you use eslint_d 13 | "import/cache": { 14 | "lifetime": 5, 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | .coveralls.yml 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Parcel 41 | .cache 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | 67 | # Babel compile 68 | dist/ 69 | compile/ 70 | 71 | # Sublime text 72 | *.sublime-project 73 | *.sublime-workspace 74 | .tern-port 75 | 76 | # Flow type 77 | flow-typed/ 78 | 79 | tags 80 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": true, 10 | "arrowParens": "avoid" 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | Releases and changelog are automaticly handled by [semantic-release](https://github.com/semantic-release/semantic-release). 6 | 7 | All releases are based on Angular's [Git commit message](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) patterns. 8 | 9 | See the [releases section](https://github.com/andreidmt/tape-ui/releases) for details. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andrei Dumitrescu 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![CircleCI](https://circleci.com/gh/andreidmt/tape-ui/tree/master.svg?style=svg)](https://circleci.com/gh/andreidmt/tape-ui/tree/master) 4 | [![npm version](https://badge.fury.io/js/tape-ui.svg)](https://www.npmjs.com/package/tape-ui) 5 | 6 | # WIP: Tape UI 7 | 8 | ![Tape UI](screenshot-intro.png) 9 | 10 | ![Tape UI](screenshot-main.png) 11 | 12 | --- 13 | 14 | 15 | 16 | * [Features](#features) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [Develop](#develop) 20 | * [Changelog](#changelog) 21 | 22 | 23 | 24 | ## Features 25 | 26 | * [x] Selectively run tests 27 | * [ ] Run relevant tests based on file changes 28 | * [ ] Test coverage 29 | 30 | ## Install 31 | 32 | ```bash 33 | npm install tape-ui 34 | ``` 35 | 36 | ## Use 37 | 38 | Add script in `package.json` 39 | 40 | ```json 41 | { 42 | "scripts": { 43 | "tdd": "tape-ui -r @babel/register 'src/**/*.test.js'", 44 | } 45 | } 46 | ``` 47 | 48 | ## Develop 49 | 50 | ```bash 51 | git clone git@github.com:andreidmt/tape-ui.git && \ 52 | cd tape-ui && \ 53 | npm run setup 54 | 55 | # run tests (any `*.test.js`) once 56 | npm test 57 | 58 | # watch `src` folder for changes and run test automatically 59 | npm run tdd 60 | ``` 61 | 62 | ## Changelog 63 | 64 | See the [releases section](https://github.com/andreidmt/tape-ui/releases) for details. 65 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { isEmpty } = require("@asd14/m") 4 | 5 | // First args will always be the node path followed by interpreted file 6 | const params = require("minimist")(process.argv.slice(2), { 7 | alias: { 8 | r: "require", 9 | }, 10 | string: ["require"], 11 | default: { 12 | require: [], 13 | }, 14 | }) 15 | 16 | require("../src")({ 17 | requireModules: Array.isArray(params.r) ? params.r : [params.r], 18 | fileGlob: isEmpty(params._) ? ["src/**/*.test.js"] : params._, 19 | }) 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tape-ui", 3 | "version": "0.0.1", 4 | "description": "Terminal UI test runner for Tape", 5 | "license": "MIT", 6 | "keywords": [ 7 | "terminal", 8 | "cli", 9 | "testing", 10 | "tape", 11 | "tap", 12 | "blessed" 13 | ], 14 | "homepage": "https://github.com/andreidmt/tape-ui", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+ssh://git@github.com/andreidmt/tape-ui.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/andreidmt/tape-ui/issues" 21 | }, 22 | "author": { 23 | "name": "Andrei Dumitrescu", 24 | "url": "https://github.com/andreidmt" 25 | }, 26 | "main": "src/index.js", 27 | "bin": { 28 | "tape-ui": "bin/cli.js" 29 | }, 30 | "scripts": { 31 | "----UTIL": "", 32 | "setup": "rm -rf dist/* && rm -rf ./node_modules && npm i && npm audit fix", 33 | "setup:ci": "npm ci && npm audit fix", 34 | "update": "npm-check --update", 35 | "----DEV": "", 36 | "----BUILD": "", 37 | "prepublishOnly": "npm test && npm run lint", 38 | "----LINTING": "", 39 | "lint:md": "markdownlint *.md", 40 | "lint:js": "eslint --ext .jsx,.js src/", 41 | "lint": "npm run lint:md && npm run lint:js", 42 | "----TESTS": "", 43 | "test": "tape src/**/*.test.js", 44 | "tdd": "nodemon --watch src --exec \"npm test\"" 45 | }, 46 | "dependencies": { 47 | "@asd14/m": "^4.4.1", 48 | "dependency-tree": "^8.0.0", 49 | "fast-deep-equal": "^3.1.3", 50 | "figures": "^3.2.0", 51 | "glob": "^7.1.6", 52 | "just-a-list.redux": "^7.7.0", 53 | "minimist": "^1.2.5", 54 | "neo-blessed": "^0.2.0", 55 | "redux": "^4.0.5", 56 | "tape": "^5.1.1", 57 | "winston": "^3.3.3" 58 | }, 59 | "peerDependencies": { 60 | "tape": "^5.1.0" 61 | }, 62 | "devDependencies": { 63 | "@asd14/eslint-config": "^5.20.0", 64 | "eslint": "^7.17.0", 65 | "eslint-config-prettier": "^7.1.0", 66 | "eslint-plugin-compat": "^3.9.0", 67 | "eslint-plugin-import": "^2.22.1", 68 | "eslint-plugin-jsdoc": "^30.7.13", 69 | "eslint-plugin-json": "^2.1.2", 70 | "eslint-plugin-no-inferred-method-name": "^2.0.0", 71 | "eslint-plugin-prettier": "^3.3.1", 72 | "eslint-plugin-promise": "^4.2.1", 73 | "eslint-plugin-unicorn": "^25.0.1", 74 | "markdownlint-cli": "^0.26.0", 75 | "nodemon": "^2.0.7", 76 | "npm-check": "^5.9.2", 77 | "prettier": "^2.2.1", 78 | "semantic-release": "^17.3.1" 79 | }, 80 | "engines": { 81 | "node": ">=12" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /screenshot-intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asd-xiv/tape-ui/dbf7e9a782b890923ca7bc201e7a3601962cfe9c/screenshot-intro.png -------------------------------------------------------------------------------- /screenshot-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asd-xiv/tape-ui/dbf7e9a782b890923ca7bc201e7a3601962cfe9c/screenshot-main.png -------------------------------------------------------------------------------- /src/core.hooks/use-runner/runner.hook.js: -------------------------------------------------------------------------------- 1 | const { fork } = require("child_process") 2 | 3 | module.exports = { 4 | useRunner: ({ runArgs, onFinish }) => { 5 | const worker = fork(`${__dirname}/runner.worker.js`, runArgs) 6 | 7 | worker.on("message", onFinish) 8 | 9 | return worker 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/core.hooks/use-runner/runner.worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Runs via process.fork inside the main app. 3 | * 4 | * The overhead of starting >100 test files using process.spawn inside the 5 | * main process is not negligible and will block the UI. 6 | * Using a secondary long-running process will reduce that overhead from the 7 | * main app. 8 | */ 9 | 10 | const { spawn } = require("child_process") 11 | const { logger } = require("../../core.libs/logger") 12 | 13 | const runOne = ({ path, runArgs }) => { 14 | logger.info({ 15 | date: new Date(), 16 | path, 17 | runArgs, 18 | }) 19 | 20 | const child = spawn("node", [...runArgs, path], { 21 | detached: false, 22 | cwd: process.cwd(), 23 | env: {}, 24 | }) 25 | 26 | const stdout = [] 27 | const stderr = [] 28 | 29 | child.stdout.on("data", data => { 30 | stdout.push(data.toString()) 31 | }) 32 | 33 | child.stderr.on("data", data => { 34 | stderr.push(data.toString()) 35 | }) 36 | 37 | child.on("exit", code => { 38 | process.send({ 39 | path, 40 | code, 41 | stdout: stdout.join(""), 42 | stderr: stderr.join(""), 43 | }) 44 | }) 45 | } 46 | 47 | process.on("message", ({ path, runArgs }) => { 48 | runOne({ path, runArgs }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/core.hooks/use-scanner/scanner.hook.js: -------------------------------------------------------------------------------- 1 | const { fork } = require("child_process") 2 | 3 | module.exports = { 4 | useScanner: ({ onFinish }) => { 5 | const worker = fork(`${__dirname}/scanner.worker.js`) 6 | 7 | worker.on("message", onFinish) 8 | 9 | return worker 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/core.hooks/use-scanner/scanner.worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Runs via process.fork inside the main app. 3 | */ 4 | 5 | const dependencyTree = require("dependency-tree") 6 | const { forEach } = require("@asd14/m") 7 | 8 | process.on( 9 | "message", 10 | forEach(item => { 11 | process.send({ 12 | path: item, 13 | dependsOnFiles: dependencyTree.toList({ 14 | filename: item, 15 | directory: __dirname, 16 | filter: path => path.indexOf("node_modules") === -1, 17 | }), 18 | }) 19 | }) 20 | ) 21 | -------------------------------------------------------------------------------- /src/core.libs/depends-on.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync */ 2 | 3 | const espree = require("espree") 4 | const fs = require("fs") 5 | const path = require("path") 6 | const { pipe, filterWith, startsWith, map, read } = require("@asd14/m") 7 | 8 | const scanRequiredFiles = source => { 9 | const content = fs.readFileSync(source, "utf8") 10 | const ast = espree.parse(content, { ecmaVersion: 8, sourceType: "module" }) 11 | 12 | return pipe( 13 | read("body"), 14 | filterWith({ 15 | type: "ImportDeclaration", 16 | }), 17 | map([ 18 | read(["source", "value"]), 19 | relativePath => ({ 20 | isLocalFile: startsWith(".", relativePath), 21 | path: path.resolve(path.dirname(source), relativePath), 22 | }), 23 | ]) 24 | )(ast) 25 | } 26 | 27 | module.exports = { scanRequiredFiles } 28 | -------------------------------------------------------------------------------- /src/core.libs/logger.js: -------------------------------------------------------------------------------- 1 | const { createLogger, format, transports } = require("winston") 2 | 3 | module.exports = { 4 | logger: createLogger({ 5 | format: format.json(), 6 | transports: [new transports.File({ filename: "tape-ui.log" })], 7 | }), 8 | } 9 | -------------------------------------------------------------------------------- /src/core.ui/loader.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const { is } = require("@asd14/m") 3 | 4 | const asciiFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 5 | 6 | const loaderUI = ({ parent, top, left, height }) => { 7 | const labelBox = blessed.box({ 8 | parent, 9 | tags: true, 10 | keys: false, 11 | vi: false, 12 | mouse: false, 13 | top, 14 | left, 15 | height, 16 | }) 17 | 18 | let spinnerInterval = null 19 | let spinnerFrame = 0 20 | 21 | return [ 22 | labelBox, 23 | ({ content, width, isLoading }) => { 24 | labelBox.setContent( 25 | isLoading ? `${asciiFrames[spinnerFrame]} ${content}` : ` ${content}` 26 | ) 27 | labelBox.width = width 28 | 29 | /* 30 | * useEffect(() => { 31 | * ... 32 | * }, [isLoading]) 33 | */ 34 | 35 | if (isLoading !== labelBox._.isLoading) { 36 | if (isLoading && !is(spinnerInterval)) { 37 | spinnerInterval = setInterval(() => { 38 | spinnerFrame = (spinnerFrame + 1) % asciiFrames.length 39 | 40 | labelBox.setContent(`${asciiFrames[spinnerFrame]} ${content}`) 41 | labelBox.screen.render() 42 | }, 70) 43 | } 44 | 45 | if (!isLoading && is(spinnerInterval)) { 46 | spinnerInterval = clearInterval(spinnerInterval) 47 | } 48 | } 49 | 50 | /* 51 | * Local props, acts like prevProps 52 | */ 53 | 54 | labelBox._.content = content 55 | labelBox._.width = width 56 | labelBox._.isLoading = isLoading 57 | }, 58 | ] 59 | } 60 | 61 | module.exports = { loaderUI } 62 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const { 3 | all, 4 | hasKey, 5 | pipe, 6 | reduce, 7 | forEach, 8 | read, 9 | readMany, 10 | } = require("@asd14/m") 11 | 12 | const projectPkg = require(`${process.cwd()}/package.json`) 13 | 14 | const { homePage } = require("./page.home/home") 15 | const { loaderPage } = require("./page.loader/loader") 16 | 17 | const { FileList, store } = require("./index.state") 18 | const { useScanner } = require("./core.hooks/use-scanner/scanner.hook") 19 | const { useRunner } = require("./core.hooks/use-runner/runner.hook") 20 | 21 | /** 22 | * Main app function, called from "bin/cli.js" 23 | * 24 | * @param {string[]} props.requireModules asd 25 | * @param {string[]} props.fileGlob asd 26 | */ 27 | module.exports = ({ requireModules, fileGlob }) => { 28 | const testRunArgs = reduce( 29 | (acc, item) => [...acc, "-r", item], 30 | [], 31 | requireModules 32 | ) 33 | 34 | // Separate node process for offloading test execution 35 | const runnerWorker = useRunner({ 36 | runArgs: testRunArgs, 37 | onFinish: ({ path, stdout, stderr, code }) => { 38 | FileList.update(path, { 39 | isRunning: false, 40 | stdout, 41 | stderr, 42 | code, 43 | }) 44 | }, 45 | }) 46 | 47 | // 48 | const screen = blessed.screen({ 49 | title: `${projectPkg.name} v${projectPkg.version}`, 50 | 51 | // The width of tabs within an element's content 52 | tabSize: 2, 53 | 54 | // Automatically position child elements with border and padding in mind 55 | autoPadding: true, 56 | 57 | // Whether the focused element grabs all keypresses 58 | grabKeys: false, 59 | 60 | // Prevent keypresses from being received by any element 61 | lockKeys: true, 62 | 63 | // Automatically "dock" borders with other elements instead of overlapping, 64 | // depending on position 65 | dockBorders: true, 66 | 67 | // Allow for rendering of East Asian double-width characters, utf-16 68 | // surrogate pairs, and unicode combining characters. 69 | fullUnicode: true, 70 | 71 | debug: true, 72 | }) 73 | 74 | // Left/Right arrow switch focus between file list and results 75 | screen.on("keypress", (code, key) => { 76 | if (key.full === "right") { 77 | store.dispatch({ 78 | type: "USE-STATE.SET", 79 | payload: { id: "focusId", value: "result" }, 80 | }) 81 | } 82 | 83 | if (key.full === "left") { 84 | store.dispatch({ 85 | type: "USE-STATE.SET", 86 | payload: { id: "focusId", value: "files" }, 87 | }) 88 | } 89 | }) 90 | 91 | // Cycle through focusable elements 92 | screen.key("S-tab", () => { 93 | screen.focusPrevious() 94 | }) 95 | 96 | screen.key("tab", () => { 97 | screen.focusNext() 98 | }) 99 | 100 | // Switch to file result tab 101 | screen.key("1", () => { 102 | store.dispatch({ 103 | type: "USE-STATE.SET", 104 | payload: { id: "tabsSelectId", value: "results" }, 105 | }) 106 | }) 107 | 108 | // Switch to file details tab 109 | screen.key("2", () => { 110 | store.dispatch({ 111 | type: "USE-STATE.SET", 112 | payload: { id: "tabsSelectId", value: "details" }, 113 | }) 114 | }) 115 | 116 | // Open filter/cli input 117 | screen.key(["/", ":"], () => { 118 | store.dispatch({ 119 | type: "USE-STATE.SET", 120 | payload: { id: "isCLIVisible", value: true }, 121 | }) 122 | }) 123 | 124 | // Run all test files at once 125 | screen.key("S-r", () => { 126 | const { items } = FileList.selector(store.getState()) 127 | 128 | forEach(({ id }) => { 129 | runnerWorker.send({ 130 | path: id, 131 | runArgs: testRunArgs, 132 | }) 133 | 134 | FileList.update(id, { 135 | isRunning: true, 136 | }) 137 | })(items()) 138 | }) 139 | 140 | /** 141 | * High level UI components 142 | */ 143 | 144 | const [homePageRef, renderHomePage] = homePage({ 145 | parent: screen, 146 | 147 | onFileSelect: id => { 148 | store.dispatch({ 149 | type: "USE-STATE.SET", 150 | payload: { id: "fileSelectId", value: id }, 151 | }) 152 | }, 153 | 154 | onFileRun: id => { 155 | runnerWorker.send({ path: id, runArgs: testRunArgs }) 156 | 157 | store.dispatch({ 158 | type: "USE-STATE.SET", 159 | payload: { id: "fileSelectId", value: id }, 160 | }) 161 | 162 | FileList.update(id, { 163 | isRunning: true, 164 | }) 165 | }, 166 | }) 167 | 168 | // Offload test file dependency scanning to separate node process 169 | const dependencyScannerWorker = useScanner({ 170 | onFinish: ({ path, dependsOnFiles }) => { 171 | FileList.update(path, { dependsOnFiles }) 172 | }, 173 | }) 174 | 175 | const [loaderPageRef, renderLoaderPage] = loaderPage({ parent: screen }) 176 | 177 | /* eslint-disable unicorn/no-process-exit */ 178 | screen.key(["C-c"], () => { 179 | dependencyScannerWorker.kill() 180 | runnerWorker.kill() 181 | }) 182 | 183 | dependencyScannerWorker.on("close", () => 184 | runnerWorker.killed ? process.exit() : null 185 | ) 186 | runnerWorker.on("close", () => 187 | dependencyScannerWorker.killed ? process.exit() : null 188 | ) 189 | 190 | const renderApp = () => { 191 | const currentState = store.getState() 192 | const { items, byId, isLoaded, isLoading } = FileList.selector(currentState) 193 | const isFileDependencyScanFinished = all(hasKey("dependsOnFiles"), items()) 194 | 195 | // Find all test files and scan for each file's dependencies 196 | if (!isLoading() && !isLoaded()) { 197 | FileList.read(fileGlob).then(({ result }) => { 198 | dependencyScannerWorker.send(readMany("id", null, result)) 199 | }) 200 | } 201 | 202 | if (isFileDependencyScanFinished) { 203 | renderHomePage({ 204 | files: items(), 205 | fileSelected: pipe( 206 | read(["USE-STATE", "fileSelectId"]), 207 | byId 208 | )(currentState), 209 | tab: read(["USE-STATE", "tabsSelectId"], "results", currentState), 210 | cliQuery: read(["USE-STATE", "cliQuery"], "", currentState), 211 | isCLIVisible: read(["USE-STATE", "isCLIVisible"], false, currentState), 212 | }) 213 | 214 | loaderPageRef.hide() 215 | homePageRef.show() 216 | } else { 217 | renderLoaderPage({ 218 | fileGlob, 219 | files: items(), 220 | isLoaded: isLoaded(), 221 | isFileDependencyScanFinished, 222 | }) 223 | 224 | loaderPageRef.show() 225 | homePageRef.hide() 226 | } 227 | 228 | // Only call to screen.render in the app 229 | screen.render() 230 | } 231 | 232 | // When any change occures in the Redux store, render the app 233 | store.subscribe(() => renderApp()) 234 | 235 | // Kickstart the app 236 | renderApp() 237 | } 238 | -------------------------------------------------------------------------------- /src/index.state.js: -------------------------------------------------------------------------------- 1 | const glob = require("glob") 2 | // const dependencyTree = require("dependency-tree") 3 | const { sep } = require("path") 4 | const { buildList } = require("just-a-list.redux") 5 | const { createStore, combineReducers } = require("redux") 6 | const { flatten, distinct, split, last, map, pipe } = require("@asd14/m") 7 | 8 | /** 9 | * 10 | */ 11 | const FileList = buildList({ 12 | name: "FILES", 13 | 14 | // @signature (fileGlob: String[]): Object[] 15 | read: pipe( 16 | map(item => glob.sync(item, { absolute: true })), 17 | flatten, 18 | distinct, 19 | map(item => { 20 | return { 21 | id: item, 22 | name: pipe(split(sep), last)(item), 23 | code: null, 24 | isRunning: false, 25 | } 26 | }) 27 | ), 28 | 29 | update: (id, data) => ({ 30 | id, 31 | ...data, 32 | }), 33 | }) 34 | 35 | /** 36 | * 37 | */ 38 | const store = createStore( 39 | combineReducers({ 40 | "USE-STATE": (state = {}, { type, payload: { id, value } = {} }) => { 41 | switch (type) { 42 | case "USE-STATE.SET": 43 | return { 44 | ...state, 45 | [id]: value, 46 | } 47 | default: 48 | return state 49 | } 50 | }, 51 | [FileList.name]: FileList.reducer, 52 | }) 53 | ) 54 | 55 | FileList.set({ dispatch: store.dispatch }) 56 | 57 | module.exports = { FileList, store } 58 | -------------------------------------------------------------------------------- /src/page.home/home.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const { 3 | read, 4 | pipe, 5 | map, 6 | count, 7 | max, 8 | push, 9 | contains, 10 | filterWith, 11 | } = require("@asd14/m") 12 | 13 | const { store } = require("../index.state") 14 | 15 | const { filesUI } = require("./ui/files") 16 | const { resultUI } = require("./ui/result") 17 | const { commandUI } = require("./ui/cli") 18 | const { tabsUI } = require("./ui/tabs") 19 | const { helpUI } = require("./ui/help") 20 | 21 | const homePage = ({ parent, onFileSelect, onFileRun }) => { 22 | // Parent of all component's UI elements 23 | const baseRef = blessed.box({ 24 | parent, 25 | }) 26 | 27 | const [filesRef, renderFilesUI] = filesUI({ 28 | parent: baseRef, 29 | onSelect: path => onFileSelect(path), 30 | onRun: path => onFileRun(path), 31 | }) 32 | 33 | filesRef.focus() 34 | 35 | const [, renderResultUI] = resultUI({ 36 | parent: baseRef, 37 | top: 2, 38 | }) 39 | 40 | const [, renderTabsUI] = tabsUI({ 41 | parent: baseRef, 42 | tabs: ["results", "details"], 43 | top: 0, 44 | }) 45 | 46 | const [, renderHelpUI] = helpUI({ 47 | parent: baseRef, 48 | width: "100%", 49 | left: 0, 50 | bottom: 0, 51 | }) 52 | 53 | const [, renderCommandUI] = commandUI({ 54 | parent: baseRef, 55 | width: "100%", 56 | height: "1px", 57 | top: "100%-1", 58 | left: "0", 59 | 60 | onChange: source => { 61 | store.dispatch({ 62 | type: "USE-STATE.SET", 63 | payload: { id: "cliQuery", value: source }, 64 | }) 65 | }, 66 | 67 | onSubmit: source => { 68 | filesRef.selectFirstWith(source) 69 | 70 | store.dispatch({ 71 | type: "USE-STATE.SET", 72 | payload: { id: "isCLIVisible", value: false }, 73 | }) 74 | }, 75 | 76 | onCancel: () => { 77 | store.dispatch({ 78 | type: "USE-STATE.SET", 79 | payload: { id: "isCLIVisible", value: false }, 80 | }) 81 | store.dispatch({ 82 | type: "USE-STATE.SET", 83 | payload: { id: "cliQuery", value: "" }, 84 | }) 85 | }, 86 | 87 | onBlur: () => { 88 | store.dispatch({ 89 | type: "USE-STATE.SET", 90 | payload: { id: "isCLIVisible", value: false }, 91 | }) 92 | store.dispatch({ 93 | type: "USE-STATE.SET", 94 | payload: { id: "cliQuery", value: "" }, 95 | }) 96 | }, 97 | }) 98 | 99 | return [ 100 | baseRef, 101 | ({ files, fileSelected = {}, tab, cliQuery, isCLIVisible }) => { 102 | const { id, name, dependsOnFiles, stdout } = fileSelected 103 | const filesFiltered = filterWith({ name: contains(cliQuery) }, files) 104 | const listWidth = pipe( 105 | map([read("name"), count]), 106 | push(20), 107 | max, 108 | source => source + 5 109 | )(filesFiltered) 110 | 111 | renderTabsUI({ 112 | label: name, 113 | left: listWidth, 114 | width: `100%-${listWidth}`, 115 | selected: tab, 116 | }) 117 | 118 | renderFilesUI({ 119 | items: filesFiltered, 120 | highlight: cliQuery, 121 | width: listWidth, 122 | }) 123 | 124 | renderResultUI({ 125 | left: listWidth, 126 | width: `100%-${listWidth}`, 127 | content: 128 | tab === "results" 129 | ? stdout 130 | : JSON.stringify( 131 | { 132 | path: id, 133 | watch: dependsOnFiles, 134 | }, 135 | null, 136 | 2 137 | ), 138 | }) 139 | 140 | renderHelpUI({ 141 | isVisible: !isCLIVisible, 142 | }) 143 | 144 | renderCommandUI({ 145 | value: cliQuery, 146 | isVisible: isCLIVisible, 147 | }) 148 | }, 149 | ] 150 | } 151 | 152 | module.exports = { homePage } 153 | -------------------------------------------------------------------------------- /src/page.home/ui/cli.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | 3 | const commandUI = ({ 4 | parent, 5 | width, 6 | height, 7 | top, 8 | left, 9 | onChange, 10 | onCancel, 11 | onSubmit, 12 | onBlur, 13 | }) => { 14 | const label = blessed.box({ 15 | parent, 16 | content: "/", 17 | left, 18 | top, 19 | }) 20 | 21 | const input = blessed.text({ 22 | parent, 23 | tags: true, 24 | keys: true, 25 | vi: true, 26 | mouse: true, 27 | width, 28 | height, 29 | top, 30 | left: `${left}+1`, 31 | }) 32 | 33 | input.on("blur", onBlur) 34 | 35 | input.on("keypress", (code, key) => { 36 | const value = input.getContent() 37 | 38 | if (/[\d .=A-Za-z\-]/.test(code)) { 39 | onChange(`${value}${code}`) 40 | } 41 | 42 | if (key.full === "backspace") { 43 | if (value === "") { 44 | onCancel() 45 | } else { 46 | onChange(value.slice(0, Math.max(0, value.length - 1))) 47 | } 48 | } 49 | 50 | if (key.full === "escape") { 51 | onCancel() 52 | } 53 | 54 | if (key.full === "enter") { 55 | onSubmit(value) 56 | } 57 | }) 58 | 59 | return [ 60 | input, 61 | ({ value, isVisible }) => { 62 | input.setContent(value) 63 | 64 | /* 65 | * useEffect(() => { 66 | * ... 67 | * }, [isVisible]) 68 | */ 69 | 70 | if (isVisible !== input._.isVisible) { 71 | if (isVisible === true) { 72 | label.show() 73 | input.show() 74 | input.focus() 75 | } else { 76 | label.hide() 77 | input.hide() 78 | } 79 | } 80 | 81 | /* 82 | * Local props, acts like prevProps 83 | */ 84 | 85 | input._.value = value 86 | input._.isVisible = isVisible 87 | }, 88 | ] 89 | } 90 | 91 | module.exports = { commandUI } 92 | -------------------------------------------------------------------------------- /src/page.home/ui/files.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const isDeepEqual = require("fast-deep-equal") 3 | const { 4 | read, 5 | map, 6 | hasWith, 7 | replace, 8 | findIndexWith, 9 | contains, 10 | } = require("@asd14/m") 11 | 12 | const { loaderUI } = require("../../core.ui/loader") 13 | 14 | const filesUI = ({ parent, onRun, onSelect }) => { 15 | const [, renderLoaderUI] = loaderUI({ 16 | parent, 17 | top: 0, 18 | left: 0, 19 | height: 1, 20 | }) 21 | 22 | const borderTopLine = blessed.line({ 23 | parent, 24 | orientation: "horizontal", 25 | top: 1, 26 | left: 0, 27 | height: 1, 28 | }) 29 | 30 | const list = blessed.list({ 31 | parent, 32 | tags: true, 33 | keys: true, 34 | vi: true, 35 | mouse: true, 36 | 37 | padding: { 38 | top: 0, 39 | left: 0, 40 | bottom: 0, 41 | right: 1, 42 | }, 43 | top: 2, 44 | left: 0, 45 | height: "100%-4", 46 | 47 | scrollbar: { 48 | style: { 49 | bg: "white", 50 | }, 51 | }, 52 | 53 | style: { 54 | focus: { 55 | selected: { 56 | bg: "gray", 57 | }, 58 | scrollbar: { 59 | bg: "blue", 60 | }, 61 | }, 62 | 63 | // Unselected item 64 | item: {}, 65 | 66 | // Selected item 67 | selected: { 68 | // bg: "gray", 69 | }, 70 | }, 71 | }) 72 | 73 | list.key(["down", "up"], () => { 74 | onSelect(read([list.selected, "id"], null)(list._.items), list.selected) 75 | }) 76 | 77 | list.key("r", () => { 78 | onRun(read([list.selected, "id"], null)(list._.items), list.selected) 79 | }) 80 | 81 | list.on("element click", () => { 82 | onSelect(read([list.selected, "id"], null)(list._.items), list.selected) 83 | }) 84 | 85 | list.selectFirstWith = source => { 86 | const index = findIndexWith({ name: contains(source) }, list._.items) 87 | 88 | if (index !== -1) { 89 | list.select(index) 90 | } 91 | } 92 | 93 | list.selectNext = () => { 94 | const selectedId = list._.selectedId 95 | const items = list._.items 96 | const index = findIndexWith({ id: contains(selectedId) }, items) 97 | 98 | if (index < items.length) { 99 | onSelect(read([index + 1, "id"])(items)) 100 | } 101 | } 102 | 103 | list.selectPrev = () => { 104 | const selectedId = list._.selectedId 105 | const items = list._.items 106 | const index = findIndexWith({ id: contains(selectedId) }, items) 107 | 108 | if (index > 0) { 109 | onSelect(read([index - 1, "id"])(items)) 110 | } 111 | } 112 | 113 | return [ 114 | list, 115 | 116 | ({ selectedId, items, highlight, width }) => { 117 | /* 118 | * useEffect(() => { 119 | * }, [selectedId]) 120 | */ 121 | 122 | // const prevSelectedId = list._.selectedId 123 | 124 | // if (selectedId !== prevSelectedId) { 125 | // if (is(prevSelectedId)) { 126 | // } 127 | // } 128 | 129 | /* 130 | * useEffect(() => { 131 | * }, [items, highlight]) 132 | */ 133 | 134 | const prevHighlight = list._.highlight 135 | const prevItems = list._.items 136 | 137 | if (!isDeepEqual(items, prevItems) || highlight !== prevHighlight) { 138 | list.setItems( 139 | map(({ name, code, isRunning }) => { 140 | const color = isRunning 141 | ? "blue" 142 | : code === 0 143 | ? "green" 144 | : code === null 145 | ? "gray" 146 | : "red" 147 | const text = replace( 148 | highlight, 149 | `{yellow-bg}{black-fg}${highlight}{/black-fg}{/yellow-bg}` 150 | )(name) 151 | 152 | return `{${color}-fg}■{/} ${text}` 153 | }, items) 154 | ) 155 | } 156 | 157 | renderLoaderUI({ 158 | content: `${items.length} test files`, 159 | width, 160 | isLoading: hasWith({ isRunning: true }, items), 161 | }) 162 | 163 | borderTopLine.width = width 164 | list.width = width 165 | 166 | /* 167 | * Local props, acts like prevProps 168 | */ 169 | 170 | list._.selectedId = selectedId 171 | list._.items = items 172 | list._.highlight = highlight 173 | list._.width = width 174 | }, 175 | ] 176 | } 177 | 178 | module.exports = { 179 | filesUI, 180 | } 181 | -------------------------------------------------------------------------------- /src/page.home/ui/help.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const { map, pipe, join } = require("@asd14/m") 3 | 4 | const helpUI = ({ parent, width, left, bottom }) => { 5 | const shortcutList = [ 6 | { key: "1", label: "results" }, 7 | { key: "2", label: "details" }, 8 | { key: "/", label: "filter" }, 9 | { key: "r", label: "run" }, 10 | { key: "shift+r", label: "run all" }, 11 | ] 12 | 13 | const contentBox = blessed.box({ 14 | parent, 15 | tags: true, 16 | 17 | width, 18 | height: 1, 19 | left, 20 | bottom, 21 | 22 | content: pipe( 23 | map(({ key, label }) => ` ${key} {white-bg}{black-fg}${label}{/}`), 24 | join("") 25 | )(shortcutList), 26 | }) 27 | 28 | blessed.line({ 29 | parent, 30 | orientation: "horizontal", 31 | top: "100%-2", 32 | left, 33 | width, 34 | height: 1, 35 | }) 36 | 37 | return [ 38 | contentBox, 39 | (isVisible = true) => { 40 | /* 41 | * useEffect(() => { 42 | * ... 43 | * }, [isVisible]) 44 | */ 45 | 46 | if (isVisible !== contentBox._.isVisible) { 47 | if (isVisible) { 48 | contentBox.show() 49 | } else { 50 | contentBox.hide() 51 | } 52 | } 53 | 54 | /* 55 | * Local props, acts like prevProps 56 | */ 57 | 58 | contentBox._.isVisible = isVisible 59 | }, 60 | ] 61 | } 62 | 63 | module.exports = { helpUI } 64 | -------------------------------------------------------------------------------- /src/page.home/ui/result.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | 3 | const resultUI = ({ parent, top }) => { 4 | const contentBox = blessed.log({ 5 | parent, 6 | tags: true, 7 | keys: true, 8 | vi: true, 9 | mouse: true, 10 | scrollable: true, 11 | scrollOnInput: true, 12 | 13 | top, 14 | height: "100%-4", 15 | 16 | scrollbar: { 17 | style: { 18 | bg: "white", 19 | }, 20 | }, 21 | 22 | style: { 23 | border: { 24 | fg: "white", 25 | }, 26 | focus: { 27 | border: { 28 | fg: "blue", 29 | }, 30 | scrollbar: { 31 | bg: "blue", 32 | }, 33 | }, 34 | }, 35 | }) 36 | 37 | return [ 38 | contentBox, 39 | ({ left, width, content }) => { 40 | contentBox.setContent(content) 41 | contentBox.width = width 42 | contentBox.position.left = left 43 | 44 | /** 45 | * Persist state data 46 | */ 47 | 48 | contentBox._.width = width 49 | contentBox._.left = left 50 | contentBox._.content = content 51 | }, 52 | ] 53 | } 54 | 55 | module.exports = { resultUI } 56 | -------------------------------------------------------------------------------- /src/page.home/ui/tabs.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const { map, reduce } = require("@asd14/m") 3 | 4 | const tabsUI = ({ parent, top, tabs }) => { 5 | const props = { tabs } 6 | 7 | const tabBoxes = map(item => { 8 | const box = blessed.box({ 9 | parent, 10 | top, 11 | height: 1, 12 | width: item.length + 2, 13 | content: ` ${item} `, 14 | }) 15 | 16 | box._.id = item 17 | 18 | return box 19 | }, tabs) 20 | 21 | const borderTopLine = blessed.line({ 22 | parent, 23 | orientation: "horizontal", 24 | top: `${top}+1`, 25 | height: 1, 26 | }) 27 | 28 | return [ 29 | "", 30 | ({ label = "", width, left, selected }) => { 31 | borderTopLine.width = width 32 | borderTopLine.position.left = left 33 | 34 | reduce( 35 | (acc, item) => { 36 | item.position.left = left + acc 37 | 38 | if (item._.id === selected) { 39 | item.style.bg = "blue" 40 | } else { 41 | item.style.bg = "" 42 | } 43 | 44 | return acc + item.getContent().length 45 | }, 46 | 0, 47 | tabBoxes 48 | ) 49 | 50 | /** 51 | * Persist state data 52 | */ 53 | 54 | props.label = label 55 | props.width = width 56 | props.left = left 57 | props.selected = selected 58 | }, 59 | ] 60 | } 61 | 62 | module.exports = { tabsUI } 63 | -------------------------------------------------------------------------------- /src/page.loader/loader.js: -------------------------------------------------------------------------------- 1 | const blessed = require("neo-blessed") 2 | const { 3 | flatten, 4 | distinct, 5 | count, 6 | map, 7 | read, 8 | prepend, 9 | countWith, 10 | is, 11 | join, 12 | push, 13 | pipe, 14 | } = require("@asd14/m") 15 | 16 | const loaderPage = ({ parent }) => { 17 | // Parent of all component's UI elements 18 | const ref = blessed.element({ 19 | parent, 20 | }) 21 | 22 | blessed.box({ 23 | parent: ref, 24 | align: "left", 25 | top: "10%", 26 | left: "center", 27 | width: 49, 28 | height: 10, 29 | content: ` 30 | ████████╗ █████╗ ██████╗ ███████╗ ██╗ ██╗██╗ 31 | ╚══██╔══╝██╔══██╗██╔══██╗██╔════╝ ██║ ██║██║ 32 | ██║ ███████║██████╔╝█████╗ ██║ ██║██║ 33 | ██║ ██╔══██║██╔═══╝ ██╔══╝ ██║ ██║██║ 34 | ██║ ██║ ██║██║ ███████╗ ╚██████╔╝██║ 35 | ╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ 36 | 37 | --- Reactive test runner for Tape ---`, 38 | }) 39 | 40 | const progress = blessed.box({ 41 | parent: ref, 42 | top: "10%+11", 43 | align: "left", 44 | left: "center", 45 | width: 60, 46 | tags: true, 47 | }) 48 | 49 | return [ 50 | ref, 51 | ({ fileGlob, files, isLoaded, isFileDependencyScanFinished }) => { 52 | const sourceFileCount = pipe( 53 | map(read("dependsOnFiles", [])), 54 | flatten, 55 | distinct, 56 | count 57 | )(files) 58 | 59 | const testFilesTraversed = countWith({ dependsOnFiles: is }, files) 60 | 61 | progress.setContent( 62 | pipe( 63 | push( 64 | `${isLoaded ? "{bold}{green-fg}✓{/green-fg}" : " "} Scanning ... ${ 65 | files.length 66 | } test files found{/}` 67 | ), 68 | push("{007-fg}"), 69 | push(""), 70 | push(" // Files matching:"), 71 | push(map(prepend(" // - "), fileGlob)), 72 | push("{/}"), 73 | 74 | push(""), 75 | 76 | push( 77 | `{bold}${ 78 | isFileDependencyScanFinished ? "{green-fg}✓{/green-fg}" : " " 79 | } Building dependency tree ... ${testFilesTraversed}/${ 80 | files.length 81 | } files{/}` 82 | ), 83 | push(""), 84 | push( 85 | `{007-fg} // Will watch for changes of ${sourceFileCount} source files{/}` 86 | ), 87 | 88 | flatten, 89 | join("\n") 90 | )([]) 91 | ) 92 | }, 93 | ] 94 | } 95 | 96 | module.exports = { loaderPage } 97 | --------------------------------------------------------------------------------