diff --git a/README.md b/README.md index 7cca5bc..a759ace 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ There is a list of built-int `formatters` to customize output: | Package | Version | |--------|-------| | [`@supertape/formatter-tap`](/packages/formatter-tap) | [![npm](https://img.shields.io/npm/v/@supertape/formatter-tap.svg?maxAge=86400)](https://www.npmjs.com/package/@supertape/formatter-tap) | +| [`@supertape/formatter-time`](/packages/formatter-time) | [![time](https://img.shields.io/npm/v/@supertape/formatter-time.svg?maxAge=86400)](https://www.npmjs.com/package/@supertape/formatter-time) | | [`@supertape/formatter-fail`](/packages/formatter-fail) | [![npm](https://img.shields.io/npm/v/@supertape/formatter-fail.svg?maxAge=86400)](https://www.npmjs.com/package/@supertape/formatter-fail) | | [`@supertape/formatter-short`](/packages/formatter-short) | [![npm](https://img.shields.io/npm/v/@supertape/formatter-short.svg?maxAge=86400)](https://www.npmjs.com/package/@supertape/formatter-short) | | [`@supertape/formatter-progress-bar`](/packages/formatter-progress-bar) | [![npm](https://img.shields.io/npm/v/@supertape/formatter-progress-bar.svg?maxAge=86400)](https://www.npmjs.com/package/@supertape/formatter-progress-bar) | diff --git a/packages/formatter-time/.eslintrc.json b/packages/formatter-time/.eslintrc.json new file mode 100644 index 0000000..c182222 --- /dev/null +++ b/packages/formatter-time/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": [ + "plugin:n/recommended", + "plugin:putout/recommended" + ], + "plugins": [ + "n", + "putout" + ] +} diff --git a/packages/formatter-time/.gitignore b/packages/formatter-time/.gitignore new file mode 100644 index 0000000..ed08967 --- /dev/null +++ b/packages/formatter-time/.gitignore @@ -0,0 +1,8 @@ +*.swp +node_modules +.nyc_output +yarn-error.log +.*.swp + +coverage +.idea diff --git a/packages/formatter-time/.madrun.mjs b/packages/formatter-time/.madrun.mjs new file mode 100644 index 0000000..c3948bf --- /dev/null +++ b/packages/formatter-time/.madrun.mjs @@ -0,0 +1,13 @@ +import {run} from 'madrun'; + +export default { + 'test': () => `supertape 'lib/**/*.spec.js'`, + 'watch:test': async () => `nodemon -w lib -w test -x "${await run('test')}"`, + 'lint': () => 'putout .', + 'fresh:lint': () => run('lint', '--fresh'), + 'lint:fresh': () => run('lint', '--fresh'), + 'fix:lint': () => run('lint', '--fix'), + 'coverage': () => `c8 npm test`, + 'report': () => 'c8 report --reporter=lcov', + 'wisdom': () => run(['lint', 'coverage']), +}; diff --git a/packages/formatter-time/.npmignore b/packages/formatter-time/.npmignore new file mode 100644 index 0000000..0df6abf --- /dev/null +++ b/packages/formatter-time/.npmignore @@ -0,0 +1,8 @@ +.* +*.spec.js +test +yarn-error.log +coverage + +fixture + diff --git a/packages/formatter-time/.nycrc.json b/packages/formatter-time/.nycrc.json new file mode 100644 index 0000000..9242a55 --- /dev/null +++ b/packages/formatter-time/.nycrc.json @@ -0,0 +1,9 @@ +{ + "check-coverage": true, + "all": true, + "exclude": [".*", "{bin,lib}/**/{fixture,*.spec.{js,mjs}}"], + "branches": 100, + "lines": 100, + "functions": 100, + "statements": 100 +} diff --git a/packages/formatter-time/.putout.json b/packages/formatter-time/.putout.json new file mode 100644 index 0000000..ce0e1da --- /dev/null +++ b/packages/formatter-time/.putout.json @@ -0,0 +1,7 @@ +{ + "match": { + "*.md": { + "remove-unreachable-code": "off" + } + } +} diff --git a/packages/formatter-time/LICENSE b/packages/formatter-time/LICENSE new file mode 100644 index 0000000..eaec9a1 --- /dev/null +++ b/packages/formatter-time/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) coderaiser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/formatter-time/README.md b/packages/formatter-time/README.md new file mode 100644 index 0000000..eee7bdd --- /dev/null +++ b/packages/formatter-time/README.md @@ -0,0 +1,31 @@ +# @supertape/formatter-time [![NPM version][NPMIMGURL]][NPMURL] + +[NPMIMGURL]: https://img.shields.io/npm/v/@supertape/formatter-time.svg?style=flat&longCache=true +[NPMURL]: https://npmjs.org/package/@supertape/formatter-time "npm" + +📼[`Supertape`](https://github.com/coderaiser/supertape) formatter shows progress bar. + +## Install + +``` +npm i supertape @supertape/formatter-time +``` + +## Usage + +``` +supertape --format time lib +``` + +## Env Variables + +- `CI=1` - disable progress bar +- `SUPERTAPE_TIME=1` - force enable/disable progress bar; +- `SUPERTAPE_TIME_COLOR` - set color of progress bar; +- `SUPERTAPE_TIME_MIN=100` - count of tests to show progress bar; +- `SUPERTAPE_TIME_STACK=1` - force show/hide stack on fail; +- `SUPERTAPE_TIME_CLOCK=⏳` - set clock icon; + +## License + +MIT diff --git a/packages/formatter-time/lib/time.js b/packages/formatter-time/lib/time.js new file mode 100644 index 0000000..8133baa --- /dev/null +++ b/packages/formatter-time/lib/time.js @@ -0,0 +1,236 @@ +import {Writable} from 'node:stream'; +import cliProgress from 'cli-progress'; +import chalk from 'chalk'; +import fullstore from 'fullstore'; +import {isCI} from 'ci-info'; +import process from 'node:process'; +import {Timer} from 'timer-node'; + +global._isCI = isCI; + +const OK = '👌'; +const YELLOW = '#218bff'; + +const {red} = chalk; +const formatErrorsCount = (a) => a ? red(a) : OK; + +const isStr = (a) => typeof a === 'string'; + +const {stderr} = process; + +let SUPERTAPE_TIME; +let SUPERTAPE_TIME_MIN = 100; +let SUPERTAPE_TIME_COLOR; +let SUPERTAPE_TIME_STACK = 1; +let SUPERTAPE_TIME_CLOCK = '⏳'; + +export function createFormatter(bar) { + ({ + SUPERTAPE_TIME, + SUPERTAPE_TIME_MIN = 100, + SUPERTAPE_TIME_COLOR, + SUPERTAPE_TIME_STACK = 1, + SUPERTAPE_TIME_CLOCK = '⏳', + } = process.env); + + const out = createOutput(); + const store = fullstore(); + const barStore = fullstore(bar); + const timerStore = fullstore(); + + return { + start: start({ + barStore, + timerStore, + out, + }), + test: test({ + store, + }), + testEnd: testEnd({ + clock: SUPERTAPE_TIME_CLOCK, + barStore, + timerStore, + }), + fail: fail({ + out, + store, + }), + end: end({ + barStore, + out, + }), + }; +} + +export const start = ({barStore, timerStore, out}) => ({total}) => { + out('TAP version 13'); + + const color = SUPERTAPE_TIME_COLOR || YELLOW; + const {bar, timer} = _createProgress({ + total, + color, + test: '', + }); + + barStore(bar); + timerStore(timer); +}; + +export const test = ({store}) => ({test}) => { + store(`# ${test}`); +}; + +export const testEnd = ({barStore, clock, timerStore}) => ({count, total, failed, test}) => { + const timer = timerStore(); + + barStore().increment({ + count, + total, + test, + failed: formatErrorsCount(failed), + time: !timer ? '' : getTime({ + clock, + timer: timerStore(), + }), + }); +}; + +export const fail = ({out, store}) => ({at, count, message, operator, result, expected, output, errorStack}) => { + out(''); + out(store()); + out(`❌ not ok ${count} ${message}`); + out(' ---'); + out(` operator: ${operator}`); + + if (output) + out(output); + + if (!isStr(output)) { + out(' expected: |-'); + out(` ${expected}`); + out(' result: |-'); + out(` ${result}`); + } + + out(` ${at}`); + + if (SUPERTAPE_TIME_STACK !== '0') { + out(' stack: |-'); + out(errorStack); + } + + out(' ...'); + out(''); +}; + +export const end = ({barStore, out}) => ({count, passed, failed, skiped}) => { + barStore().stop(); + + out(''); + + out(`1..${count}`); + out(`# tests ${count}`); + out(`# pass ${passed}`); + + if (skiped) + out(`# ⚠️ skip ${skiped}`); + + out(''); + + if (failed) + out(`# ❌ fail ${failed}`); + + if (!failed) + out('# ✅ ok'); + + out(''); + out(''); + + return `\r${out()}`; +}; + +function createOutput() { + let output = []; + + return (...args) => { + const [line] = args; + + if (!args.length) { + const result = output.join('\n'); + + output = []; + + return result; + } + + output.push(line); + }; +} + +const getColorFn = (color) => { + if (color.startsWith('#')) + return chalk.hex(color); + + return chalk[color]; +}; + +const defaultStreamOptions = { + total: Infinity, +}; + +const getStream = ({total} = defaultStreamOptions) => { + const is = total >= SUPERTAPE_TIME_MIN; + + if (is && !global._isCI || SUPERTAPE_TIME === '1') + return stderr; + + return new Writable(); +}; + +export const _getStream = getStream; + +function _createProgress({total, color, test}) { + const timer = new Timer({ + label: 'supertape-timer', + }); + + const colorFn = getColorFn(color); + const bar = new cliProgress.SingleBar({ + format: `${colorFn('{bar}')} {percentage}% | {failed} | {count}/{total} | {time} | {test}`, + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + clearOnComplete: true, + stopOnComplete: true, + hideCursor: true, + stream: getStream({ + total, + }), + }, cliProgress.Presets.react); + + bar.start(total, 0, { + test, + total, + count: 0, + failed: OK, + time: getTime({ + clock: SUPERTAPE_TIME_CLOCK, + timer, + }), + }); + + return { + bar, + timer, + }; +} + +export const maybeZero = (a) => a <= 9 ? '0' : ''; + +function getTime({clock, timer}) { + const {m, s} = timer.time(); + const minute = `${maybeZero(m)}${m}`; + const second = `${maybeZero(s)}${s}`; + + return `${clock} ${minute}:${second}`; +} diff --git a/packages/formatter-time/lib/time.spec.js b/packages/formatter-time/lib/time.spec.js new file mode 100644 index 0000000..b956259 --- /dev/null +++ b/packages/formatter-time/lib/time.spec.js @@ -0,0 +1,492 @@ +import montag from 'montag'; +import pullout from 'pullout'; +import { + test, + stub, + createTest, +} from 'supertape'; +import process from 'node:process'; +import * as time from './time.js'; + +const {env} = process; + +const pull = async (stream, i = 9) => { + const output = await pullout(stream); + const SLASH_R = 1; + + return output + .slice(SLASH_R) + .split('\n') + .slice(0, i) + .join('\n'); +}; + +test('supertape: format: time', async (t) => { + const successFn = (t) => { + t.ok(true); + t.end(); + }; + + const successMessage = 'time: success'; + + const failFn = (t) => { + t.ok(false); + t.end(); + }; + + const failMessage = 'time: fail'; + + const {CI} = env; + + env.CI = 1; + + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(successMessage, successFn); + test(failMessage, failFn); + + const [result] = await Promise.all([ + pull(stream, 10), + run(), + ]); + + env.CI = CI; + + const expected = montag` + TAP version 13 + + # time: fail + ❌ not ok 2 should be truthy + --- + operator: ok + expected: |- + true + result: |- + false + `; + + t.equal(result, expected); + t.end(); +}); + +test('supertape: format: time: diff', async (t) => { + const fn = (t) => { + t.equal(1, 2); + t.end(); + }; + + const message = 'time'; + + const {CI} = env; + + env.CI = 1; + + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(message, fn); + + const [result] = await Promise.all([ + pull(stream, 9), + run(), + ]); + + env.CI = CI; + + const expected = montag` + TAP version 13 + + # time + ❌ not ok 1 should equal + --- + operator: equal + diff: |- + - 2 + + 1 + `; + + t.equal(result, expected); + t.end(); +}); + +test('supertape: format: time: success', async (t) => { + const fn = (t) => { + t.ok(true); + t.end(); + }; + + const message = 'time: success'; + + env.CI = 1; + + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(message, fn); + + const [result] = await Promise.all([ + pull(stream, 8), + run(), + ]); + + env.CI = 1; + + const expected = montag` + TAP version 13 + + 1..1 + # tests 1 + # pass 1 + + # ✅ ok + ` + '\n'; + + t.equal(result, expected); + t.end(); +}); + +test('supertape: format: time: skip', async (t) => { + const fn = (t) => { + t.ok(true); + t.end(); + }; + + const message = 'skip: success'; + + const {CI} = env; + + env.CI = 1; + + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(message, fn, { + skip: true, + }); + + const [result] = await Promise.all([ + pull(stream, 8), + run(), + ]); + + env.CI = CI; + + const expected = montag` + TAP version 13 + + 1..0 + # tests 0 + # pass 0 + # ⚠️ skip 1 + + # ✅ ok + `; + + t.equal(result, expected); + t.end(); +}); + +test('supertape: format: time: color', async (t) => { + const fn = (t) => { + t.ok(true); + t.end(); + }; + + const message = 'progress-bar: color'; + const {SUPERTAPE_TIME_COLOR} = env; + + updateEnv({ + SUPERTAPE_TIME_COLOR: 'red', + }); + + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(message, fn); + + const [result] = await Promise.all([ + pull(stream, 8), + run(), + ]); + + updateEnv({ + SUPERTAPE_TIME_COLOR, + }); + + const expected = montag` + TAP version 13 + + 1..1 + # tests 1 + # pass 1 + + # ✅ ok + ` + '\n'; + + t.equal(result, expected); + t.end(); +}); + +test('supertape: format: time: color: hash', async (t) => { + const fn = (t) => { + t.ok(true); + t.end(); + }; + + const message = 'progress-bar: color'; + const {SUPERTAPE_TIME_COLOR} = env; + + updateEnv({ + SUPERTAPE_TIME_COLOR: 'undefined', + }); + + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(message, fn); + + const [result] = await Promise.all([ + pull(stream, 8), + run(), + ]); + + updateEnv({ + SUPERTAPE_TIME_COLOR, + }); + + const expected = montag` + TAP version 13 + + 1..1 + # tests 1 + # pass 1 + + # ✅ ok + ` + '\n'; + + t.equal(result, expected); + t.end(); +}); + +test('supertape: format: time: getStream: no SUPERTAPE_TIME', (t) => { + const {SUPERTAPE_TIME} = env; + + const {_isCI} = global; + + global._isCI = 1; + + updateEnv({ + SUPERTAPE_TIME: '0', + }); + + const stream = time._getStream({ + total: 1, + }); + + updateEnv({ + SUPERTAPE_TIME, + }); + + global._isCI = _isCI; + + t.notEqual(stream, process.stderr); + t.end(); +}); + +test('supertape: format: time: getStream: SUPERTAPE_TIME', (t) => { + const {SUPERTAPE_TIME} = env; + + const {_isCI} = global; + + global._isCI = false; + + updateEnv({ + SUPERTAPE_TIME: '1', + }); + + const stream = time._getStream({ + total: 100, + }); + + updateEnv({ + SUPERTAPE_TIME, + }); + + global._isCI = _isCI; + + t.equal(stream, process.stderr); + t.end(); +}); + +test('supertape: format: time: getStream: SUPERTAPE_TIME, no CI', (t) => { + const {CI, SUPERTAPE_TIME} = env; + + updateEnv({ + SUPERTAPE_TIME: '1', + }); + + delete global._isCI; + + const stream = time._getStream(); + + updateEnv({ + SUPERTAPE_TIME, + }); + + global._isCI = CI; + + t.equal(stream, process.stderr); + t.end(); +}); + +test('supertape: format: time: testEnd', (t) => { + const increment = stub(); + const SingleBar = stub().returns({ + start: stub(), + stop: stub(), + increment, + }); + + const bar = SingleBar(); + + const count = 1; + const total = 10; + const failed = 0; + const test = 'hi'; + + const {testEnd} = time.createFormatter(bar); + + testEnd({ + count, + total, + failed, + test, + }); + + const expected = [{ + count, + total, + failed: '👌', + test, + time: '', + }]; + + t.calledWith(increment, expected); + t.end(); +}); + +test('supertape: format: time: no stack', async (t) => { + const fn = (t) => { + t.ok(false); + t.end(); + }; + + const message = 'progress-bar: success'; + const {CI} = env; + + updateEnv({ + SUPERTAPE_TIME_STACK: '0', + }); + + global._isCI = true; + const { + run, + test, + stream, + } = await createTest({ + formatter: time, + }); + + test(message, fn); + + const [output] = await Promise.all([ + pull(stream, 19), + run(), + ]); + + const result = output.replace(/at .+\n/, 'at xxx\n'); + + delete env.SUPERTAPE_TIME_STACK; + env.CI = CI; + + const expected = montag` + TAP version 13 + + # progress-bar: success + ❌ not ok 1 should be truthy + --- + operator: ok + expected: |- + true + result: |- + false + at xxx + ... + + + 1..1 + # tests 1 + # pass 0 + + # ❌ fail 1 + `; + + t.equal(result, expected); + t.end(); +}); + +test('formatter: time: maybeZero: no', (t) => { + const result = time.maybeZero(60); + const expected = ''; + + t.equal(result, expected); + t.end(); +}); + +test('formatter: time: maybeZero: yes', (t) => { + const result = time.maybeZero(5); + const expected = '0'; + + t.equal(result, expected); + t.end(); +}); + +function updateEnv(env) { + for (const [name, value] of Object.entries(env)) { + if (value === 'undefined') + delete process.env[name]; + else + process.env[name] = value; + } +} diff --git a/packages/formatter-time/package.json b/packages/formatter-time/package.json new file mode 100644 index 0000000..a9f00cf --- /dev/null +++ b/packages/formatter-time/package.json @@ -0,0 +1,57 @@ +{ + "name": "@supertape/formatter-time", + "version": "1.0.0", + "author": "coderaiser (https://github.com/coderaiser)", + "description": "📼 Supertape formatter progress bar", + "homepage": "http://github.com/coderaiser/supertape", + "main": "./lib/time.js", + "release": false, + "tag": false, + "changelog": false, + "type": "module", + "repository": { + "type": "git", + "url": "git://github.com/coderaiser/supertape.git" + }, + "scripts": { + "test": "madrun test", + "watch:test": "madrun watch:test", + "lint": "madrun lint", + "fix:lint": "madrun fix:lint", + "coverage": "madrun coverage", + "report": "madrun report", + "wisdom": "madrun wisdom" + }, + "dependencies": { + "chalk": "^4.1.0", + "ci-info": "^4.0.0", + "cli-progress": "^3.8.2", + "fullstore": "^3.0.0", + "once": "^1.4.0", + "timer-node": "^5.0.7" + }, + "keywords": [ + "formatter", + "time", + "supertape" + ], + "devDependencies": { + "c8": "^8.0.0", + "eslint": "^8.0.0-beta.0", + "eslint-plugin-n": "^16.0.1", + "eslint-plugin-putout": "^22.0.0", + "madrun": "^10.0.0", + "montag": "^1.0.0", + "nodemon": "^3.0.1", + "pullout": "^4.0.0", + "putout": "^34.0.0", + "supertape": "^9.0.0" + }, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/supertape/package.json b/packages/supertape/package.json index 00654e3..3d7d7f6 100644 --- a/packages/supertape/package.json +++ b/packages/supertape/package.json @@ -48,6 +48,7 @@ "@supertape/formatter-progress-bar": "^4.0.0", "@supertape/formatter-short": "^2.0.0", "@supertape/formatter-tap": "^3.0.0", + "@supertape/formatter-time": "^1.0.0", "@supertape/operator-stub": "^3.0.0", "cli-progress": "^3.8.2", "deep-equal": "^2.0.3",